feat(opencode): slash command support (#671)#753
Conversation
Wire OpenCode sessions through the same slash-command pipeline Codex uses: register HAPI built-ins (/plan, /default, /status, /model, /reasoning, /permissions, /help, /clear, /compact) so the web autocomplete picks them up, scan ~/.config/opencode/command and <projectDir>/.opencode/command for custom prompts, and intercept slash text in runOpencode before it reaches the ACP backend. /clear and /compact reply with a not-yet-supported message because the OpenCode ACP backend exposes no equivalent RPC. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
Follow-up tweaks based on dogfooding the initial /671 cut: - Trim the OpenCode autocomplete menu to /help, /status, /plan, /default, /init. /model, /reasoning, and /permissions are already covered by composer buttons, so they stay in the resolver (typed input still works) but no longer clutter the suggestion popup. Aliases /effort and /permission and the not-yet-supported /clear, /compact are hidden the same way. - Add /init: expands a HAPI-curated AGENTS.md generation prompt and routes it through backend.prompt so the model writes the file in the normal turn flow. Optional trailing args are appended as extra instructions. - Format /help and /status as markdown so the web renders them as a list + code spans instead of one squashed paragraph (the composer uses MarkdownText with remark-gfm). - Fix slash-reply ordering by stamping invokedAt (emitMessagesConsumed) before sending the agent reply. The web sorts chat by invokedAt ?? createdAt, so the previous order made the reply float above the user prompt. - Clear the 15s queued-thinking grace on the hub when messages-consumed arrives, and push thinking=false from the runner right after a handled slash reply. Otherwise the composer spinner stayed lit for the full grace window because slash commands never call onThinkingChange(true). via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- [Major] Cancel can miss OpenCode messages while slash-command resolution is still pending —
onUserMessagenow waits forlistSlashCommands()before the message is inserted intoMessageQueue2, but the cancel RPC still only callsmessageQueue.cancelByLocalId(). If the user cancels during that async window, the CLI acksremoved: false, the hub marks the row invoked, and this handler later pushes the message anyway. Evidence:cli/src/opencode/runOpencode.ts:140.
Suggested fix:const preparingLocalIds = new Set<string>(); const cancelledBeforeEnqueue = new Set<string>(); session.onUserMessage((message, localId) => { if (localId) preparingLocalIds.add(localId); userMessageChain = userMessageChain.then(async () => { const wasCancelled = () => localId !== undefined && cancelledBeforeEnqueue.delete(localId); try { if (wasCancelled()) return; const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []); if (wasCancelled()) return; // resolve slash command and enqueue/send replies here } finally { if (localId) preparingLocalIds.delete(localId); } }); }); session.onCancelQueuedMessage((localId) => { if (messageQueue.cancelByLocalId(localId)) return true; if (preparingLocalIds.has(localId)) { cancelledBeforeEnqueue.add(localId); return true; } return false; });
Questions
- None.
Summary
- Review mode: initial
- One OpenCode cancellation regression found in the new async slash-command path. Add coverage for cancelling a message before
listSlashCommands()resolves so it neither reaches OpenCode nor emits a handled slash reply.
Testing
- Not run (automation):
bunis not installed in this runner (bun: command not found).
HAPI Bot
| }; | ||
| try { | ||
| let text = message.content.text; | ||
| const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []); |
There was a problem hiding this comment.
[MAJOR] Cancel can miss OpenCode messages while slash-command resolution is still pending. This new async path waits for listSlashCommands() before inserting the message into MessageQueue2; during that window, onCancelQueuedMessage only checks the queue and returns removed: false. The hub then treats the row as already consumed, but this handler later pushes it to OpenCode anyway. Track in-flight localIds before the await and make cancellation return true for those ids, then skip enqueue/reply when the resolver resumes.\n\nSuggested fix:\nts\nconst preparingLocalIds = new Set<string>();\nconst cancelledBeforeEnqueue = new Set<string>();\n\nsession.onUserMessage((message, localId) => {\n if (localId) preparingLocalIds.add(localId);\n userMessageChain = userMessageChain.then(async () => {\n const wasCancelled = () => localId !== undefined && cancelledBeforeEnqueue.delete(localId);\n try {\n if (wasCancelled()) return;\n const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []);\n if (wasCancelled()) return;\n // resolve slash command and enqueue/send replies here\n } finally {\n if (localId) preparingLocalIds.delete(localId);\n }\n });\n});\n\nsession.onCancelQueuedMessage((localId) => {\n if (messageQueue.cancelByLocalId(localId)) return true;\n if (preparingLocalIds.has(localId)) {\n cancelledBeforeEnqueue.add(localId);\n return true;\n }\n return false;\n});\n
…nn#671) Reported by the HAPI Bot review on tiann#753. The new async onUserMessage chain awaits listSlashCommands before pushing to messageQueue or sending a slash reply. If onCancelQueuedMessage fired in that window the old handler only checked messageQueue, returned false, and the hub marked the row consumed — but the chain would still resume and either push the message to OpenCode or fire the /slash reply. Track in-flight localIds before the await; cancel returns true and marks them in cancelledBeforeEnqueue; the chain checks at every resume point (start, after listSlashCommands) and short-circuits without enqueue or reply. Adds a regression test that cancels a /status command mid-prep. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
-
[Major]
messages-consumednow clears queued-thinking grace for normal turns — why it matters:MessageQueue2emitsmessages-consumedas soon as a queued prompt is shifted, before OpenCode/Codex actually starts the backend turn. Clearing the 15s grace for every ack lets the next 2sthinking=falsekeepalive turn the spinner off during model/effort switching or prompt startup; the new behavior should be limited to slash commands that are handled synchronously and will never callonThinkingChange(true). Evidencehub/src/socket/handlers/cli/sessionHandlers.ts:292.
Suggested fix:// shared/src/socket.ts and cli/src/api/apiSession.ts type MessagesConsumedPayload = { sid: string localIds: string[] clearQueuedThinkingGrace?: boolean } // only the synchronous handled-slash path opts in session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }) // hub/src/socket/handlers/cli/sessionHandlers.ts if (data.clearQueuedThinkingGrace === true) { onMessagesConsumed?.(data.sid) }
-
[Major]
/model defaultupdates HAPI state but leaves OpenCode on the previous backend model — why it matters: the new resolver mapsdefault/autotonull, butrunOpencodeconvertsnulltoundefinedin the queuedOpencodeMode, andopencodeRemoteLauncheronly callsbackend.setModel()whenbatch.mode.modelis truthy. After switching to a custom model,/model defaultreports success and the UI shows default, while the next turn still runs on the custom backend model. Evidencecli/src/opencode/utils/slashCommands.ts:117.
Suggested fix:// cli/src/opencode/types.ts export interface OpencodeMode { permissionMode: PermissionMode; model?: string | null; modelReasoningEffort?: string | null; } // cli/src/opencode/runOpencode.ts const buildMode = (): OpencodeMode => ({ permissionMode: currentPermissionMode, model: sessionModel, modelReasoningEffort: sessionModelReasoningEffort }); // cli/src/opencode/opencodeRemoteLauncher.ts private defaultBackendModel: string | null = null; // after initialMetadata is read: this.defaultBackendModel = this.currentBackendModel; const requestedModel = batch.mode.model === null ? this.defaultBackendModel : batch.mode.model; if (requestedModel && requestedModel !== this.currentBackendModel) { await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' }); this.currentBackendModel = requestedModel; }
Questions
- None.
Summary
Review mode: follow-up after new commits
The previous cancellation issue is addressed, but the follow-up still introduces two regressions: normal queued turns can lose their thinking grace too early, and /model default does not actually reset the OpenCode backend model on the next turn.
Testing
- Not run (automation):
bunis not installed in this runner (bun: command not found).
HAPI Bot
| try { | ||
| store.messages.markMessagesInvoked(data.sid, localIds, invokedAt) | ||
| onSessionActivity?.(data.sid, invokedAt) | ||
| onMessagesConsumed?.(data.sid) |
There was a problem hiding this comment.
[MAJOR] messages-consumed now clears queued-thinking grace for normal turns. MessageQueue2 emits messages-consumed as soon as a queued prompt is shifted, before OpenCode/Codex actually starts the backend turn. Clearing the 15s grace for every ack lets the next 2s thinking=false keepalive turn the spinner off during model/effort switching or prompt startup; this should be limited to slash commands handled synchronously.
Suggested fix:
// shared/src/socket.ts and cli/src/api/apiSession.ts
type MessagesConsumedPayload = {
sid: string
localIds: string[]
clearQueuedThinkingGrace?: boolean
}
// only the synchronous handled-slash path opts in
session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true })
// hub/src/socket/handlers/cli/sessionHandlers.ts
if (data.clearQueuedThinkingGrace === true) {
onMessagesConsumed?.(data.sid)
}| if (!rest) { | ||
| return { kind: 'handled', message: `OpenCode model: ${state.model ?? 'default'}` }; | ||
| } | ||
| const model = rest === 'auto' || rest === 'default' ? null : rest; |
There was a problem hiding this comment.
[MAJOR] /model default updates HAPI state but leaves OpenCode on the previous backend model. The resolver maps default/auto to null, but runOpencode converts null to undefined in the queued OpencodeMode, and opencodeRemoteLauncher only calls backend.setModel() when batch.mode.model is truthy. After switching to a custom model, /model default reports success and the UI shows default, while the next turn still runs on the custom backend model.
Suggested fix:
// cli/src/opencode/types.ts
export interface OpencodeMode {
permissionMode: PermissionMode;
model?: string | null;
modelReasoningEffort?: string | null;
}
// cli/src/opencode/runOpencode.ts
const buildMode = (): OpencodeMode => ({
permissionMode: currentPermissionMode,
model: sessionModel,
modelReasoningEffort: sessionModelReasoningEffort
});
// cli/src/opencode/opencodeRemoteLauncher.ts
private defaultBackendModel: string | null = null;
// after initialMetadata is read:
this.defaultBackendModel = this.currentBackendModel;
const requestedModel = batch.mode.model === null
? this.defaultBackendModel
: batch.mode.model;
if (requestedModel && requestedModel !== this.currentBackendModel) {
await backend.setModel(acpSessionId, requestedModel, { flavor: 'opencode' });
this.currentBackendModel = requestedModel;
}Two regressions caught by HAPI Bot on commit a29d4a4: 1. **messages-consumed now wrongly cleared queued-thinking grace for normal turns.** MessageQueue2 emits messages-consumed as soon as a queued prompt is shifted, before backend.prompt starts; clearing the 15s grace there made the spinner flicker off between drain and turn start. Make the grace-clear opt-in: - `emitMessagesConsumed` takes an optional `{ clearQueuedThinkingGrace }` flag; payload carries it through to the hub. - The opencode handled-slash path passes the flag; the normal queue.onBatchConsumed callback does not. - Hub only calls `clearQueuedThinkingGrace` when the flag is set. 2. **/model default updated HAPI state but did not reset the OpenCode backend.** The resolver returns `model: null` for "reset"; runOpencode used to collapse that to `undefined` in OpencodeMode, and the launcher only switches when batch.mode.model is truthy. So the backend kept the previous model while the UI showed default. Fix: - OpencodeMode.model is now `string | null | undefined`. Null explicitly means "reset to the backend launch-time default"; undefined means "no change". - runOpencode buildMode propagates `sessionModel` (which can be null) directly; the queue hash treats null distinctly so reset batches keep their semantics. - opencodeRemoteLauncher captures defaultBackendModel after init and resolves `batch.mode.model === null` to that default before the setModel call. The resolved id is written back into batch.mode.model so downstream display logic sees the concrete value. Test seeds backend.getSessionModelsMetadata with a launch-time model, queues `/custom` then `null`, and asserts setModel fires for both the custom model and the launch-time default. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
There was a problem hiding this comment.
Findings
- No findings.
Questions
- None.
Summary
Review mode: follow-up after new commits
No high-confidence issues found in the latest diff. The prior bot findings around queued-thinking grace and /model default backend reset appear addressed in this head. Residual risk: OpenCode slash-command behavior was reviewed statically only because tests could not be run here.
Testing
- Not run (automation):
bunis not installed in this runner (bun: command not found).
HAPI Bot
… commands Upstream tiann#753 added opencode entries; this branch had opencode: [] which conflicts on driver soup rebuild after main advances. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Closes #671. Wires OpenCode sessions through the same slash-command pipeline Codex uses, scoped to what the OpenCode ACP backend can actually do today.
/help,/status,/plan [prompt],/default,/init [extra]. Everything else covered by composer buttons (model / reasoning / permissions) stays in the resolver — typed input still works — but isn't surfaced in the autocomplete so the menu stays focused./initexpands to a HAPI-curated AGENTS.md generation prompt and goes throughbackend.prompt, so it runs as a normal turn and writes/refinesAGENTS.md. Optional trailing args append as extra instructions./clearand/compactreply with a friendly not yet supported message because the OpenCode ACP backend exposes no equivalent RPC (verified incli/src/agent/backends/acp/AcpSdkBackend.ts); avoiding silent state surprises is intentional.~/.config/opencode/command/(XDG-aware) and<projectRoot>/.opencode/command/. Project-level beats user-level, same precedence as Claude/Codex./helpand/statususe markdown so the web composer'sMarkdownText(remark-gfm) renders them as a bullet list / code spans instead of one squashed paragraph.invokedAt(viaemitMessagesConsumed) before the agent reply. The web sorts the chat byinvokedAt ?? createdAt(web/src/lib/messages.ts), so doing the reply first made it visually float above the user prompt.onThinkingChange(true), so the hub's 15s queued-thinking grace used to leave the composer spinner lit for the full window. NewsessionCache.clearQueuedThinkingGraceruns onmessages-consumed, and the runner pushesthinking=falseright after a handled slash reply so the UI updates promptly without waiting for the next 2s keepalive tick.Notes for reviewers
cli/src/codex/utils/slashCommands.tsbut trims Codex-only branches (goal, codex CLI unsupported list).runOpencodeserializes the slash chain with auserMessageChainPromise (same pattern Codex uses atrunCodex.ts:151) so out-of-order results aren't possible if two messages arrive in the same tick./plan offgets replaced by/plan); affects every agent./help//statusstill use\njoins and render as a paragraph; this PR fixes OpenCode but the shared cleanup is left for a follow-up.current_token_usageupdates never relayed).skill_lookupMCP tool so\$skillautocomplete works for OpenCode / Gemini / Cursor (not in this PR).Test plan
pnpm typecheck(cli / web / hub)cli/src/opencode/utils/slashCommands.test.ts)listSlashCommandsintegration tests cover OpenCode built-ins, user dir (XDG-aware), project dir, project-overrides-builtin (cli/src/modules/common/slashCommands.test.ts)🤖 Generated with Claude Code