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.