Skip to content

feat(opencode): slash command support (#671)#753

Merged
tiann merged 4 commits into
tiann:mainfrom
swear01:feat/opencode-slash-commands
May 31, 2026
Merged

feat(opencode): slash command support (#671)#753
tiann merged 4 commits into
tiann:mainfrom
swear01:feat/opencode-slash-commands

Conversation

@swear01
Copy link
Copy Markdown
Contributor

@swear01 swear01 commented May 31, 2026

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.

  • Built-in menu (5 commands): /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.
  • /init expands to a HAPI-curated AGENTS.md generation prompt and goes through backend.prompt, so it runs as a normal turn and writes/refines AGENTS.md. Optional trailing args append as extra instructions.
  • /clear and /compact reply with a friendly not yet supported message because the OpenCode ACP backend exposes no equivalent RPC (verified in cli/src/agent/backends/acp/AcpSdkBackend.ts); avoiding silent state surprises is intentional.
  • Custom prompt directories follow the OpenCode upstream convention: ~/.config/opencode/command/ (XDG-aware) and <projectRoot>/.opencode/command/. Project-level beats user-level, same precedence as Claude/Codex.
  • /help and /status use markdown so the web composer's MarkdownText (remark-gfm) renders them as a bullet list / code spans instead of one squashed paragraph.
  • Slash-reply ordering stamps invokedAt (via emitMessagesConsumed) before the agent reply. The web sorts the chat by invokedAt ?? createdAt (web/src/lib/messages.ts), so doing the reply first made it visually float above the user prompt.
  • Spinner fix: when the runner handles a slash command synchronously it never calls onThinkingChange(true), so the hub's 15s queued-thinking grace used to leave the composer spinner lit for the full window. New sessionCache.clearQueuedThinkingGrace runs on messages-consumed, and the runner pushes thinking=false right after a handled slash reply so the UI updates promptly without waiting for the next 2s keepalive tick.

Notes for reviewers

Test plan

  • pnpm typecheck (cli / web / hub)
  • OpenCode resolver unit tests (16 tests in cli/src/opencode/utils/slashCommands.test.ts)
  • listSlashCommands integration tests cover OpenCode built-ins, user dir (XDG-aware), project dir, project-overrides-builtin (cli/src/modules/common/slashCommands.test.ts)
  • Manual smoke: start an OpenCode session, type `/` and confirm the 5 built-ins appear; run `/status`, `/help`, `/plan`, `/plan off`, `/init`, `/clear`, `/compact` and verify replies, ordering, and that the composer spinner clears immediately after slash replies
  • Manual smoke: drop a custom `.opencode/command/.md` and verify `/` expands as a replace

🤖 Generated with Claude Code

swear01 and others added 2 commits May 30, 2026 20:00
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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Cancel can miss OpenCode messages while slash-command resolution is still pending — onUserMessage now waits for listSlashCommands() before the message is inserted into MessageQueue2, but the cancel RPC still only calls messageQueue.cancelByLocalId(). If the user cancels during that async window, the CLI acks removed: 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): bun is not installed in this runner (bun: command not found).

HAPI Bot

};
try {
let text = message.content.text;
const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] messages-consumed now clears queued-thinking grace for normal turns — why it matters: 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; the new behavior should be limited to slash commands that are handled synchronously and will never call onThinkingChange(true). Evidence hub/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 default updates HAPI state but leaves OpenCode on the previous backend model — why it matters: the new 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. Evidence cli/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): bun is 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): bun is not installed in this runner (bun: command not found).

HAPI Bot

@tiann tiann merged commit 5b797bb into tiann:main May 31, 2026
2 checks passed
heavygee added a commit to heavygee/hapi that referenced this pull request May 31, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OpenCode: slash commands not working

2 participants