Status: Diagnosis only — no code change yet.
Date: 2026-07-19
Related PR: #818 (fix/preset-tui-slash-command) — same root class as the original /preset fix.
Suspected sibling bug reported by user: During superpowers / brainstorm skill conversations, when the orchestrator asks for confirmation or work is interrupted (subagent completes, background task finishes), a build agent turn sometimes appears with an empty user input.
The build agent turn with empty input is the same class of bug as the original /preset issue fixed in #818: a plugin hook calls sessionSdk.promptAsync({ body: { parts: [createInternalAgentTextPart(...)] } }) without specifying an agent field. opencode then resolves the agent via agents.defaultInfo(), which falls back to the built-in build agent whenever default_agent is unset, user-overridden, or not effectively applied. The synthetic: true flag hides the injected text from the TUI, so the user perceives the build turn as having "empty input."
Orchestrator enters input-wait. After emitting a confirmation question (skill flow), the assistant turn finishes. opencode's per-session Runner transitions to Idle (packages/opencode/src/effect/runner.ts:115-138, packages/opencode/src/session/run-state.ts:60-63). The session is no longer "busy" from the Runner's perspective.
Plugin hook fires promptAsync with a synthetic part and no agent field. Two call sites in omos do this:
src/hooks/task-session-manager/index.ts:398-402 — CONTINUATION_NUDGE injection, fires on session.idle / session.status(idle) when the orchestrator session has incomplete todos (matches "subagent completes" / "background task finishes").src/interview/service.ts:622, 871, 933, 1007 — interview/skill flow injections (matches "brainstorm skill flow").opencode does not guard promptAsync against busy/input-wait state. The HTTP handler at packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329 does not call assertNotBusy and does not consult the Question service. It proceeds straight to promptSvc.prompt. The Runner, being Idle, immediately startRuns the new turn (runner.ts:131-134). No queue, no reject, no cancellation of the pending question.
Agent resolves to build. packages/opencode/src/session/prompt.ts:636-637:
const agentName = input.agent
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
When input.agent is omitted, opencode uses agents.defaultInfo() (packages/opencode/src/agent/agent.ts:328-340), which returns the first visible mode: "primary" agent — build (declared first in the agent registry, agent.ts:141-155). omos attempts to set default_agent = "orchestrator" via its config hook (src/index.ts:546-551), but only when default_agent is absent. build is selected whenever:
config.setDefaultAgent === false (plugin config disables it)opencode.json already sets a different default_agentconfig hook didn't run or didn't apply (SDK/runtime version skew: plugin built against @opencode-ai/sdk v1.4.3, installed runtime v1.18.3)"Empty input" is the synthetic flag's visual effect. synthetic: true only controls TUI visibility. packages/opencode/src/session/message-v2.ts:206 still includes synthetic text parts in model messages (the only filters are !part.ignored and part.text !== ""; there is no synthetic filter when building model messages). The user sees the build agent respond to a turn with no visible user message — perceived as "empty input." (createInternalAgentTextPart appends a \n<!-- SLIM_INTERNAL_INITIATOR --> marker, so the text is non-empty from the LLM's perspective.)
Session agent is durably corrupted. packages/opencode/src/session/prompt.ts:672-689 compares current.agent !== info.agent and, if they differ, calls sessions.setAgentModel({ agent: info.agent, ... }). A single agent-less promptAsync that resolves to build permanently rewrites the session's agent to build — every subsequent turn also routes to build until explicitly reset.
| File:line | Trigger | Body omits agent? |
Gate |
|---|---|---|---|
src/hooks/task-session-manager/index.ts:398-402 |
session.idle / session.status(idle) on orchestrator session with incomplete todos |
Yes | continuationConsumed, hasInputWait (3×: lines 282, 362, 386), isCurrentContinuation, isFallbackInProgress, backgroundJobBoard.hasTerminalUnreconciled |
src/interview/service.ts:622 |
User submits interview dashboard input | Yes | sessionBusy lock, interview active state |
src/interview/service.ts:871 |
User submits interview chat | Yes | same |
src/interview/service.ts:933 |
User submits interview answer | Yes | same |
src/interview/service.ts:1007 |
User submits interview comment | Yes | same |
src/interview/service.ts:504 |
Interview URL notification | Yes (but noReply: true, non-synthetic text) |
none |
src/tools/smartfetch/secondary-model.ts:252 |
Smartfetch secondary model query | Yes | none |
src/hooks/foreground-fallback/index.ts:635-639 explicitly includes the agent:
const promptBody = {
parts: lastUser.parts,
model: ref,
...(agentName ? { agent: agentName } : {}),
};
This is the pattern every promptAsync caller in omos should follow.
hasInputWait gate in task-session-manager is not sufficientThe gate exists and works in the common case (task-session-manager/index.ts:282, 362, 386, with tests at index.test.ts:2772-2858, 3013-3048). But:
Documented race window. IDLE_RECONCILE_DELAY_MS = 2_000 (line 54). The comment at lines 49-53 admits: "Completions arriving after the window are still dropped (the race is reduced, not eliminated)." If session.idle fires and the 2s timer expires before question.asked is delivered, and the 3 SDK calls (todo/children/status) in evaluateContinuation all resolve before question.asked arrives, the nudge fires.
Input-wait is not the only trigger. The interview/skill path (src/interview/service.ts) does not consult hasInputWait at all — it injects on user dashboard actions, which can happen while the orchestrator is mid-question.
The gate does not address the missing agent field. Even when the nudge legitimately fires (no input-wait, real incomplete todos), the resulting turn still routes to build if default_agent is unset. The gate prevents some unwanted injections; it does not prevent misrouting when injection happens.
/preset fix#818's original bug: /preset used createInternalAgentTextPart() to trigger an LLM turn that was invisible in the TUI (synthetic: true). The fix moved /preset to pure TUI dialogs (src/tui-preset.ts uses only api.ui.dialog / DialogSelect / DialogPrompt / DialogConfirm — no promptAsync).
This bug: other hooks still use the same createInternalAgentTextPart + promptAsync pattern, and additionally omit the agent field, so the invisible turn routes to build instead of the orchestrator. Same shape: a synthetic part starting an invisible turn. Different symptom: build agent instead of orchestrator.
Add agent: 'orchestrator' to the promptAsync body at all four affected call sites:
src/hooks/task-session-manager/index.ts:398-402src/interview/service.ts:622, 871, 933, 1007This ensures the continuation nudge and interview injections always route to the orchestrator regardless of opencode's default_agent resolution, eliminating the path to build.
hasInputWait (or an equivalent signal) before injecting in src/interview/service.ts. Do not inject while the orchestrator is waiting for user input.promptAsync, assert current.agent was not changed out from under the orchestrator; if it was, restore it via setAgentModel.default_agent application reliability on opencode v1.18.x. The plugin was built against @opencode-ai/sdk v1.4.3; the installed runtime is v1.18.3. The config hook's default_agent = 'orchestrator' mutation may not be applied reliably under this skew. (Note: #799 tracks the package upgrade.)IDLE_RECONCILE_DELAY_MS race for sessions that have a pending question.asked / permission.asked.agent field (the bug): src/hooks/task-session-manager/index.ts:398-402agent field (skill flow): src/interview/service.ts:622, 871, 933, 1007src/hooks/foreground-fallback/index.ts:635-639default_agent only when absent: src/index.ts:546-551createInternalAgentTextPart produces synthetic: true: src/utils/internal-initiator.ts:9-21CONTINUATION_NUDGE is non-empty: src/hooks/task-session-manager/index.ts:56-57hasInputWait gate (3 checks): src/hooks/task-session-manager/index.ts:282, 362, 386IDLE_RECONCILE_DELAY_MS race window: src/hooks/task-session-manager/index.ts:54 (admission at lines 49-53)disableDefaultAgents preserves build and plan: src/cli/config-io.ts:564-600anomalyco/opencode @ dev)promptAsync HTTP handler (no busy/input-wait guard): packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:311-329prompt HTTP handler (no guard): same file, :295-309prompt always starts a new turn: packages/opencode/src/session/prompt.ts:1052-1071input.agent ?? defaultInfo(): packages/opencode/src/session/prompt.ts:636-637defaultInfo() → default_agent or first visible primary: packages/opencode/src/agent/agent.ts:328-340build agent definition (default primary, first in registry): packages/opencode/src/agent/agent.ts:141-155setAgentModel overwrite on agent change: packages/opencode/src/session/prompt.ts:672-689packages/opencode/src/session/message-v2.ts:206ensureRunning (Idle = run now, no queue): packages/opencode/src/effect/runner.ts:115-138packages/opencode/src/session/run-state.ts:60-63assertNotBusy (NOT used by prompt/promptAsync): packages/opencode/src/session/run-state.ts:71-75default_agent doc: "Falls back to 'build' if not set or invalid": node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1255-1257SessionPromptAsyncData.body.agent? is optional: node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:3241-3269build is a built-in agent: node_modules/@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:1273-1279MERGEABLE / CLEAN / CI green; amending widens its scope and may delay merge.)default_agent application (fix direction #3) as part of this work, or track it separately under #799?This report is diagnostic only. No code was changed. The fix awaits the user's decision on scope and PR strategy.