Skip to content

Commit e56a6ff

Browse files
committed
fix(tui): preserve plugin agents after /connect by serializing bootstrap
Two concurrent bootstrap() calls race after /connect completes auth: 1. dialog-provider.tsx: instance.dispose() then sync.bootstrap() immediately 2. disposeMiddleware fires server.instance.disposed async (post-response), triggering a second bootstrap() If app.agents resolves before plugin.init() completes, setStore('agent') overwrites the store with only built-in agents (build/plan), losing all oh-my-opencode agents. Fix (server): Agent.state now awaits plugin.init() before config.get(), ensuring external plugin config hooks have merged their agent definitions before the agent list is built from config. Fix (client): bootstrap() uses trailing-edge coalescing — any concurrent caller during an in-flight bootstrap queues one trailing run that fires after the current bootstrap finishes, guaranteeing agents are fetched after plugin.init() completes regardless of bootstrap call ordering. Also: server.instance.disposed event handler changed to fatal:false so a bootstrap failure from the async dispose event does not exit the TUI.
1 parent 6d6cfc9 commit e56a6ff

2 files changed

Lines changed: 115 additions & 92 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const layer = Layer.effect(
9090

9191
const state = yield* InstanceState.make<State>(
9292
Effect.fn("Agent.state")(function* (ctx) {
93+
// Must complete before config.get() so external plugin config hooks have merged their agent definitions.
94+
yield* plugin.init()
9395
const cfg = yield* config.get()
9496
const skillDirs = yield* skill.dirs()
9597
const whitelistedDirs = [

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 113 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
141141
event.subscribe((event, { workspace }) => {
142142
switch (event.type) {
143143
case "server.instance.disposed":
144-
void bootstrap()
144+
void bootstrap({ fatal: false })
145145
break
146146
case "permission.replied": {
147147
const requests = store.permission[event.properties.sessionID]
@@ -388,107 +388,128 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
388388
const exit = useExit()
389389
const args = useArgs()
390390

391+
// fatal:false callers (dispose events) queue a trailing run so agents are fetched
392+
// after plugin.init() completes, not during it.
393+
let bootstrapInFlight: Promise<void> | undefined
394+
let bootstrapQueued = false
395+
391396
async function bootstrap(input: { fatal?: boolean } = {}) {
397+
if (bootstrapInFlight) {
398+
bootstrapQueued = true
399+
return bootstrapInFlight
400+
}
392401
const fatal = input.fatal ?? true
393-
const workspace = project.workspace.current()
394-
const projectPromise = project.sync()
395-
const sessionListPromise = projectPromise.then(() => listSessions())
402+
const run = async () => {
403+
const workspace = project.workspace.current()
404+
const projectPromise = project.sync()
405+
const sessionListPromise = projectPromise.then(() => listSessions())
396406

397-
// blocking - include session.list when continuing a session
398-
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
399-
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
400-
const consoleStatePromise = sdk.client.experimental.console
401-
.get({ workspace }, { throwOnError: true })
402-
.then((x) => x.data)
403-
.catch(() => emptyConsoleState)
404-
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
405-
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
406-
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
407-
{ name: "config.providers", promise: providersPromise },
408-
{ name: "provider.list", promise: providerListPromise },
409-
{ name: "app.agents", promise: agentsPromise },
410-
{ name: "config.get", promise: configPromise },
411-
{ name: "project.sync", promise: projectPromise },
412-
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
413-
]
407+
// blocking - include session.list when continuing a session
408+
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
409+
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true })
410+
const consoleStatePromise = sdk.client.experimental.console
411+
.get({ workspace }, { throwOnError: true })
412+
.then((x) => x.data)
413+
.catch(() => emptyConsoleState)
414+
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
415+
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
416+
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
417+
{ name: "config.providers", promise: providersPromise },
418+
{ name: "provider.list", promise: providerListPromise },
419+
{ name: "app.agents", promise: agentsPromise },
420+
{ name: "config.get", promise: configPromise },
421+
{ name: "project.sync", promise: projectPromise },
422+
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
423+
]
414424

415-
await Promise.allSettled(blockingRequests.map((r) => r.promise))
416-
.then((settled) => {
417-
// Surface every failed endpoint in one labeled message instead of
418-
// letting the first rejection drown its siblings as unhandled
419-
// rejections.
420-
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
421-
if (failure) throw failure
422-
})
423-
.then(async () => {
424-
const providersResponse = providersPromise.then((x) => x.data!)
425-
const providerListResponse = providerListPromise.then((x) => x.data!)
426-
const consoleStateResponse = consoleStatePromise
427-
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
428-
const configResponse = configPromise.then((x) => x.data!)
429-
const sessionListResponse = args.continue ? sessionListPromise : undefined
425+
await Promise.allSettled(blockingRequests.map((r) => r.promise))
426+
.then((settled) => {
427+
// Surface every failed endpoint in one labeled message instead of
428+
// letting the first rejection drown its siblings as unhandled
429+
// rejections.
430+
const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })))
431+
if (failure) throw failure
432+
})
433+
.then(async () => {
434+
const providersResponse = providersPromise.then((x) => x.data!)
435+
const providerListResponse = providerListPromise.then((x) => x.data!)
436+
const consoleStateResponse = consoleStatePromise
437+
const agentsResponse = agentsPromise.then((x) => x.data ?? [])
438+
const configResponse = configPromise.then((x) => x.data!)
439+
const sessionListResponse = args.continue ? sessionListPromise : undefined
430440

431-
return Promise.all([
432-
providersResponse,
433-
providerListResponse,
434-
consoleStateResponse,
435-
agentsResponse,
436-
configResponse,
437-
...(sessionListResponse ? [sessionListResponse] : []),
438-
]).then((responses) => {
439-
const providers = responses[0]
440-
const providerList = responses[1]
441-
const consoleState = responses[2]
442-
const agents = responses[3]
443-
const config = responses[4]
444-
const sessions = responses[5]
441+
return Promise.all([
442+
providersResponse,
443+
providerListResponse,
444+
consoleStateResponse,
445+
agentsResponse,
446+
configResponse,
447+
...(sessionListResponse ? [sessionListResponse] : []),
448+
]).then((responses) => {
449+
const providers = responses[0]
450+
const providerList = responses[1]
451+
const consoleState = responses[2]
452+
const agents = responses[3]
453+
const config = responses[4]
454+
const sessions = responses[5]
445455

446-
batch(() => {
447-
setStore("provider", reconcile(providers.providers))
448-
setStore("provider_default", reconcile(providers.default))
449-
setStore("provider_next", reconcile(providerList))
450-
setStore("console_state", reconcile(consoleState))
451-
setStore("agent", reconcile(agents))
452-
setStore("config", reconcile(config))
453-
if (sessions !== undefined) setStore("session", reconcile(sessions))
456+
batch(() => {
457+
setStore("provider", reconcile(providers.providers))
458+
setStore("provider_default", reconcile(providers.default))
459+
setStore("provider_next", reconcile(providerList))
460+
setStore("console_state", reconcile(consoleState))
461+
setStore("agent", reconcile(agents))
462+
setStore("config", reconcile(config))
463+
if (sessions !== undefined) setStore("session", reconcile(sessions))
464+
})
454465
})
455466
})
456-
})
457-
.then(() => {
458-
if (store.status !== "complete") setStore("status", "partial")
459-
// non-blocking
460-
void Promise.all([
461-
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
462-
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
463-
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
464-
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
465-
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
466-
sdk.client.experimental.resource
467-
.list({ workspace })
468-
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
469-
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
470-
sdk.client.session.status({ workspace }).then((x) => {
471-
setStore("session_status", reconcile(x.data ?? {}))
472-
}),
473-
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
474-
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
475-
project.workspace.sync(),
476-
]).then(() => {
477-
setStore("status", "complete")
467+
.then(() => {
468+
if (store.status !== "complete") setStore("status", "partial")
469+
// non-blocking
470+
void Promise.all([
471+
...(args.continue
472+
? []
473+
: [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
474+
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
475+
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))),
476+
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data ?? []))),
477+
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data ?? {}))),
478+
sdk.client.experimental.resource
479+
.list({ workspace })
480+
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
481+
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data ?? []))),
482+
sdk.client.session.status({ workspace }).then((x) => {
483+
setStore("session_status", reconcile(x.data ?? {}))
484+
}),
485+
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
486+
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
487+
project.workspace.sync(),
488+
]).then(() => {
489+
setStore("status", "complete")
490+
})
478491
})
479-
})
480-
.catch(async (e) => {
481-
Log.Default.error("tui bootstrap failed", {
482-
error: e instanceof Error ? e.message : String(e),
483-
name: e instanceof Error ? e.name : undefined,
484-
stack: e instanceof Error ? e.stack : undefined,
492+
.catch(async (e) => {
493+
Log.Default.error("tui bootstrap failed", {
494+
error: e instanceof Error ? e.message : String(e),
495+
name: e instanceof Error ? e.name : undefined,
496+
stack: e instanceof Error ? e.stack : undefined,
497+
})
498+
if (fatal) {
499+
await exit(e)
500+
} else {
501+
throw e
502+
}
485503
})
486-
if (fatal) {
487-
await exit(e)
488-
} else {
489-
throw e
490-
}
491-
})
504+
}
505+
bootstrapInFlight = run().finally(() => {
506+
bootstrapInFlight = undefined
507+
if (bootstrapQueued) {
508+
bootstrapQueued = false
509+
void bootstrap({ fatal: false })
510+
}
511+
})
512+
return bootstrapInFlight
492513
}
493514

494515
onMount(() => {

0 commit comments

Comments
 (0)