Skip to content

feat: report from opencode and Crush sessions - #10

Merged
omartelo merged 1 commit into
mainfrom
feat/opencode-crush-hooks
Aug 11, 2026
Merged

omartelo merged 1 commit into
mainfrom
feat/opencode-crush-hooks

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

Two more harnesses report to lich. Neither fits the script model the other two share, so each is met where it is rather than bent into the shape of the others.

Implements the contracts extended in omartelo/lich#217 — that PR merges first: the fixtures-drift job here diffs tests/fixtures/ against lich's main, so it is red until then. The vendored session-start.jsonl in this branch is already the post-merge copy.

opencode

opencode runs no hook commands: a plugin there is a JavaScript module its server imports. So its client is one file, opencode/lich.js, posting the same four reports to the same endpoints off the event bus — the session id from session.created, busy/done from session.status, waiting from permission.asked, the tool line from tool.execute.before, the label from session.updated, and the git-status refresh from file.edited. Installing it is dropping the file in ~/.config/opencode/plugin/; there is no marketplace to go through.

Three things the module has to get right, each measured rather than assumed:

  • Sub-sessions are dropped by their parentID. The task tool runs a sub-agent as its own session, and its idle would otherwise end the turn on the card while the real one is still running.
  • The title a session is created with is a placeholder built from its timestamp, so the module holds that first value and reports only a title that differs from it — no matching on its wording.
  • opencode awaits its plugin hooks, so no report is ever awaited and nothing throws. That is this client's version of "always exit 0", and a test holds it: a listener that takes 3s must not hold the turn.

Crush

Crush runs the existing scripts unchanged — its hooks are Claude Code-compatible down to the stdin payload and the exit codes — but PreToolUse is the only event it has. Only the two reports that event can honour are registered: the session id, and the git-status refresh on write tools.

busy is deliberately left out. With no Stop behind it, a spinner would stay on the card until the next turn — wrong for longer than it is right. The day Crush ships an end-of-turn event, report-state.sh and report-tool.sh are already here and the registration grows two lines.

Crush has no plugin system either, so hooks/crush-hooks.json is a block to merge into the user's own crush.json, with <lich-plugin> where the clone's path goes — command is resolved against the working directory, not the config file, so a global install needs the absolute path.

Tests

The suite splits so both clients answer to the same lines: tests/contract.mjs holds lich's fixtures, the assertions and the stub; tests/hooks.test.mjs keeps spawning scripts (now including Crush's registration); tests/opencode.test.mjs imports the module and drives it through the payloads a real opencode run emits — including the sub-session ones it must drop, a listener answering 500, a refused connection, and unknown or malformed events.

opencode/package.json exists only so Node reads lich.js as a module; opencode imports it through Bun, which needs no help.

Test plan

  • node --test tests/*.test.mjs — 113 pass
  • Crush verified end to end: crush run in an isolated XDG_CONFIG_HOME against a stub listener, all three reports arriving on the one event
  • opencode verified end to end: opencode serve with this module installed, driven through its HTTP API against a stub provider — /session-start, busy, busy + write/oc-probe.txt, /session-touched, waiting and done all arrived, and a rename produced /session-title
  • Mutation-checked: removing the parentID filter fails 4 tests, reporting the placeholder title fails 1, mapping opencode's idle to lich's idle fails 1
  • Merge after lich#217

Two more harnesses, and neither fits the script model the other two
share — so each is met where it is rather than bent into the shape of
the others.

**opencode** runs no hook commands: a plugin there is a JavaScript
module its server imports. Its client is one file, opencode/lich.js,
posting the same four reports to the same endpoints off the event bus —
the session id from `session.created`, `busy`/`done` from
`session.status`, `waiting` from `permission.asked`, the tool line from
`tool.execute.before`, the label from `session.updated`, and the
git-status refresh from `file.edited`. Installing it is dropping the
file in ~/.config/opencode/plugin/; there is no marketplace.

Three things the module has to get right, all of them measured rather
than assumed:

- Sub-sessions are dropped by their `parentID`. The `task` tool runs a
  sub-agent as its own session, and its `idle` would otherwise end the
  turn on the card while the real one is still running.
- The title a session is created with is a placeholder built from its
  timestamp, so the module holds that first value and reports only a
  title that differs from it — no matching on its wording.
- opencode awaits its plugin hooks, so no report is ever awaited and
  nothing throws. That is this client's version of "always exit 0", and
  a test holds it: a listener that takes 3s must not hold the turn.

**Crush** runs the existing scripts unchanged — its hooks are Claude
Code-compatible down to the stdin payload — but `PreToolUse` is the only
event it has. Only the two reports that event can honour are registered:
the session id, and the git-status refresh on write tools. A `busy` with
no `Stop` behind it would pin a spinner to a card, so state and title
stay unregistered until Crush ships an end-of-turn event, at which point
report-state.sh and report-tool.sh are already here. Crush has no plugin
system either, so hooks/crush-hooks.json is a block to merge into the
user's own crush.json, with <lich-plugin> where the clone's path goes.

The suite splits so both clients answer to the same lines:
tests/contract.mjs holds lich's fixtures, the assertions and the stub;
tests/opencode.test.mjs drives the module through the payloads a real
opencode run emits, including the ones it must drop.
@omartelo
omartelo merged commit 637cda8 into main Aug 11, 2026
4 of 5 checks passed
@omartelo omartelo mentioned this pull request Aug 11, 2026
2 tasks done
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.

1 participant