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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions docs/session-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <title>}`.
Providers keep the title in the transcript file (`transcript_path` or `transcriptPath` on
Expand Down
5 changes: 5 additions & 0 deletions hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 10 additions & 0 deletions hooks/codex-hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 8 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/report-title.sh once"
}
]
},
{
"matcher": "Edit|Write|NotebookEdit|Bash",
"hooks": [
Expand Down
25 changes: 22 additions & 3 deletions hooks/report-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions tests/fixtures/session-start.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
85 changes: 82 additions & 3 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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'],
)
})

Expand Down Expand Up @@ -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')
Expand Down
Loading