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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **The card says which tool the agent is running.** A new `report-tool.sh`
rides `PreToolUse` in both harnesses and reports the same `busy` with two
extra fields — the tool's name and what it acts on — which lich draws under
the session's label and clears when the turn leaves `busy`. The detail is read
by field rather than by tool name (`command`, then `file_path`, then
`pattern` / `url` / `query`), which is what lets one rule cover both: Codex
reports a shell call as `Bash` carrying the same `command` string Claude Code
sends. Two shapes needed more: `apply_patch` passes the whole patch as its
command, so the file its `*** Add File:` line names is what goes on the card,
and an absolute path is shortened against the session's directory, because
both harnesses report full ones and a card is 240px wide. Without `jq` the
tool name still goes out; the detail does not. Needs lich ≥ 0.29.0 — an older
one ignores the two fields.

`PreToolUse` is the first contract event on the agent's critical path: a hook
exiting `2` there blocks the tool call. Until now the worst a broken script
could do was lose a status report.

- **The hook payloads are asserted against lich's contract fixtures.** lich
publishes every hook contract as bytes (`docs/hooks/fixtures/*.jsonl` there,
one case per line: the payloads it accepts and the ones it refuses) and
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ tests/ # hook payloads asserted against lich's fixtur

Contracts are **canonical in the lich repository** (`docs/hooks/` there); this plugin only implements the client side. Each hook has a doc in `docs/` here that points at its contract and describes the plugin-side behavior. Read it before creating or changing a hook:

- [docs/session-state.md](docs/session-state.md) — session-state reporting (`busy`/`done`) via `UserPromptSubmit`/`Stop`
- [docs/session-state.md](docs/session-state.md) — session-state reporting (`busy`/`done`) via `UserPromptSubmit`/`Stop`, and the tool a turn is running via `PreToolUse`
- [docs/session-start.md](docs/session-start.md) — Claude session id via `SessionStart`
- [docs/session-title.md](docs/session-title.md) — auto-generated `ai-title` via `Stop`
- [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ It also ships skills for the parts of lich you configure from inside a session:
hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/report-state.sh # session-state hook
hooks/report-tool.sh # session-state hook: the tool a turn is running
hooks/report-session-start.sh # session-start hook
hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
Expand Down
6 changes: 4 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@ harness it supports:
The repository root is the plugin root for both, so a single clone installs on
either CLI. The scripts are shared because both harnesses expose the same
things a report needs: the payload arrives as JSON on stdin with `session_id`
and `transcript_path`, and `$CLAUDE_PLUGIN_ROOT` points at the installed plugin
— Codex sets that same variable, so the command lines are identical.
and `transcript_path` (and `tool_name` / `tool_input` / `cwd` on a pre-tool
event), and `$CLAUDE_PLUGIN_ROOT` points at the installed plugin — Codex sets
that same variable, so the command lines are identical.

## Event vocabulary

| Report | Claude Code | Codex |
|---------------------|------------------------------|------------------------------|
| session id | `SessionStart` | `SessionStart` |
| `busy` | `UserPromptSubmit`, `PostToolUse` | `UserPromptSubmit`, `PostToolUse` |
| `busy` + tool | `PreToolUse` | `PreToolUse` |
| `waiting` | `Notification` | `PermissionRequest` |
| `done` + title | `Stop` | `Stop` |
| `idle` | `SessionEnd` | — (registered, never fires) |
Expand Down
60 changes: 52 additions & 8 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ Reports the session's processing state so the lich card shows a spinner while
the agent works, a check when the turn ends, and a bell (plus an actionable
toast) when the agent is blocked on the user.

| script | state | Claude Code hook | Codex hook |
|---------------------------|-----------|--------------------|---------------------|
| `report-state.sh busy` | `busy` | `UserPromptSubmit` | `UserPromptSubmit` |
| `report-state.sh busy` | `busy` | `PostToolUse` | `PostToolUse` |
| `report-state.sh done` | `done` | `Stop` | `Stop` |
| `report-state.sh waiting` | `waiting` | `Notification` | `PermissionRequest` |
| `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) |
| script | state | Claude Code hook | Codex hook |
|---------------------------|------------------|--------------------|---------------------|
| `report-state.sh busy` | `busy` | `UserPromptSubmit` | `UserPromptSubmit` |
| `report-tool.sh` | `busy` + `tool` | `PreToolUse` | `PreToolUse` |
| `report-state.sh busy` | `busy` | `PostToolUse` | `PostToolUse` |
| `report-state.sh done` | `done` | `Stop` | `Stop` |
| `report-state.sh waiting` | `waiting` | `Notification` | `PermissionRequest` |
| `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) |

`POST /hook` with `{"session_id": $LICH_SESSION_ID, "state":
"busy"|"done"|"waiting"|"idle"}`.
"busy"|"done"|"waiting"|"idle"}`, plus the optional `tool` / `detail` pair the
pre-tool report adds.

`Notification` fires when Claude needs a permission decision or has been idle
waiting for input — both mean "your turn". A later `busy` or `done` clears it.
Expand All @@ -43,3 +45,45 @@ does not fire `UserPromptSubmit`, but the tool that resumes work fires
the turn without another tool call stays `waiting` until `Stop`. Codex recovers
the same way: an approved request runs the tool it was asking about, and that
tool's `PostToolUse` re-arms the spinner.

## The tool report

`report-tool.sh` rides `PreToolUse` and reports the same `busy`, with the tool
the agent is about to run: `tool` is the harness's own name for it, `detail` its
own words for what it acts on. lich shows the pair under the session's label and
drops it when the state leaves `busy`, so this script never has to report an
end.

**This is the one hook on the agent's critical path.** In both harnesses a
`PreToolUse` hook exiting `2` blocks the tool call — the client rules (silent,
always exit 0) stop being merely polite here.

What the two harnesses actually send, taken off a real run of each against a
stub listener rather than off their documentation:

| 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`) |

The `detail` is read by field, never by tool name — `command`, then
`file_path` / `path`, then `pattern` / `url` / `query` — which is what makes one
rule cover both: a Codex shell call arrives as `Bash` carrying the same
`command` string Claude Code sends.

Two shapes need more than the plain rule:

- **`apply_patch`** passes the *whole patch* as its command, so the plain rule
would put `*** Begin Patch` on the card. The file its `*** Add/Update/Delete
File:` line names is the readable half; a patch touching several shows the
first, and one naming none sends no detail at all.
- **A path** is shortened against the payload's `cwd`, because both harnesses
report absolute paths and a lich card is 240px wide. Outside that directory
only the file name survives. Commands are never shortened — a leading slash
there is a binary, not a path worth cutting.

Without `jq` (Windows, usually) the tool name still goes out, read with `sed`
like the session id is; the detail does not, because a hand-built body cannot
escape arbitrary text.
11 changes: 11 additions & 0 deletions hooks/codex-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-tool.sh\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"hooks": [
Expand Down
10 changes: 10 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh"
}
]
}
],
"PostToolUse": [
{
"hooks": [
Expand Down
76 changes: 76 additions & 0 deletions hooks/report-tool.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/sh
# Reports the tool the agent is about to run, so lich's card can name it.
# Contract: docs/session-state.md
# stdin: PreToolUse payload (JSON with "tool_name" and "tool_input").
#
# PreToolUse is the one event on the agent's critical path: a hook exiting 2
# there *blocks the tool call* in both harnesses. Everything below swallows its
# own failures and exits 0, which is load-bearing here rather than merely tidy.

# Outside lich (vars absent) → no-op. Safe to install globally.
[ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0

payload=$(cat)
command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq=

# Prefer jq; fall back to sed so the hook works on Windows, where jq is usually
# absent but sed (Git Bash) is not. A tool name is an identifier, so sed reading
# it out of the raw payload is safe in a way arbitrary text would not be.
if [ -n "$has_jq" ]; then
tool=$(printf '%s' "$payload" | jq -r '.tool_name // empty' 2>/dev/null)
else
tool=$(printf '%s' "$payload" | \
sed -n 's/.*"tool_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
fi
[ -n "$tool" ] || exit 0

# What the call is about, taken from whichever field the harness filled rather
# than from the tool's name: one rule then covers both, because a Codex shell
# call arrives as `Bash` with the same `command` field Claude Code uses. Only
# with jq — the value is arbitrary text, and a hand-built body cannot escape it.
#
# `apply_patch` is the exception. Codex passes the whole patch as the command,
# so the plain rule would put "*** Begin Patch" on the card; the file the patch
# names is the readable half of it. A patch touching several files shows the
# first, which is the one the eye would land on anyway.
#
# A path is shortened against the session's own directory, because a card is
# 240px wide and both harnesses report absolute ones: a full path fills the line
# with the part every card shares. Outside that directory only the file name is
# left, which at least names the file. Commands are never shortened — a leading
# slash there belongs to a binary, not to a path worth cutting.
detail=""
if [ -n "$has_jq" ]; then
detail=$(printf '%s' "$payload" | jq -r '
.cwd as $cwd
| def short: if ($cwd // "") != "" and startswith($cwd + "/") then ltrimstr($cwd + "/")
elif startswith("/") then sub(".*/"; "")
else . end;
(.tool_input // {})
| if ((.command? // "") | tostring) != "" then
(.command | tostring
| if startswith("*** Begin Patch")
then ((capture("\\*\\*\\* (?:Add|Update|Delete) File: (?<p>[^\n]*)") // {}) | .p // "")
else . end)
else
((.file_path? // .path? // .pattern? // .url? // .query? // "") | tostring | short)
end' 2>/dev/null | head -n 1)
fi

# The detail is omitted rather than sent empty: the contract's optional field is
# absent when there is nothing to say.
if [ -n "$detail" ]; then
body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg tool "$tool" --arg detail "$detail" \
'{session_id: $sid, state: "busy", tool: $tool, detail: $detail}')
else
body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"busy\",\"tool\":\"${tool}\"}"
fi

curl -s -o /dev/null --max-time 1 \
-X POST "http://127.0.0.1:${LICH_PORT}/hook?token=${LICH_TOKEN}" \
-H 'Content-Type: application/json' \
-d "$body" \
|| true

# Never blocks or fails the turn.
exit 0
8 changes: 7 additions & 1 deletion tests/fixtures/session-state.jsonl
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
{"name":"busy","body":{"session_id":"s1","state":"busy"},"accept":{"session_id":"s1","state":"busy"}}
{"name":"busy","body":{"session_id":"s1","state":"busy"},"accept":{"session_id":"s1","state":"busy","tool":"","detail":""}}
{"name":"done","body":{"session_id":"s1","state":"done"},"accept":{"session_id":"s1","state":"done"}}
{"name":"waiting","body":{"session_id":"s1","state":"waiting"},"accept":{"session_id":"s1","state":"waiting"}}
{"name":"idle","body":{"session_id":"s1","state":"idle"},"accept":{"session_id":"s1","state":"idle"}}
{"name":"busy names the tool it is about to run","body":{"session_id":"s1","state":"busy","tool":"Bash","detail":"pnpm test"},"accept":{"session_id":"s1","state":"busy","tool":"Bash","detail":"pnpm test"}}
{"name":"a tool with nothing to say about it","body":{"session_id":"s1","state":"busy","tool":"Read"},"accept":{"session_id":"s1","state":"busy","tool":"Read","detail":""}}
{"name":"each harness names its own tools","body":{"session_id":"s1","state":"busy","tool":"apply_patch","detail":"internal/terminal/usage.go"},"accept":{"session_id":"s1","state":"busy","tool":"apply_patch","detail":"internal/terminal/usage.go"}}
{"name":"tool and detail are trimmed","body":{"session_id":"s1","state":"busy","tool":" shell ","detail":" go test ./... "},"accept":{"session_id":"s1","state":"busy","tool":"shell","detail":"go test ./..."}}
{"name":"a detail with no tool names nothing","body":{"session_id":"s1","state":"busy","detail":"pnpm test"},"accept":{"session_id":"s1","state":"busy","tool":"","detail":""}}
{"name":"whitespace is not a tool","body":{"session_id":"s1","state":"busy","tool":" ","detail":"pnpm test"},"accept":{"session_id":"s1","state":"busy","tool":"","detail":""}}
{"name":"missing session_id","body":{"state":"busy"},"reject":"session_id is required"}
{"name":"empty session_id","body":{"session_id":"","state":"busy"},"reject":"session_id is required"}
{"name":"unknown state","body":{"session_id":"s1","state":"cooking"},"reject":"state must be one of busy, done, waiting, idle"}
Expand Down
Loading
Loading