From 88a27030329de2cdc4acc6fa0deb455ffc50a657 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 10 Aug 2026 11:31:51 -0300 Subject: [PATCH] feat: report the tool a turn is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A busy lich card showed a spinning ring and nothing else, so three seconds into a file read and three minutes into a test run looked the same. `report-tool.sh` rides `PreToolUse` in both harnesses and reports the same `busy` with two more fields: the tool's name and what it acts on. lich draws them under the session's label and clears them when the state leaves `busy`, so this script never has to report an end. The detail is read by field, never by tool name — `command`, then `file_path` / `path`, then `pattern` / `url` / `query`. That is what lets one rule cover both harnesses: Codex reports a shell call as `Bash` carrying the same `command` string Claude Code sends, and routes reading and searching through it. Every payload shape here was taken off a real run of both CLIs against a stub listener, not off their docs. Two shapes needed more than the plain rule. `apply_patch` — Codex's one word of its own — passes the whole patch as its command, so the file its `*** Add/Update/Delete File:` line names goes on the card instead of `*** Begin Patch`. And a path is shortened against the payload's `cwd`, because both harnesses report absolute ones and a card is 240px wide; outside that directory only the file name survives. Commands are never shortened, since a leading slash there is a binary. Without jq 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. `PreToolUse` is the first contract event on the agent's critical path: a hook exiting 2 there blocks the tool call, which makes the client rules load-bearing rather than polite. The suite covers it as it covers the others, plus the two shapes above and the no-jq fallback. Needs lich >= 0.29.0; an older one ignores the two fields. --- CHANGELOG.md | 18 +++ CLAUDE.md | 2 +- README.md | 1 + docs/providers.md | 6 +- docs/session-state.md | 60 ++++++++-- hooks/codex-hooks.json | 11 ++ hooks/hooks.json | 10 ++ hooks/report-tool.sh | 76 +++++++++++++ tests/fixtures/session-state.jsonl | 8 +- tests/hooks.test.mjs | 173 +++++++++++++++++++++++++++-- 10 files changed, 344 insertions(+), 21 deletions(-) create mode 100755 hooks/report-tool.sh 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 < { + const { requests } = await runToolHook( + { tool_name: 'Bash', tool_input: { command: 'pnpm test' } }, + { env: { PATH: binWithout('jq') } }, + ) + const { body, match } = assertContractHonoured('/hook', requests[0]) + assert.equal(match, 'a tool with nothing to say about it') + assert.equal(body.tool, 'Bash') + assert.ok(!('detail' in body), `built a detail without jq: ${requests[0].raw}`) +}) + // 5: the client rules — a hook must never block or fail the user's turn. +// `event` is the payload the script is fed: only report-tool.sh reads fields +// that a SessionStart payload does not carry. const EVERY_SCRIPT = [ - { script: 'report-state.sh', argument: 'busy' }, - { script: 'report-session-start.sh', argument: 'claude' }, - { script: 'report-title.sh', argument: '' }, - { script: 'report-touched.sh', argument: '' }, + { script: 'report-state.sh', argument: 'busy', event: 'SessionStart' }, + { script: 'report-tool.sh', argument: '', event: 'PreToolUse' }, + { script: 'report-session-start.sh', argument: 'claude', event: 'SessionStart' }, + { script: 'report-title.sh', argument: '', event: 'SessionStart' }, + { script: 'report-touched.sh', argument: '', event: 'SessionStart' }, ] const PARTIAL_ENVS = { @@ -553,14 +708,14 @@ const PARTIAL_ENVS = { 'blank LICH_SESSION_ID': { LICH_PORT: '1', LICH_TOKEN: TOKEN, LICH_SESSION_ID: '' }, } -for (const { script, argument } of EVERY_SCRIPT) { +for (const { script, argument, event } of EVERY_SCRIPT) { for (const [label, env] of Object.entries(PARTIAL_ENVS)) { test(`${script} no-ops outside lich: ${label}`, async () => { await withStub(async (stub) => { const result = await runHook(`"${ROOT}/hooks/${script}" ${argument}`.trim(), { // LICH_PORT points at the stub so a leak would be caught, not refused. env: { ...env, ...(('LICH_PORT' in env) ? { LICH_PORT: String(stub.port) } : {}) }, - stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }), + stdin: stdinFor({ provider: 'claude', event }), }) assertSilentSuccess(result) assert.equal(stub.requests.length, 0, 'reported without a full lich environment') @@ -573,7 +728,7 @@ for (const { script, argument } of EVERY_SCRIPT) { async (stub) => { const result = await runHook(`"${ROOT}/hooks/${script}" ${argument}`.trim(), { env: lichEnv(stub.port), - stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }), + stdin: stdinFor({ provider: 'claude', event }), }) assertSilentSuccess(result) assert.equal(stub.requests.length, 1) @@ -588,7 +743,7 @@ for (const { script, argument } of EVERY_SCRIPT) { await stub.close() // nothing listens on that port any more const result = await runHook(`"${ROOT}/hooks/${script}" ${argument}`.trim(), { env: lichEnv(port), - stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }), + stdin: stdinFor({ provider: 'claude', event }), }) assertSilentSuccess(result) })