diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc39d7..a02a5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Report a session's title from the first event of the turn that can carry it, + instead of waiting for the turn to end. Claude Code writes its generated title + about three seconds into a turn, so a long first turn showed the card's + placeholder name for its whole run and an interrupted one never got a name at + all — both now take the title while the turn is still going. The in-turn + report stops scanning the transcript once lich has accepted a title, so a turn + with many tool calls pays for one scan rather than one per call. + ## [0.11.1] - 2026-08-23 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 8f647af..dec51fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [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-title.md](docs/session-title.md) — the provider's own session title, via `PostToolUse`/`PreInvocation` and `Stop` - [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only) - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report diff --git a/docs/providers.md b/docs/providers.md index 796db55..0b8cb10 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -284,7 +284,10 @@ against a stub listener — not off a name that reads plausibly. - **`PreInvocation` fires before every model call, not once per turn.** Session id and `busy` therefore go out several times a turn. Both are idempotent, so the card is right either way, and it is what makes `busy` cheap enough to leave - on the same event as the session id. + on the same event as the session id. The title rides it too — this is + Antigravity's earliest event, and the only one before `Stop` that is not + matched to write tools — and pays for the repetition with `once`, which stops + it re-reading the transcript after lich has taken a title. - **Tool names can only be measured, never derived.** The CLI's own guide says a tool name is its step type lowercased with the `CORTEX_STEP_TYPE_` prefix dropped — and that is not what arrives: the enum's `MCP_TOOL` reaches a hook diff --git a/docs/session-title.md b/docs/session-title.md index a0ffa4f..0dddeb4 100644 --- a/docs/session-title.md +++ b/docs/session-title.md @@ -6,11 +6,29 @@ the lich repository; this plugin only implements it. Reports the title the agent CLI gives its own session, so lich can name the session card after it. lich only applies it while the card's label is still -automatic, so re-sending on every `Stop` is idempotent. +automatic, so re-sending is idempotent. -| script | action | Claude Code hook | Codex hook | Antigravity hook | opencode event | omp event | Crush hook | -|-------------------|---------------------------|------------------|------------|------------------|-------------------|------------------------------|------------| -| `report-title.sh` | send the session's title | `Stop` | `Stop` | `Stop` | `session.updated` | `session_stop`, `turn_start` | — | +| script | action | Claude Code hook | Codex hook | Antigravity hook | opencode event | omp event | Crush hook | +|-------------------|---------------------------|------------------------|------------------------|--------------------------|-------------------|------------------------------|------------| +| `report-title.sh` | send the session's title | `PostToolUse` + `Stop` | `PostToolUse` + `Stop` | `PreInvocation` + `Stop` | `session.updated` | `session_stop`, `turn_start` | — | + +**The turn's end is too late to be the only place.** Claude Code fires its title +call in parallel with the turn's own first model call, so the title is on disk +about three seconds in — while a turn that runs for ten minutes used to show +`Session 3` for all ten, and an interrupted one never got a name at all. Codex +and Antigravity are later still by nothing: their title is the user's own first +message, which exists before the turn does. So the report goes out from the +turn's first event that can carry it, and again at `Stop`. + +The in-turn registrations pass **`once`**, and that argument is the whole +difference: the script latches on the first title it gets lich to accept +(an empty `$TMPDIR/lich-title-$LICH_SESSION_ID`) and stops looking. Finding a +title means scanning a transcript that grows all turn — 324ms on a 168MB one — +and the in-turn hook fires on every tool call, so without the latch a fifty-tool +turn would pay that fifty times to learn what it knew after the first. A refusal +does not latch: a title lich did not take is worth looking for again next call. +`Stop` runs without the argument and keeps scanning, which is the path a later +retitle arrives on. `POST /session-title` with `{"session_id": $LICH_SESSION_ID, "title": }`. Providers keep the title in the transcript file (`transcript_path` or `transcriptPath` on diff --git a/hooks.json b/hooks.json index 8be3809..8ed6597 100644 --- a/hooks.json +++ b/hooks.json @@ -10,6 +10,11 @@ "type": "command", "command": "hooks/report-state.sh busy; printf '{}'", "timeout": 5 + }, + { + "type": "command", + "command": "hooks/report-title.sh once; printf '{}'", + "timeout": 5 } ], "PreToolUse": [ diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json index 3238793..744ded4 100644 --- a/hooks/codex-hooks.json +++ b/hooks/codex-hooks.json @@ -47,6 +47,16 @@ } ] }, + { + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\" once", + "commandWindows": "\"${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd\" \"${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh\" once", + "timeout": 5 + } + ] + }, { "matcher": "apply_patch|Edit|Write|Bash", "hooks": [ diff --git a/hooks/hooks.json b/hooks/hooks.json index 5a323dd..ac1f0f2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -39,6 +39,14 @@ } ] }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh once" + } + ] + }, { "matcher": "Edit|Write|NotebookEdit|Bash", "hooks": [ diff --git a/hooks/report-title.sh b/hooks/report-title.sh index cc911d7..bc93dad 100755 --- a/hooks/report-title.sh +++ b/hooks/report-title.sh @@ -2,11 +2,26 @@ # Reports the provider's own session title to lich. # Contract: docs/session-title.md # stdin: hook payload (JSON with "transcript_path"). +# +# `once` is how the in-turn registrations run it: report at most one title per +# session, then stop looking. Without it — the Stop registration — every call +# scans, which is what carries a later retitle. # Outside lich (vars absent) → no-op. Safe to install globally. [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 command -v jq >/dev/null 2>&1 || exit 0 +# Checked before stdin is read, because it is the whole point: finding a title +# means scanning a transcript that grows all turn — 324ms on a 168MB one — and +# the in-turn registration fires on every tool call. Claude writes the title ~3s +# into the turn, so the first scan that finds it is the last one worth paying +# for. The file is empty; its existence is the signal. +latch="" +if [ "$1" = "once" ]; then + latch="${TMPDIR:-/tmp}/lich-title-${LICH_SESSION_ID}" + [ -e "$latch" ] && exit 0 +fi + transcript_path=$(jq -r '.transcriptPath // .transcript_path // empty' 2>/dev/null) [ -f "$transcript_path" ] || exit 0 @@ -48,11 +63,15 @@ title=$(printf '%s' "$title" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' body=$(jq -cn --arg sid "$LICH_SESSION_ID" --arg title "$title" \ '{session_id: $sid, title: ($title[0:80])}') -curl -s -o /dev/null --max-time 1 \ +# -f so a refusal counts as one: a title lich did not take is one worth looking +# for again on the next tool call, where latching on it would wait for Stop. +if curl -sf -o /dev/null --max-time 1 \ -X POST "http://127.0.0.1:${LICH_PORT}/session-title?token=${LICH_TOKEN}" \ -H 'Content-Type: application/json' \ - -d "$body" \ - || true + -d "$body" +then + [ -n "$latch" ] && : >"$latch" 2>/dev/null +fi # Never blocks or fails the turn. exit 0 diff --git a/tests/fixtures/session-start.jsonl b/tests/fixtures/session-start.jsonl index 3a104e7..f2fe64d 100644 --- a/tests/fixtures/session-start.jsonl +++ b/tests/fixtures/session-start.jsonl @@ -3,6 +3,7 @@ {"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":"omp session id","body":{"session_id":"s1","provider_session_id":"019ffb38-ceab-7000-afae-20b8eae145d8","provider":"omp"},"accept":{"session_id":"s1","provider_session_id":"019ffb38-ceab-7000-afae-20b8eae145d8","provider":"omp"}} {"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":"cursor chat id","body":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"},"accept":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"}} {"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/tests/hooks.test.mjs b/tests/hooks.test.mjs index 1c62289..f1c8bab 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -45,12 +45,24 @@ const BASE_ENV = Object.fromEntries( * folder holding hooks.json — so it is run here with the variable unset, which * is the regression test for a registration that reaches for it anyway: the * command resolves to /hooks/<script> and exits 127. + * + * `tmpdir` is a scratch directory of the run's own, because a hook keeps state + * there: report-title.sh latches under $TMPDIR so it stops scanning once it has + * reported. A fresh one per call is what keeps these tests independent of each + * other and out of the developer's /tmp — the latch is keyed by session id, and + * every test here uses the same one. A test that means to exercise what carries + * across calls passes the same directory to both. */ -function runHook(command, { env = {}, stdin = '', pluginRoot = true } = {}) { +function runHook(command, { env = {}, stdin = '', pluginRoot = true, tmpdir = null } = {}) { return new Promise((resolve) => { const child = spawn('/bin/sh', ['-c', command], { cwd: ROOT, - env: { ...BASE_ENV, ...(pluginRoot ? { CLAUDE_PLUGIN_ROOT: ROOT } : {}), ...env }, + env: { + ...BASE_ENV, + TMPDIR: tmpdir ?? mkdtempSync(path.join(TMP, 'run-')), + ...(pluginRoot ? { CLAUDE_PLUGIN_ROOT: ROOT } : {}), + ...env, + }, stdio: ['pipe', 'pipe', 'pipe'], }) let stdout = '' @@ -370,7 +382,7 @@ test('the vendored fixtures parse and carry exactly one body and one verdict', ( assert.deepEqual([...STATES].sort(), ['busy', 'done', 'idle', 'waiting']) assert.deepEqual( [...PROVIDERS].sort(), - ['antigravity', 'claude', 'codex', 'crush', 'omp', 'opencode'], + ['antigravity', 'claude', 'codex', 'crush', 'cursor', 'omp', 'opencode'], ) }) @@ -748,6 +760,73 @@ test('session-title trims the title it reports', async () => { }) }) +// The in-turn registrations run the script as `once`, on every tool call, and +// finding a title means scanning a transcript that grows all turn. So `once` +// reports the first one it finds and then stops looking, while the Stop +// registration keeps scanning — that is the path a later retitle arrives on. +// Both calls share a $TMPDIR here, because the latch living there is the thing +// under test. +test('session-title reported once per session stops scanning', async () => { + await withStub(async (stub) => { + const scratch = mkdtempSync(path.join(TMP, 'latch-')) + const file = claudeTranscript('Fix the replay ring overflow', 'latched.jsonl') + const stdin = JSON.stringify({ session_id: 's', transcript_path: file }) + const run = () => + runHook(`"${ROOT}/hooks/report-title.sh" once`, { + env: lichEnv(stub.port), + stdin, + tmpdir: scratch, + }) + + assertHookSucceeded(await run()) + assertContractHonoured('/session-title', stub.requests[0]) + assertHookSucceeded(await run()) + assert.equal(stub.requests.length, 1, `reported ${stub.requests.length} times, expected 1`) + }) +}) + +test('session-title without the latch reports every time, for a retitle', async () => { + await withStub(async (stub) => { + const scratch = mkdtempSync(path.join(TMP, 'unlatched-')) + const first = claudeTranscript('Fix the replay ring overflow', 'retitle-first.jsonl') + const second = claudeTranscript('Rewrite the replay ring', 'retitle-second.jsonl') + const run = (file) => + runHook(`"${ROOT}/hooks/report-title.sh"`, { + env: lichEnv(stub.port), + stdin: JSON.stringify({ session_id: 's', transcript_path: file }), + tmpdir: scratch, + }) + + assertHookSucceeded(await run(first)) + assertHookSucceeded(await run(second)) + assert.equal(stub.requests.length, 2, `reported ${stub.requests.length} times, expected 2`) + assert.equal(assertContractHonoured('/session-title', stub.requests[1]).body.title, 'Rewrite the replay ring') + }) +}) + +// A refusal must not latch: a title lich did not take is one to look for again +// on the next tool call, where latching on it would wait for Stop instead. +test('session-title does not latch on a report lich refused', async () => { + await withStub( + async (stub) => { + const scratch = mkdtempSync(path.join(TMP, 'refused-')) + const file = claudeTranscript('Fix the replay ring overflow', 'refused.jsonl') + const stdin = JSON.stringify({ session_id: 's', transcript_path: file }) + const run = () => + runHook(`"${ROOT}/hooks/report-title.sh" once`, { + env: lichEnv(stub.port), + stdin, + tmpdir: scratch, + }) + + assertHookSucceeded(await run()) + assertHookSucceeded(await run()) + assert.equal(stub.requests.length, 2, `reported ${stub.requests.length} times, expected 2`) + }, + { status: 500 }, + ) +}) + test('session-title escapes a title that would break the JSON body', async () => { await withStub(async (stub) => { const file = claudeTranscript('Fix "quoted" \\ and \ttabbed', 'quoted-title.jsonl')