From 99aac9cff9c687cea3f217dcf56da565a1993c4f Mon Sep 17 00:00:00 2001 From: omartelo Date: Tue, 11 Aug 2026 14:45:06 -0300 Subject: [PATCH] fix: ring the bell when an opencode session asks a question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opencode session blocked on an interactive question looked exactly like one that was working: spinner, and the tool line reading `question`. No bell, no toast, no desktop notification — the session waited until somebody happened to look at it. Only permissions were reported. A question rides an event of its own, `question.asked`, and the module listened for `permission.asked` by name, so the last thing the card heard was the `busy` from the tool call that asked. A run against a stub confirms nothing follows it: opencode reports no status at all while the question is pending, so the spinner is not stale for a moment, it is stale for as long as the user takes. Matched by suffix rather than by name. opencode asks in four spellings today — `permission.asked`, `question.asked` and a `.v2.` variant of each — and its catalogue is not exhaustive: a real run emits types (`server.heartbeat`) that its published schema does not list. Enumerating names is precisely how this bug happened, and the failure modes are not symmetric. A surplus bell is cleared by the next status report, which a reply produces within ~100ms; a name nobody enumerated is a session waiting behind a card that says it is busy. Neither `question.replied` nor `question.rejected` is registered: both are followed by a status of their own — `busy` on an answer, `idle` on a dismissal — so the card re-arms itself, and a second report would only say what the first already did. --- CHANGELOG.md | 12 +++++++ docs/providers.md | 9 +++++- docs/session-state.md | 14 +++++++- opencode/lich.js | 18 ++++++++--- tests/opencode.test.mjs | 71 +++++++++++++++++++++++++++++++++++------ 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1624b..a69cd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **An opencode session that asks you a question now rings the bell.** Only its + permission prompts did: an interactive question rides an event of its own + (`question.asked`), which the plugin did not listen for, so the card kept the + spinner and the tool name of the question itself while the session sat waiting + — no bell, no toast, no desktop notification, and nothing to distinguish it + from work in progress. Every way opencode asks the user something now reports + `waiting`, matched by the shape of the event name rather than by a list of + them: its published catalogue is not exhaustive, and a prompt nobody + enumerated is a session that waits unseen. + ## [0.9.0] - 2026-08-11 ### Added diff --git a/docs/providers.md b/docs/providers.md index 108c600..654a5e5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -44,7 +44,7 @@ never awaits a report. | session id | `SessionStart` | `SessionStart` | `session.created` | `PreToolUse` | | `busy` | `UserPromptSubmit`, `PostToolUse` | `UserPromptSubmit`, `PostToolUse` | `session.status` (`busy`) | — | | `busy` + tool | `PreToolUse` | `PreToolUse` | `tool.execute.before` | — | -| `waiting` | `Notification` | `PermissionRequest` | `permission.asked` | — | +| `waiting` | `Notification` | `PermissionRequest` | any `*.asked` | — | | `done` | `Stop` | `Stop` | `session.status` (`idle`) | — | | title | `Stop` | `Stop` | `session.updated` | — | | `idle` | `SessionEnd` | — (registered, never fires) | — (nothing outlives it) | — | @@ -65,6 +65,13 @@ is ignored rather than rejected, so the report starts working the day Codex adds one. Until then a Codex card holds its last indicator (and its provider mark) until lich respawns that PTY. +opencode's `waiting` is the one cell that is a rule rather than an event name. +It asks the user in more than one way — `permission.asked` and `question.asked`, +each with a `.v2.` spelling published beside it — and its catalogue is not +exhaustive: a real run emits types (`server.heartbeat`) absent from its own +schema. Enumerating names there is how a prompt goes unreported, so the module +matches the suffix. See [session-state.md](session-state.md). + The session id carries the provider that reported it (`report-session-start.sh codex`), which is what puts Codex's icon rather than Claude's on the card, and what lets lich resume the conversation with `codex resume `. Both need lich diff --git a/docs/session-state.md b/docs/session-state.md index 05cc9b3..e6369a7 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -14,7 +14,7 @@ toast) when the agent is blocked on the user. | `report-tool.sh` | `busy` + `tool` | `PreToolUse` | `PreToolUse` | `tool.execute.before` | | `report-state.sh busy` | `busy` | `PostToolUse` | `PostToolUse` | `session.status` (`busy`) | | `report-state.sh done` | `done` | `Stop` | `Stop` | `session.status` (`idle`) | -| `report-state.sh waiting` | `waiting` | `Notification` | `PermissionRequest` | `permission.asked` | +| `report-state.sh waiting` | `waiting` | `Notification` | `PermissionRequest` | any `*.asked` | | `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) | — (never fires) | opencode runs no scripts: `opencode/lich.js` sends the same payloads off the @@ -35,6 +35,18 @@ There, the hook's own exit code is an approval decision — exit `2` would deny the request — so `report-state.sh` staying silent and always exiting 0 is what keeps the report an observation instead of an answer. +**opencode's `waiting` is matched by suffix, not by name.** It asks the user in +more than one way — a permission and an interactive question, each with a `.v2.` +spelling alongside — and its event catalogue is not exhaustive: a real run emits +types (`server.heartbeat`) that its own published schema does not list. So +`lich.js` reports `waiting` for any event type ending in `.asked` rather than for +a list of names. The failure modes are not symmetric: a surplus bell is cleared +by the next status report, which follows a reply within ~100ms, while a name +nobody enumerated leaves a session waiting behind a card that shows a spinner. +Answering (`question.replied` → `session.status busy`) and dismissing +(`question.rejected` → `session.status idle`) both re-arm the card on their own, +which is why neither is registered here. + `SessionEnd → idle` clears the card's indicator (no spinner/check/bell). It fires when the session ends or is reset, so a stale state does not linger on a dead session, and a `/clear` starts the next session with a clean card. Codex diff --git a/opencode/lich.js b/opencode/lich.js index 4586cf4..8e23f28 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -292,14 +292,22 @@ export const LichPlugin = async ({ $ } = {}) => { else if (type === "busy" || type === "retry") report("hook", { state: "busy" }) return } - case "permission.asked": { - if (subSessions.has(properties.sessionID)) return - report("hook", { state: "waiting" }) - return - } case "file.edited": report("session-touched", {}) return + default: + // Anything opencode *asked* the user is "your turn": a permission and + // a question today, each in two spellings (`permission.asked`, + // `question.asked`, and their `.v2.` variants). Matched by suffix + // rather than by name because opencode's event catalogue is not + // exhaustive — its server emits types its own schema does not list — + // and a prompt whose name is not here would leave the card silent, + // which is the failure this line exists for. A surplus bell costs the + // next status report, which follows a reply within ~100ms; a missing + // one costs the user the session. + if (event?.type?.endsWith(".asked") && !subSessions.has(properties.sessionID)) { + report("hook", { state: "waiting" }) + } } }, diff --git a/tests/opencode.test.mjs b/tests/opencode.test.mjs index d232e0c..9802259 100644 --- a/tests/opencode.test.mjs +++ b/tests/opencode.test.mjs @@ -108,19 +108,69 @@ for (const [type, state] of STATUS_STATES) { }) } -test('permission.asked reports waiting', async () => { +// Every way opencode 1.18.16 asks the user something. The first two were taken +// off a real run; the `.v2.` pair is published in the server's own schema and +// carries the same `sessionID`, so the suffix rule covers it the day it fires. +const ASKED_EVENTS = { + 'permission.asked': { + id: 'per_fee7bb506001U0pyEOIdUduVvD', + sessionID: OPENCODE_SESSION_ID, + permission: 'edit', + patterns: ['oc-probe.txt'], + metadata: { filepath: '/w/oc-probe.txt' }, + }, + 'question.asked': { + id: 'que_ff1dae01f001v4G2287HaQw71N', + sessionID: OPENCODE_SESSION_ID, + questions: [ + { + question: 'coffee or tea?', + header: 'Hot drink preference', + options: [{ label: 'Coffee', description: 'Go with coffee' }], + }, + ], + tool: { messageID: 'msg_ff1dac169001EiYr6PAjjcEL80', callID: 'call_e5dacd91b4a44eec' }, + }, + 'permission.v2.asked': { + id: 'per_fee7bb506001U0pyEOIdUduVvE', + sessionID: OPENCODE_SESSION_ID, + action: 'edit', + resources: ['oc-probe.txt'], + }, + 'question.v2.asked': { + id: 'que_ff1dae01f001v4G2287HaQw71T', + sessionID: OPENCODE_SESSION_ID, + questions: [{ question: 'coffee or tea?' }], + }, +} + +for (const [type, properties] of Object.entries(ASKED_EVENTS)) { + test(`${type} reports waiting`, async () => { + const requests = await reportsFor(({ event }) => event(type, properties)) + assert.equal(requests.length, 1) + const { body } = assertContractHonoured('/hook', requests[0]) + assert.equal(body.state, 'waiting') + }) +} + +// The point of the suffix rule: a prompt opencode adds later, under a name +// nothing here knows, still reaches the card. +test('an unknown .asked event reports waiting', async () => { const requests = await reportsFor(({ event }) => - event('permission.asked', { - id: 'per_fee7bb506001U0pyEOIdUduVvD', - sessionID: OPENCODE_SESSION_ID, - permission: 'edit', - patterns: ['oc-probe.txt'], - metadata: { filepath: '/w/oc-probe.txt' }, - }), + event('elicitation.asked', { id: 'eli_1', sessionID: OPENCODE_SESSION_ID }), ) assert.equal(requests.length, 1) - const { body } = assertContractHonoured('/hook', requests[0]) - assert.equal(body.state, 'waiting') + assert.equal(assertContractHonoured('/hook', requests[0]).body.state, 'waiting') +}) + +// ...and its bound: the suffix is the whole rule, so an event merely mentioning +// the word must not put a bell on the card. +test('an event that only contains "asked" reports nothing', async () => { + const requests = await reportsFor( + ({ event }) => event('question.asked.dismissed', { sessionID: OPENCODE_SESSION_ID }), + { expected: 0 }, + ) + assert.equal(requests.length, 0, `a non-prompt raised a bell: ${requests[0]?.raw}`) }) test('file.edited refreshes the git status', async () => { @@ -221,6 +271,7 @@ test('a sub-session finishing does not end the turn', async () => { await event('session.created', { sessionID: SUB_SESSION_ID, info: sub }) await event('session.status', { sessionID: SUB_SESSION_ID, status: { type: 'idle' } }) await event('permission.asked', { sessionID: SUB_SESSION_ID, permission: 'edit' }) + await event('question.asked', { sessionID: SUB_SESSION_ID, questions: [{ question: 'q' }] }) }, { expected: 0 }, )