Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | — |
Expand All @@ -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 <id>`. Both need lich
Expand Down
14 changes: 13 additions & 1 deletion docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 13 additions & 5 deletions opencode/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
}
}
},

Expand Down
71 changes: 61 additions & 10 deletions tests/opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 },
)
Expand Down
Loading