diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b28f0..dfa91f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index d752c65..56dab0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/README.md b/README.md index 13c85ea..4359f2d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/providers.md b/docs/providers.md index ec22c93..c6f5e4b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -23,8 +23,9 @@ 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 @@ -32,6 +33,7 @@ and `transcript_path`, and `$CLAUDE_PLUGIN_ROOT` points at the installed plugin |---------------------|------------------------------|------------------------------| | 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) | diff --git a/docs/session-state.md b/docs/session-state.md index febcab2..9208def 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -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. @@ -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. diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index 074844b..e70b8ac 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -22,6 +22,17 @@ ] } ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PLUGIN_ROOT/hooks/report-tool.sh\"", + "timeout": 5 + } + ] + } + ], "PostToolUse": [ { "hooks": [ diff --git a/hooks/hooks.json b/hooks/hooks.json index 5551c6b..5a323dd 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -20,6 +20,16 @@ ] } ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-tool.sh" + } + ] + } + ], "PostToolUse": [ { "hooks": [ diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh new file mode 100755 index 0000000..2bf9e6e --- /dev/null +++ b/hooks/report-tool.sh @@ -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: (?
[^\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
diff --git a/tests/fixtures/session-state.jsonl b/tests/fixtures/session-state.jsonl
index 1725671..dcba061 100644
--- a/tests/fixtures/session-state.jsonl
+++ b/tests/fixtures/session-state.jsonl
@@ -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"}
diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs
index 9c184c8..b50c160 100644
--- a/tests/hooks.test.mjs
+++ b/tests/hooks.test.mjs
@@ -230,6 +230,7 @@ const HOOK_FILES = [
const SCRIPTS = {
'report-state.sh': '/hook',
+ 'report-tool.sh': '/hook',
'report-session-start.sh': '/session-start',
'report-title.sh': '/session-title',
'report-touched.sh': '/session-touched',
@@ -305,7 +306,7 @@ function codexTranscript(message, name = 'codex.jsonl') {
function binWithout(missing) {
const dir = path.join(TMP, `bin-without-${missing}`)
mkdirSync(dir, { recursive: true })
- for (const tool of ['sh', 'sed', 'grep', 'cut', 'head', 'tail', 'curl', 'jq']) {
+ for (const tool of ['sh', 'sed', 'grep', 'cut', 'head', 'tail', 'cat', 'curl', 'jq']) {
if (tool === missing) continue
const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim()
if (!found) continue
@@ -328,6 +329,18 @@ const TRANSCRIPT = {
codex: codexTranscript('Fix the replay ring overflow\nand its second line'),
}
+/**
+ * The tool call a PreToolUse payload describes. Both harnesses spell the
+ * envelope the same way (`tool_name` / `tool_input`), and Codex reports a shell
+ * call as `Bash` with a plain-string `command` exactly as Claude Code does —
+ * verified against a real `codex exec` run, not assumed. Its one own word is
+ * `apply_patch` (see the details table below).
+ */
+const TOOL_CALL = {
+ claude: { tool_name: 'Bash', tool_input: { command: 'pnpm test', description: 'run the suite' } },
+ codex: { tool_name: 'Bash', tool_input: { command: 'go test ./...' } },
+}
+
/** What the harness puts on stdin for the event this registration rides. */
function stdinFor({ provider, event }) {
return JSON.stringify({
@@ -335,6 +348,7 @@ function stdinFor({ provider, event }) {
transcript_path: TRANSCRIPT[provider],
cwd: ROOT,
hook_event_name: event,
+ ...(event === 'PreToolUse' ? TOOL_CALL[provider] : {}),
})
}
@@ -426,6 +440,10 @@ for (const registration of REGISTRATIONS) {
const { body } = assertContractHonoured(endpoint, stub.requests[0])
assert.equal(body.session_id, LICH_SESSION_ID)
if (script === 'report-state.sh') assert.equal(body.state, argument)
+ if (script === 'report-tool.sh') {
+ assert.equal(body.state, 'busy')
+ assert.equal(body.tool, TOOL_CALL[provider].tool_name)
+ }
if (script === 'report-session-start.sh') {
assert.equal(body.provider, provider)
assert.equal(body.provider_session_id, PROVIDER_SESSION_ID[provider])
@@ -537,12 +555,149 @@ test('session-title no-ops when the transcript is missing', async () => {
})
})
+// ---------------------------------------------------------- the tool report --
+
+/** Runs report-tool.sh against a PreToolUse payload and returns the stub's view. */
+async function runToolHook(payload, { env = {} } = {}) {
+ return withStub(async (stub) => {
+ const result = await runHook(`"${ROOT}/hooks/report-tool.sh"`, {
+ env: { ...lichEnv(stub.port), ...env },
+ stdin: JSON.stringify({ session_id: 's', hook_event_name: 'PreToolUse', ...payload }),
+ })
+ assertSilentSuccess(result)
+ return { requests: stub.requests }
+ })
+}
+
+test('report-tool no-ops when the payload names no tool', async () => {
+ const { requests } = await runToolHook({ tool_input: { command: 'pnpm test' } })
+ assert.equal(requests.length, 0, `reported a tool it was never given: ${requests[0]?.raw}`)
+})
+
+test('report-tool no-ops when the tool name is empty', async () => {
+ const { requests } = await runToolHook({ tool_name: '', tool_input: {} })
+ assert.equal(requests.length, 0, `reported an empty tool: ${requests[0]?.raw}`)
+})
+
+// The detail is optional in the contract: absent, not blank. A blank one is a
+// shape no accept case carries, which assertContractHonoured would catch.
+test('report-tool omits the detail when the call carries nothing to say', async () => {
+ const { requests } = await runToolHook({ tool_name: 'Read', tool_input: {} })
+ const { body, match } = assertContractHonoured('/hook', requests[0])
+ assert.equal(match, 'a tool with nothing to say about it')
+ assert.equal(body.tool, 'Read')
+ assert.ok(!('detail' in body), `sent a detail anyway: ${requests[0].raw}`)
+})
+
+// The field, not the tool name, is what one rule can cover for both harnesses.
+// The payloads here are the ones a real run produces — the Codex shapes were
+// taken off `codex exec` against a stub listener.
+const DETAILS = [
+ ['a command line', { tool_name: 'Bash', tool_input: { command: 'pnpm test' } }, 'pnpm test'],
+ [
+ 'the file a codex patch adds',
+ {
+ tool_name: 'apply_patch',
+ tool_input: { command: '*** Begin Patch\n*** Add File: probe.txt\n+hello\n*** End Patch' },
+ },
+ 'probe.txt',
+ ],
+ [
+ 'the file a codex patch updates',
+ {
+ tool_name: 'apply_patch',
+ tool_input: {
+ command: '*** Begin Patch\n*** Update File: internal/terminal/usage.go\n@@\n-a\n+b\n',
+ },
+ },
+ 'internal/terminal/usage.go',
+ ],
+ [
+ 'the file an edit acts on, against the session directory',
+ { cwd: '/w', tool_name: 'Edit', tool_input: { file_path: '/w/internal/terminal/usage.go' } },
+ 'internal/terminal/usage.go',
+ ],
+ [
+ 'a file outside the session directory by its name alone',
+ { cwd: '/w', tool_name: 'Read', tool_input: { file_path: '/etc/hosts' } },
+ 'hosts',
+ ],
+ [
+ 'a command that starts with a path, uncut',
+ { cwd: '/w', tool_name: 'Bash', tool_input: { command: '/usr/bin/env node build.mjs' } },
+ '/usr/bin/env node build.mjs',
+ ],
+ ['a search pattern', { tool_name: 'Grep', tool_input: { pattern: 'statusEvent' } }, 'statusEvent'],
+ [
+ 'a fetched url',
+ { tool_name: 'WebFetch', tool_input: { url: 'https://example.com/a' } },
+ 'https://example.com/a',
+ ],
+]
+
+for (const [label, payload, want] of DETAILS) {
+ test(`report-tool names ${label}`, async () => {
+ const { requests } = await runToolHook(payload)
+ const { body } = assertContractHonoured('/hook', requests[0])
+ assert.equal(body.state, 'busy')
+ assert.equal(body.tool, payload.tool_name)
+ assert.equal(body.detail, want)
+ })
+}
+
+// The whole patch is what Codex passes as the command, so the plain rule would
+// put its envelope on the card. A patch naming no file leaves nothing readable,
+// and the tool name goes alone rather than "*** Begin Patch".
+test('report-tool sends the tool alone when a patch names no file', async () => {
+ const { requests } = await runToolHook({
+ tool_name: 'apply_patch',
+ tool_input: { command: '*** Begin Patch\nmalformed' },
+ })
+ const { body, match } = assertContractHonoured('/hook', requests[0])
+ assert.equal(match, 'a tool with nothing to say about it')
+ assert.equal(body.tool, 'apply_patch')
+ assert.ok(!('detail' in body), `put a patch envelope on the card: ${requests[0].raw}`)
+})
+
+test('report-tool escapes a detail that would break the JSON body', async () => {
+ const command = 'grep -n "state" internal/*.go | sed \'s/\\t/ /g\''
+ const { requests } = await runToolHook({ tool_name: 'Bash', tool_input: { command } })
+ const { body } = assertContractHonoured('/hook', requests[0])
+ assert.equal(body.detail, command)
+})
+
+// A card is one line high; the rest of a heredoc has nowhere to go.
+test('report-tool sends one line of a multi-line command', async () => {
+ const { requests } = await runToolHook({
+ tool_name: 'Bash',
+ tool_input: { command: 'cat <