From db5c166a88962c7c3fff85e1f18dd6c70d4fe140 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 10 Aug 2026 23:05:44 -0300 Subject: [PATCH] feat: extend the hook contracts to opencode and Crush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each contract's mapping table gains a column per harness, so the two newest ones are documented where the other two already are, and session-start gains a fixture line for each provider id — the payload their clients POST is now pinned as bytes like every other. The endpoints needed nothing: `provider` has accepted any id in providers.Registry since it existed, and opencode and Crush have been in that registry since they were spawnable. What the prose adds is the part the code cannot say — which of each harness's events maps onto which report, and which reports it cannot make at all: - opencode reports all four. Its `session.status` carries the state rather than implying it, and its `idle` is the turn ending, which is this contract's `done`. Nothing there reports lich's own `idle`: the event that would say "the CLI has left" is the server exiting with the plugin inside it. - Crush reports two. `PreToolUse` is the only event it has, so a `busy` with nothing to end it would pin a spinner to a card — worse for longer than it is right. It reports its session id and refreshes git status; the refresh runs one tool early, which the ceilings name. Both spell resume (`--session `), and neither is wired: lich only offers a resume it has proof of, and both keep conversations in SQLite where there is no per-session file to glob. The id is stored today so the day one is wired the sessions already carry it. Every row was taken off a real run of each CLI against a stub listener. --- docs/hooks/README.md | 9 ++-- docs/hooks/fixtures/session-start.jsonl | 2 + docs/hooks/session-start.md | 28 ++++++++-- docs/hooks/session-state.md | 72 +++++++++++++++++-------- docs/hooks/session-title.md | 13 +++-- docs/hooks/session-touched.md | 27 +++++++--- 6 files changed, 111 insertions(+), 40 deletions(-) diff --git a/docs/hooks/README.md b/docs/hooks/README.md index 93e635055..47514343a 100644 --- a/docs/hooks/README.md +++ b/docs/hooks/README.md @@ -1,9 +1,12 @@ # Hook contracts -lich observes and drives provider sessions through **hooks**. Each hook is a -small script that runs inside a session (shipped by the companion plugin +lich observes and drives provider sessions through **hooks**. Each hook runs +inside a session (shipped by the companion plugin [`omartelo/lich-plugin`](https://github.com/omartelo/lich-plugin), which installs -on Claude Code and Codex) and talks to lich over a shared local transport. +on Claude Code, Codex, opencode and Crush) and talks to lich over a shared local +transport. On the first three it is a small script the harness runs; opencode +loads a JavaScript module instead, which is a difference in packaging, not in +what a report is. The contracts are provider-agnostic: lich injects the same variables into every PTY it spawns, so what changes per provider is only which of its lifecycle diff --git a/docs/hooks/fixtures/session-start.jsonl b/docs/hooks/fixtures/session-start.jsonl index b0acea181..21d2460d7 100644 --- a/docs/hooks/fixtures/session-start.jsonl +++ b/docs/hooks/fixtures/session-start.jsonl @@ -1,4 +1,6 @@ {"name":"reported provider","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"codex"}} +{"name":"opencode session id","body":{"session_id":"s1","provider_session_id":"ses_011856954ffe5NEMhsYeTuWykR","provider":"opencode"},"accept":{"session_id":"s1","provider_session_id":"ses_011856954ffe5NEMhsYeTuWykR","provider":"opencode"}} +{"name":"crush session id","body":{"session_id":"s1","provider_session_id":"db3492a0-4a6b-4335-8c0b-d4cc43fc4cb0","provider":"crush"},"accept":{"session_id":"s1","provider_session_id":"db3492a0-4a6b-4335-8c0b-d4cc43fc4cb0","provider":"crush"}} {"name":"absent provider defaults to claude","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}} {"name":"legacy claude_session_id is folded","body":{"session_id":"s1","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02","provider":"claude"}} {"name":"provider_session_id wins over legacy","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}} diff --git a/docs/hooks/session-start.md b/docs/hooks/session-start.md index 229448c92..a5f1ff8b0 100644 --- a/docs/hooks/session-start.md +++ b/docs/hooks/session-start.md @@ -42,16 +42,25 @@ deprecated alias and the defaulted `provider`. ## Event → action mapping -| Claude Code hook | Codex hook | action | -|------------------|----------------|----------------------------------------------| -| `SessionStart` | `SessionStart` | store `provider_session_id` on the lich | -| | | session row, and mark the card as running | -| | | `provider` (the `session-agent` app event) | +| Claude Code hook | Codex hook | opencode event | Crush hook | action | +|------------------|----------------|--------------------|---------------|-------------------------------------------| +| `SessionStart` | `SessionStart` | `session.created` | `PreToolUse` | store `provider_session_id` on the lich | +| | | | | session row, and mark the card as running | +| | | | | `provider` (the `session-agent` app event)| `SessionStart` fires on startup, resume, `/clear` and compaction. A resume reports the resumed session's id and overwrites the stored value — lich always holds the id of the provider session currently in the card. +The other two report the same id from the earliest place each one offers it. +opencode raises `session.created` when the conversation is created, before the +first turn, so it lands as early as `SessionStart` does. **Crush has only +`PreToolUse`**, whose payload carries the same `session_id` every other harness +reports on stdin — so a Crush session announces itself when it first reaches for +a tool, and a conversation that answers without one never announces itself at +all. That is a late report, not a wrong one: nothing downstream of this contract +cares when the id arrived, only which conversation it names. + ## lich server side - **Endpoint** — `internal/terminal/transport.go`, `transport.sessionStart`: @@ -99,6 +108,15 @@ holds the id of the provider session currently in the card. (`frontend/src/lib/session/sessions.ts`) list the kinds that have one. A provider outside that list reporting an id has it stored and ignored until its own invocation is wired. +- **opencode and Crush report an id lich does not yet resume.** Both spell it + (`opencode --session `, `crush --session `), so the invocation is the + easy half. The gate is the one below: lich only offers a resume it has proof + of, and both keep their conversations in SQLite — `opencode.db` under the data + dir, Crush's under its `--data-dir` — where there is no per-session file to + glob for. Wiring them means either reading another tool's schema or offering a + resume that can die inside the PTY, and neither is worth it for a mark on a + card. The id is stored today so the day one of them is wired, the sessions + already carry it. - **Resume availability is asked of each provider's transcript directory.** `ResumeAvailable` (`internal/terminal/resume.go`) globs `~/.claude/projects/*/.jsonl` for Claude Code and diff --git a/docs/hooks/session-state.md b/docs/hooks/session-state.md index b2dc25c34..7b8ea0ed0 100644 --- a/docs/hooks/session-state.md +++ b/docs/hooks/session-state.md @@ -34,14 +34,27 @@ Both sides test against the payloads in ## Event → state mapping -| Claude Code hook | Codex hook | state | -|--------------------|---------------------|-----------| -| `UserPromptSubmit` | `UserPromptSubmit` | `busy` | -| `PreToolUse` | `PreToolUse` | `busy` + `tool` | -| `PostToolUse` | `PostToolUse` | `busy` | -| `Notification` | `PermissionRequest` | `waiting` | -| `Stop` | `Stop` | `done` | -| `SessionEnd` | — | `idle` | +| Claude Code hook | Codex hook | opencode event | Crush hook | state | +|--------------------|---------------------|--------------------------|------------|-----------| +| `UserPromptSubmit` | `UserPromptSubmit` | `session.status` (`busy`) | — | `busy` | +| `PreToolUse` | `PreToolUse` | `tool.execute.before` | — | `busy` + `tool` | +| `PostToolUse` | `PostToolUse` | `tool.execute.after` | — | `busy` | +| `Notification` | `PermissionRequest` | `permission.asked` | — | `waiting` | +| `Stop` | `Stop` | `session.status` (`idle`) | — | `done` | +| `SessionEnd` | — | — | — | `idle` | + +opencode is the one harness that reports a state rather than an event: its +`session.status` carries `busy`, `idle` or `retry` for the session named in the +same payload. Its `idle` means the turn ended, which is lich's `done` — not +lich's `idle`, which says the CLI itself has left. Nothing in opencode's event +list says that, so like Codex it never reports `idle`. + +**Crush reports no state at all.** Its only hook event is `PreToolUse`, and a +`busy` with nothing that can end it would leave a spinner on the card until the +next turn — a state that is wrong for longer than it is right. So the plugin +registers this contract on the three harnesses that can close it, and a Crush +card carries no indicator. The day Crush ships `Stop` (its `docs/hooks/FUTURE.md` +tracks the request, not the event), the column fills in from the existing script. `Notification` fires when Claude needs a permission decision or has been idle waiting for input — both mean "your turn"; lich shows a toast (see below) only @@ -68,22 +81,24 @@ what that tool acts on. Both are passed through as sent — lich does not translate between vocabularies, because the word on the card should be the word in the terminal beside it. -The two vocabularies overlap more than the harnesses' own tool sets do. Codex's +The vocabularies overlap more than the harnesses' own tool sets do. Codex's hook payload reports a shell call as `Bash`, the same name and the same `tool_input.command` string Claude Code sends, and routes reading and searching -through it; the one word of its own that reaches a card is `apply_patch`: - -| Action | Claude Code | Codex | -|---------------|----------------------------|--------------------------| -| run a command | `Bash` | `Bash` | -| edit a file | `Edit` / `Write` | `apply_patch` | -| read a file | `Read` | — (goes through `Bash`) | -| search | `Grep` / `Glob` | — (goes through `Bash`) | -| an MCP tool | `mcp__srv__tool` | `mcp__srv__tool` | - -Each row was taken off a real run of both CLIs against a stub listener, not from -their documentation. A harness is free to report a name outside this table; the -card shows whatever arrives. +through it; the one word of its own that reaches a card is `apply_patch`. +opencode spells the same set in lower case: + +| Action | Claude Code | Codex | opencode | +|---------------|----------------------------|--------------------------|------------------| +| run a command | `Bash` | `Bash` | `bash` | +| edit a file | `Edit` / `Write` | `apply_patch` | `edit` / `write` | +| read a file | `Read` | — (goes through `Bash`) | `read` | +| search | `Grep` / `Glob` | — (goes through `Bash`) | `grep` / `glob` | + +Each row was taken off a real run of the CLIs against a stub listener, not from +their documentation — except opencode's MCP tools, which are namespaced by their +server and were not observed here, which is why the row Claude Code and Codex +both spell `mcp__srv__tool` is absent above. A harness is free to report a name +outside this table; the card shows whatever arrives. `detail` is whatever identifies the call at a glance — the command line, the file path, the pattern. It is free text: a harness that offers nothing usable @@ -168,6 +183,19 @@ a broken script could do was lose a status report. next PTY spawn, so what lingers is a check on a card whose provider has left. The plugin registers a `SessionEnd` hook anyway: an unknown event name is ignored rather than rejected, so it starts working the day Codex adds one. +- **opencode never reports `idle` either**, for its own reason: its plugin is + loaded by the opencode server, so the event that would say "the CLI has left" + is the server going away with the plugin inside it. Nothing survives to report + it. An opencode card therefore keeps its last indicator until lich respawns + that PTY, exactly like a Codex one. +- **An opencode report is only as precise as the server it runs in.** The plugin + reads `LICH_SESSION_ID` from the environment of the process it was loaded by, + and one opencode server can hold several conversations. Sub-sessions (the + `task` tool) are excluded by their `parentID` — without that, a sub-agent + finishing would report `done` while the real turn is still running — but two + *top-level* sessions in one server would both report onto the same card. A + session lich spawns gets its own server, so this is the shape of the failure, + not something the normal path hits. - The desktop notification is not clickable: clicking it focuses nothing and routes nowhere, which is why its text names the session and the project — the user navigates by hand. Making it actionable is per-OS and none of it is diff --git a/docs/hooks/session-title.md b/docs/hooks/session-title.md index 1eeb3fef7..0410ca413 100644 --- a/docs/hooks/session-title.md +++ b/docs/hooks/session-title.md @@ -28,9 +28,9 @@ Both sides test against the payloads in ## Event → action mapping -| Claude Code hook | Codex hook | action | -|------------------|------------|-----------------------------------------------| -| `Stop` | `Stop` | set the session label to `title` (if still auto) | +| Claude Code hook | Codex hook | opencode event | Crush hook | action | +|------------------|------------|-------------------|------------|--------------------------------------------------| +| `Stop` | `Stop` | `session.updated` | — | set the session label to `title` (if still auto) | The `ai-title` is an internal Haiku summary of the first prompt, written to the transcript **after** the first turn — so it does not exist at `SessionStart`. @@ -49,6 +49,13 @@ string; where it comes from is the client's business. Send it on `Stop`. Re-sending on every `Stop` is fine — lich only applies it while the label is still automatic (see below), so a stable title is idempotent. +opencode is the one harness that hands the title over instead of being read out +of a transcript: every `session.updated` carries the whole session, title +included, and the plugin forwards it when it changed. It arrives more than once +per turn, which the idempotence above already covers. **Crush has no title to +report** — its only event is `PreToolUse`, whose payload is about the tool — so a +Crush card keeps the name lich gave it. + ## lich server side - **Endpoint** — `internal/terminal/transport.go`, `transport.sessionTitle`: diff --git a/docs/hooks/session-touched.md b/docs/hooks/session-touched.md index 28c03ed75..0e123626a 100644 --- a/docs/hooks/session-touched.md +++ b/docs/hooks/session-touched.md @@ -25,16 +25,22 @@ Both sides test against the payloads in ## Event → action mapping -| Claude Code hook | Codex hook | action | -|-----------------------------------------|-------------------------------------|----------------------------------| -| `PostToolUse` (file-mutating tools) | `PostToolUse` (file-mutating tools) | refresh the session's git status | +| Claude Code hook | Codex hook | opencode event | Crush hook | action | +|-----------------------------------------|-------------------------------------|----------------|-------------------------------------|----------------------------------| +| `PostToolUse` (file-mutating tools) | `PostToolUse` (file-mutating tools) | `file.edited` | `PreToolUse` (file-mutating tools) | refresh the session's git status | Fire it from `PostToolUse` **only for tools that write to disk** — the names are the provider's, so match its own: `Edit`, `Write`, `NotebookEdit`, `Bash` on -Claude Code, `apply_patch` (plus `Bash`) on Codex. Do **not** fire on read-only -tools (`Read`, `Grep`, `Glob`): a git-status refresh per read would cost more -than the poll it is meant to beat. The tool name is on the hook's stdin payload -if a single script filters instead of per-tool matchers. +Claude Code, `apply_patch` (plus `Bash`) on Codex, and the same set lower-cased +(`edit`, `write`, `multiedit`, `bash`) on Crush. Do **not** fire on read-only +tools (`Read`, `Grep`, `Glob`, Crush's `view`): a git-status refresh per read +would cost more than the poll it is meant to beat. The tool name is on the +hook's stdin payload if a single script filters instead of per-tool matchers. + +opencode needs no matcher: `file.edited` fires only when a file actually +changed, which is the signal the other harnesses approximate by filtering tool +names. Crush is the opposite — `PreToolUse` fires *before* the write, so its +refresh reads a tree the tool has not touched yet (see the ceilings). ## lich server side @@ -61,3 +67,10 @@ if a single script filters instead of per-tool matchers. - **Session id, not path.** The hook sends the session id and lich/the frontend resolve the path, so a `cd` inside a Bash tool can't point the refresh at the wrong repository. +- **On Crush the refresh runs one tool early.** `PreToolUse` is the only event + Crush has, so the report fires before the write lands and the immediate fetch + sees the tree as it was. What it actually front-runs is the *previous* tool's + write, and the last write of a turn waits for the poll like it always did. + Registering it anyway costs one git call per write tool and makes a burst of + edits show up a poll earlier than nothing would; a `PostToolUse` in Crush + turns this into the same signal the other three send.