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
9 changes: 6 additions & 3 deletions docs/hooks/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/hooks/fixtures/session-start.jsonl
Original file line number Diff line number Diff line change
@@ -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"}}
Expand Down
28 changes: 23 additions & 5 deletions docs/hooks/session-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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 <id>`, `crush --session <id>`), 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/*/<id>.jsonl` for Claude Code and
Expand Down
72 changes: 50 additions & 22 deletions docs/hooks/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions docs/hooks/session-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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`:
Expand Down
27 changes: 20 additions & 7 deletions docs/hooks/session-touched.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.