Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 86 additions & 82 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,94 +135,98 @@ export const TuiThreadCommand = cmd({
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
worker.onerror = (e) => {
Log.Default.error(e)
}

const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error(e)
}
const reload = () => {
client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
try {
worker.onerror = (e) => {
Log.Default.error(e)
}

const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error(e)
}
const reload = () => {
client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
})
})
})
}
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
process.on("SIGUSR2", reload)

let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", {
error: errorMessage(error),
}
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
process.on("SIGUSR2", reload)

let stopped = false
const stop = async () => {
if (stopped) return
stopped = true
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", {
error: errorMessage(error),
})
})
})
worker.terminate()
}

const prompt = await input(args.prompt)
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})

const network = await resolveNetworkOptions(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"

const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}

setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
worker.terminate()
}

try {
await tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
const prompt = await input(args.prompt)
const config = await Instance.provide({
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
fn: () => TuiConfig.get(),
})

const network = await resolveNetworkOptions(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"

const transport = external
? {
url: (await client.call("server", network)).url,
fetch: undefined,
events: undefined,
}
: {
url: "http://opencode.internal",
fetch: createWorkerFetch(client),
events: createEventSource(client),
}

setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()

try {
await tui({
url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
})
} finally {
await stop()
}
} finally {
await stop()
worker.terminate()
}
} finally {
unguard?.()
Expand Down
33 changes: 30 additions & 3 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { ModelsDev } from "../provider/models"
Expand All @@ -23,7 +23,7 @@ import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
import { constants, existsSync, readFileSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
Expand Down Expand Up @@ -393,13 +393,40 @@ export namespace Config {
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
function findPackageJsonName(filePath: string): string | undefined {
let dir = path.dirname(filePath)
const root = path.parse(dir).root

for (let i = 0; i < 5 && dir !== root; i++) {
const pkgPath = path.join(dir, "package.json")
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
if (pkg.name && typeof pkg.name === "string") {
return pkg.name
}
} catch {}
}
dir = path.dirname(dir)
}
return undefined
}

function getPluginIdentity(spec: string): string {
if (spec.startsWith("file://")) {
const filePath = fileURLToPath(spec)
return findPackageJsonName(filePath) ?? spec
}
return parsePluginSpecifier(spec).pkg
}

export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
const seenNames = new Set<string>()
const uniqueSpecifiers: PluginSpec[] = []

for (const specifier of plugins.toReversed()) {
const spec = pluginSpecifier(specifier)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
const name = getPluginIdentity(spec)
if (!seenNames.has(name)) {
seenNames.add(name)
uniqueSpecifiers.push(specifier)
Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import { JsonMigration } from "./storage/json-migration"
import { Database } from "./storage/db"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { runCleanup } from "./util/cleanup"
import { Instance } from "./project/instance"
import { setNonDumpable } from "./util/security"

setNonDumpable()

process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
Expand Down Expand Up @@ -209,9 +214,16 @@ try {
}
process.exitCode = 1
} finally {
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
// Explicitly exit to avoid any hanging subprocesses.
// Multi-phase graceful shutdown:
// 1. Run global cleanup registry (2s timeout)
// 2. Dispose all instances (2s timeout)
// 3. Failsafe: force exit after 5s total
const failsafe = setTimeout(() => process.exit(process.exitCode ?? 0), 5000)
failsafe.unref?.()
try {
await runCleanup(2000)
await Promise.race([Instance.disposeAll(), new Promise((r) => setTimeout(r, 2000))])
} catch {}
clearTimeout(failsafe)
process.exit()
}
8 changes: 8 additions & 0 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,11 +523,19 @@ export namespace MCP {
const pid = (client.transport as any)?.pid
if (typeof pid === "number") {
const pids = yield* descendants(pid)
// Signal escalation: SIGTERM → wait 400ms → SIGKILL
for (const dpid of pids) {
try {
process.kill(dpid, "SIGTERM")
} catch {}
}
yield* Effect.sleep("400 millis")
for (const dpid of pids) {
try {
process.kill(dpid, 0)
process.kill(dpid, "SIGKILL")
} catch {}
}
}
yield* Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
}),
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,19 +331,22 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
pendingOAuth = undefined
reject(new Error("OAuth callback timeout - authorization took too long"))
}
stopOAuthServer()
},
5 * 60 * 1000,
) // 5 minute timeout
)

pendingOAuth = {
pkce,
state,
resolve: (tokens) => {
clearTimeout(timeout)
stopOAuthServer()
resolve(tokens)
},
reject: (error) => {
clearTimeout(timeout)
stopOAuthServer()
reject(error)
},
}
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { APICallError } from "ai"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"
import * as ConnectionError from "@/util/connection-error"

export namespace ProviderError {
// Adapted from overflow detection patterns in:
Expand Down Expand Up @@ -48,6 +49,9 @@ export namespace ProviderError {

function message(providerID: ProviderID, e: APICallError) {
return iife(() => {
const conn = ConnectionError.extract(e)
if (conn) return ConnectionError.format(conn)

const msg = e.message
if (msg === "") {
if (e.responseBody) return e.responseBody
Expand All @@ -71,15 +75,15 @@ export namespace ProviderError {
}
} catch {}

// If responseBody is HTML (e.g. from a gateway or proxy error page),
// provide a human-readable message instead of dumping raw markup
if (/^\s*<!doctype|^\s*<html/i.test(e.responseBody)) {
if (e.statusCode === 401) {
return "Unauthorized: request was blocked by a gateway or proxy. Your authentication token may be missing or expired — try running `opencode auth login <your provider URL>` to re-authenticate."
}
if (e.statusCode === 403) {
return "Forbidden: request was blocked by a gateway or proxy. You may not have permission to access this resource — check your account and provider settings."
}
const title = e.responseBody.match(/<title>([^<]+)<\/title>/i)?.[1]?.trim()
if (title) return title
return msg
}

Expand Down
Loading
Loading