From ae8fafee23e2aa15f7ff02482f739cd3d83f60a1 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 16:53:49 -0300 Subject: [PATCH 1/5] feat(mod): report a worker's blank or refused turn as unanswered A lich subagent worker whose final turn ended blank or refused posted nothing, so its caller waited on an errand that would never be answered. The mod now posts {session_id, unanswered: blank|refusal} to /mod/answer when the turn's own main-loop Stop lists no background task. An aborted turn still posts nothing. unanswered: error stays unsent: an API error fires StopFailure instead of Stop, and in Claude Code 2.1.296 neither StopFailure's input nor turn.complete's carries background_tasks, so the mod cannot tell the failed turn left nothing running. turn.complete is now matched once per reason, so every turn drops the Stop it read. mod-answer.jsonl is read at lich's unreleased 040195f5. --- CHANGELOG.md | 10 ++++ docs/mod-answer.md | 29 +++++++--- hooks/worker-answer.js | 91 +++++++++++++++++++++--------- tests/contract.mjs | 5 +- tests/fixtures/mod-answer.jsonl | 7 ++- tests/refresh-fixtures.sh | 10 +++- tests/worker-answer.engine.test.ts | 16 ++++++ tests/worker-answer.test.mjs | 68 +++++++++++++++++++--- 8 files changed, 190 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06dda82..aafece5 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] +### Added + +- **A lich subagent worker whose turn has nothing to answer now says so.** A + final turn that ends blank or refused, with nothing left running in the + background, posts `unanswered` (`blank` or `refusal`) to `/mod/answer`, + so the caller hears the errand ended instead of waiting on it. A turn an API + error ended still reports nothing: Claude Code's `StopFailure` carries no + `background_tasks`, so the mod cannot tell it left nothing running. Needs a + lich that accepts `unanswered`. + ## [0.19.2] - 2026-10-09 ### Fixed diff --git a/docs/mod-answer.md b/docs/mod-answer.md index 228628c..7e7f59d 100644 --- a/docs/mod-answer.md +++ b/docs/mod-answer.md @@ -17,6 +17,8 @@ reply instructions, and answers the errand with what this mod posts. | event | sent | |-------------------------------------------|-------------------------------------------------------------| | `classic.Stop`, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "text"}` | +| `classic.Stop` with a blank message, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "unanswered": "blank"}` | +| `classic.Stop`, then `turn.complete{reason=refusal}` | `POST /mod/answer` with `{"session_id", "unanswered": "refusal"}` | ## How it behaves @@ -33,18 +35,31 @@ reply instructions, and answers the errand with what this mod posts. subagent, a monitor, a workflow); Claude Code resumes in a new turn when that work finishes, and that turn's `Stop`, with nothing listed, is the one that answers. -- **An aborted, failed or refused turn does not answer**, and neither does one - whose final message is blank. `turn.complete` is matched on - `reason: "answer"`, which also keeps the hook beside mod-control's own +- **A turn with nothing to answer says so.** One whose final message is blank + posts `unanswered: "blank"`, a refused one `unanswered: "refusal"`, so the + caller hears the errand ended instead of waiting on it. Both follow the rule + above: only when the turn's own `classic.Stop` lists nothing running. A + refused turn with no `Stop` of its own reports nothing. +- **An aborted turn does not report**: its user took the worker over, and the + turn they start next is the one that answers. +- **A failed turn does not report either**, though the contract has + `unanswered: "error"` for it. An API error fires `StopFailure` instead of + `Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s + input (`error`, `error_details`, `last_assistant_message`) nor + `turn.complete`'s carries `background_tasks`, and no mod engine call lists + them. Nothing tells whether the failed turn left work running, so `error` is + pending until Claude Code exposes that list on the failure. +- `turn.complete` is matched once per `reason` (`answer`, `refusal`, + `error`, `aborted`), which keeps the hooks beside mod-control's own `turn.complete` hook: Claude Code refuses one plugin's second hook on an event - with no matcher. + with no matcher. Every one of them drops the `Stop` it read. - **A subagent's turn inside the worker does not answer.** `classic.Stop` fires on the main loop only, and a `turn.complete` that carries an `agentId` is skipped. - **A Stop answers only for its own turn.** The text is posted when the - completion's `answer` equals the Stop's `last_assistant_message`, and the - Stop's verdict is dropped once a completion read it: an aborted turn fires no - Stop, and must not inherit the last one. + completion's `answer` equals the Stop's `last_assistant_message`, a blank + Stop only beside a blank `answer`, and the Stop is dropped once a completion + read it: an aborted turn fires no Stop, and must not inherit the last one. - **The text is cut at 16,000 UTF-16 units** and marked `[truncated]`, as an `ask`'s answer is. - **The turn is never held up.** The answer is posted beside the chain; one diff --git a/hooks/worker-answer.js b/hooks/worker-answer.js index eb5aff8..56a5fbd 100644 --- a/hooks/worker-answer.js +++ b/hooks/worker-answer.js @@ -11,14 +11,21 @@ // cards off for also carries; lich ignores the report from a session with no // subagent errand open, so that one costs a request per turn and nothing else. // -// A turn is the worker's answer when its main-loop `classic.Stop` lists no -// background task (a shell, subagent, monitor or workflow still running means -// the turn handed work to the background, and the turn Claude Code resumes in -// once it finishes is the one that answers) and its `turn.complete` ended with -// an answer that is not blank. Measured on Claude Code 2.1.289: `classic.Stop` -// fires on the main loop only, before `turn.complete`, and its -// `last_assistant_message` equals the completion's `answer`; an aborted turn -// fires no Stop. +// A main-loop turn reports only when its `classic.Stop` lists no background +// task (a shell, subagent, monitor or workflow still running means the turn +// handed work to the background, and the turn Claude Code resumes in once it +// finishes is the one that reports). Its `turn.complete` then says what: an +// answer that is not blank is the worker's answer, a blank one is +// `unanswered: "blank"`, a refusal `unanswered: "refusal"`. Measured on +// Claude Code 2.1.289: `classic.Stop` fires on the main loop only, before +// `turn.complete`, and its `last_assistant_message` equals the completion's +// `answer`; an aborted turn fires no Stop. +// +// A turn an API error ended reports nothing, though the contract has +// `unanswered: "error"` for it: such a turn fires `StopFailure` instead of +// `Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s +// input nor `turn.complete`'s carries `background_tasks`, so nothing tells +// whether the failed turn left work running. // // Every function that takes `$` is declared here at the top level: the loader // refuses a module that hands `$` to a nested function. @@ -37,7 +44,9 @@ const ANSWER_LIMIT = 16000 /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ base: string, token: string, session: string }} Link - * @typedef {{ link?: Link, idleText?: string }} State + * @typedef {{ idle: boolean, text: string }} Stop + * @typedef {{ link?: Link, stop?: Stop }} State + * @typedef {{ text: string } | { unanswered: "blank" | "refusal" }} Report */ /** @@ -75,26 +84,58 @@ function cut(text) { } /** - * Posts one answer beside the chain. One that cannot be sent is dropped: the + * What a finished main-loop turn reports, if anything. The Stop must be the + * turn's own: an answer's text equals the completion's, a blank Stop comes + * with a blank completion. + * + * @param {Stop | undefined} stop the turn's main-loop `classic.Stop` + * @param {import('claude-code').TurnCompleteInput} e + * @returns {Report | undefined} + */ +function reportOf(stop, e) { + if (stop === undefined || !stop.idle) return undefined + if (e.reason === "refusal") return { unanswered: "refusal" } + if (e.reason !== "answer") return undefined + if (stop.text.trim() === "") return e.answer.trim() === "" ? { unanswered: "blank" } : undefined + return stop.text === e.answer ? { text: cut(stop.text) } : undefined +} + +/** + * Posts one report beside the chain. One that cannot be sent is dropped: the * worker's card still holds it, and a second copy of a report is worse than * none. * * @param {Engine} $ * @param {Link} link - * @param {string} text + * @param {Report} body */ -async function report($, link, text) { +async function report($, link, body) { try { await $.http.fetch(`${link.base}/mod/answer?token=${link.token}`, { method: "POST", headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION }, - body: JSON.stringify({ session_id: link.session, text: cut(text) }), + body: JSON.stringify({ session_id: link.session, ...body }), }) } catch { // The contract's client rule: a report that cannot be sent is dropped. } } +/** + * @param {Engine} $ + * @param {State} state + * @param {import('claude-code').TurnCompleteInput} e + * @param {(e: import('claude-code').TurnCompleteInput) => unknown} next + */ +function settleTurn($, state, e, next) { + const stop = state.stop + state.stop = undefined + const link = state.link + const body = link !== undefined && e.agentId === undefined ? reportOf(stop, e) : undefined + if (link !== undefined && body !== undefined) void report($, link, body) + return next(e) +} + /** @param {import('claude-code').On} on */ export function register(on) { /** @type {State} */ @@ -108,21 +149,17 @@ export function register(on) { }) on("classic.Stop", ($, e, next) => { - const idle = (e.background_tasks ?? []).length === 0 - const text = e.last_assistant_message ?? "" - state.idleText = idle && text.trim() !== "" ? text : undefined + state.stop = { idle: (e.background_tasks ?? []).length === 0, text: e.last_assistant_message ?? "" } return next(e) }) - // The matcher keeps this beside mod-control.js's own turn.complete hook, which - // has none: Claude Code refuses one plugin's second hook on an event with no - // matcher. An aborted, failed or refused turn never matches. - on("turn.complete", { reason: "answer" }, ($, e, next) => { - const text = state.idleText - state.idleText = undefined - const link = state.link - const isWorkersAnswer = link !== undefined && e.agentId === undefined && !e.isAborted && text === e.answer - if (isWorkersAnswer && text !== undefined) void report($, link, text) - return next(e) - }) + // One hook per reason a turn ends with: the matchers keep them beside + // mod-control.js's own turn.complete hook, which has none, and Claude Code + // refuses one plugin's second hook on an event with no matcher. The aborted + // and failed ones report nothing; they are here so every turn drops the Stop + // it read, and a later turn never inherits it. + on("turn.complete", { reason: "answer" }, ($, e, next) => settleTurn($, state, e, next)) + on("turn.complete", { reason: "refusal" }, ($, e, next) => settleTurn($, state, e, next)) + on("turn.complete", { reason: "error" }, ($, e, next) => settleTurn($, state, e, next)) + on("turn.complete", { reason: "aborted" }, ($, e, next) => settleTurn($, state, e, next)) } diff --git a/tests/contract.mjs b/tests/contract.mjs index 5a17a5e..9b75712 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -60,6 +60,7 @@ export const rejected = (endpoint) => CASES[endpoint].filter((c) => c.reject) export const STATES = new Set(accepted('/hook').map((c) => c.accept.state)) export const PROVIDERS = new Set(accepted('/session-start').map((c) => c.accept.provider)) export const MOD_KINDS = new Set(accepted('/mod/acks').map((c) => c.body.kind)) +export const UNANSWERED = new Set(accepted('/mod/answer').flatMap((c) => c.body.unanswered ?? [])) /** * Providers this plugin already reports for, and lich does not accept yet. @@ -136,7 +137,9 @@ export const REJECT_RULES = { }, '/mod/answer': { 'missing session_id': (b) => !('session_id' in b), - 'missing text': (b) => !('text' in b), + 'neither text nor unanswered': (b) => !('text' in b) && !('unanswered' in b), + 'text and unanswered together': (b) => 'text' in b && 'unanswered' in b, + 'an aborted turn is not reported': (b) => 'unanswered' in b && !UNANSWERED.has(b.unanswered), 'blank text': (b) => typeof b.text === 'string' && b.text.trim() === '', 'text is not a string': (b) => 'text' in b && typeof b.text !== 'string', 'malformed json': (b, raw) => !isJsonObject(raw), diff --git a/tests/fixtures/mod-answer.jsonl b/tests/fixtures/mod-answer.jsonl index d68e249..18803b1 100644 --- a/tests/fixtures/mod-answer.jsonl +++ b/tests/fixtures/mod-answer.jsonl @@ -2,7 +2,12 @@ {"name":"a report keeps its lines","body":{"session_id":"s1","text":"Done.\n\n- a < b && c > d\n- `go test` green"},"accept":{"text":"Done.\n\n- a < b && c > d\n- `go test` green"}} {"name":"extra fields are ignored","body":{"session_id":"s1","text":"Done.","turn_id":"t"},"accept":{"text":"Done."}} {"name":"missing session_id","body":{"text":"Done."},"reject":"session_id is required"} -{"name":"missing text","body":{"session_id":"s1"},"reject":"text is required"} +{"name":"neither text nor unanswered","body":{"session_id":"s1"},"reject":"exactly one of text and unanswered"} {"name":"blank text","body":{"session_id":"s1","text":" \n\t"},"reject":"a blank text is no answer"} {"name":"text is not a string","body":{"session_id":"s1","text":42},"reject":"text must be a string"} {"name":"malformed json","raw":"{","reject":"body must be a JSON object"} +{"name":"a blank turn","body":{"session_id":"s1","unanswered":"blank"},"accept":{"session_id":"s1","unanswered":"blank"}} +{"name":"an api error","body":{"session_id":"s1","unanswered":"error"},"accept":{"unanswered":"error"}} +{"name":"a refusal","body":{"session_id":"s1","unanswered":"refusal"},"accept":{"unanswered":"refusal"}} +{"name":"an aborted turn is not reported","body":{"session_id":"s1","unanswered":"aborted"},"reject":"unanswered must be blank, error or refusal"} +{"name":"text and unanswered together","body":{"session_id":"s1","text":"Done.","unanswered":"blank"},"reject":"exactly one of text and unanswered"} diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 49b2762..077f0bf 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -21,11 +21,19 @@ base=$raw/docs/hooks/fixtures for file in session-start.jsonl session-state.jsonl session-title.jsonl \ session-touched.jsonl mod-usage.jsonl mod-control.jsonl mod-commands.json \ - mod-answer.jsonl mod-status.json; do + mod-status.json; do curl -fsSL "$base/$file" -o "$here/fixtures/$file" echo "refreshed $file at $ref" done +# mod-answer gained `unanswered` in lich's commit 040195f5, in no release yet, +# so it is read at that commit. Once a release ships it, move tests/lich-ref to +# that release and put the file back in the loop above. +unreleased=040195f593ae213154d4485a13ed2bdb905311fb +curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hooks/fixtures/mod-answer.jsonl" \ + -o "$here/fixtures/mod-answer.jsonl" +echo "refreshed mod-answer.jsonl at $unreleased (unreleased)" + # validate.mjs reads its token sets off this copy, so a token lich adds reaches # the validator by moving tests/lich-ref, not by hand. curl -fsSL "$raw/themes/template.json" -o "$here/../skills/theme/template.json" diff --git a/tests/worker-answer.engine.test.ts b/tests/worker-answer.engine.test.ts index 03fd799..19ada81 100644 --- a/tests/worker-answer.engine.test.ts +++ b/tests/worker-answer.engine.test.ts @@ -69,3 +69,19 @@ test('a worker lich names by its depth answers with subagent cards left on', asy await clock.settle() expect(world.answers).toEqual([{ session_id: 'lich-1', text: 'Rewrote docs/cli.md.' }]) }) + +test('a blank turn is reported unanswered, and a refused one too', async ($, on) => { + mock.env(on, WORKER) + const clock = mock.clock(on) + const world = lich(on) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await $.classic.Stop({ stop_hook_active: false, last_assistant_message: '', background_tasks: [] }) + await $.turn.complete({ ...ANSWERED, answer: '' }) + await $.classic.Stop({ stop_hook_active: false, last_assistant_message: 'No.', background_tasks: [] }) + await $.turn.complete({ ...ANSWERED, answer: 'No.', reason: 'refusal' }) + await clock.settle() + expect(world.answers).toEqual([ + { session_id: 'lich-1', unanswered: 'blank' }, + { session_id: 'lich-1', unanswered: 'refusal' }, + ]) +}) diff --git a/tests/worker-answer.test.mjs b/tests/worker-answer.test.mjs index 8478d61..992b20c 100644 --- a/tests/worker-answer.test.mjs +++ b/tests/worker-answer.test.mjs @@ -46,7 +46,7 @@ function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT) } = const hooks = new Map() register((event, ...rest) => { const hook = rest.pop() - hooks.set(event, { matcher: rest[0] ?? {}, hook }) + hooks.set(event, [...(hooks.get(event) ?? []), { matcher: rest[0] ?? {}, hook }]) }) const requests = [] const $ = { @@ -62,9 +62,11 @@ function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT) } = } const passOn = async (e) => e const fire = (event, e) => { - const { matcher, hook } = hooks.get(event) - const matches = Object.entries(matcher).every(([key, value]) => e[key] === value) - return matches ? hook($, e, passOn) : passOn(e) + const matching = hooks.get(event).filter(({ matcher }) => + Object.entries(matcher).every(([key, value]) => e[key] === value), + ) + const chain = matching.reduceRight((next, { hook }) => (ev) => hook($, ev, next), passOn) + return chain(e) } const turn = async (stopEvent, completeEvent) => { if (stopEvent) await fire('classic.Stop', stopEvent) @@ -101,23 +103,71 @@ test('a turn that handed work to the background answers only once it resumes', a assert.deepEqual(mod.bodies().map((b) => b.text), ['FINISHED: DONE']) }) -test('an aborted, failed or refused turn answers nothing', async () => { +test('a blank final message is reported unanswered as the contract spells it', async () => { + const mod = load() + await mod.start() + + await mod.turn(stop(' \n'), complete(' \n')) + await mod.turn(stop(undefined), complete('')) + + assert.deepEqual(mod.bodies(), [ + { session_id: LICH_SESSION_ID, unanswered: 'blank' }, + { session_id: LICH_SESSION_ID, unanswered: 'blank' }, + ]) + for (const request of mod.requests) assertContractHonoured('/mod/answer', request) +}) + +test('a refused turn is reported unanswered as the contract spells it', async () => { + const mod = load() + await mod.start() + + await mod.turn(stop('I cannot help with that.'), complete('I cannot help with that.', 'refusal')) + + assert.deepEqual(mod.bodies(), [{ session_id: LICH_SESSION_ID, unanswered: 'refusal' }]) + assertContractHonoured('/mod/answer', mod.requests[0]) +}) + +test('a blank or refused turn that handed work to the background reports nothing', async () => { + const mod = load() + await mod.start() + + await mod.turn(stop('', [SHELL]), complete('')) + await mod.turn(stop('I cannot help with that.', [SHELL]), complete('I cannot help with that.', 'refusal')) + + assert.equal(mod.requests.length, 0) +}) + +// A refused turn reads its background work off its own Stop; one without a +// Stop cannot tell whether work is still running. +test('a refused turn with no Stop of its own reports nothing', async () => { + const mod = load() + await mod.start() + + await mod.turn(undefined, complete('', 'refusal')) + + assert.equal(mod.requests.length, 0) +}) + +// An API error fires StopFailure, which carries no background_tasks, so the +// mod cannot tell the failed turn left nothing running (docs/mod-answer.md). +test('an aborted or failed turn reports nothing', async () => { const mod = load() await mod.start() await mod.turn(undefined, complete('half a sentence', 'aborted')) + await mod.turn(undefined, complete('', 'error')) await mod.turn(stop('partial'), complete('partial', 'error')) - await mod.turn(stop('I cannot help with that.'), complete('I cannot help with that.', 'refusal')) + await mod.turn(stop(''), complete('', 'error')) assert.equal(mod.requests.length, 0) }) -test('a blank final message answers nothing', async () => { +test('an aborted turn drops the Stop it read, so a later refusal cannot use it', async () => { const mod = load() await mod.start() - await mod.turn(stop(' \n'), complete(' \n')) - await mod.turn(stop(undefined), complete('')) + await mod.turn(stop(''), complete('', 'aborted')) + await mod.turn(undefined, complete('', 'refusal')) assert.equal(mod.requests.length, 0) }) From 7969295bddf7500b3610cfb6e342f9253fdb8b15 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 17:31:08 -0300 Subject: [PATCH 2/5] feat: name the conversation on every state report An agent CLI run as a tool inside a lich session (claude -p, cursor-agent -p) inherits LICH_* and its hooks reported as the host card: a nested cursor-agent -p SessionEnd closed the host's relay errands. lich now drops a state report whose provider_session_id is not the bound one. Every /hook report now carries provider_session_id when the harness hands one over: report-state.sh and report-tool.sh read .conversationId // .session_id off stdin (sed fallback without jq), opencode the event's sessionID, omp ctx.sessionManager.getSessionId(), and mod-compacting $.session.id(). report-state.sh reads stdin for every state now, bounded at one second so a pipe nobody writes to cannot hold the turn. session-state.jsonl is read at lich's unreleased faa40def. --- CHANGELOG.md | 7 ++++++ docs/session-state.md | 14 ++++++++++++ hooks/mod-compacting.js | 7 +++++- hooks/report-state.sh | 28 +++++++++++++++++++++--- hooks/report-tool.sh | 13 +++++++++++ omp/lich.js | 29 +++++++++++++++++-------- opencode/lich.js | 22 +++++++++++++++---- tests/contract.mjs | 23 +++++++++++++++----- tests/fixtures/session-state.jsonl | 2 ++ tests/hooks.test.mjs | 35 +++++++++++++++++++++++++++++- tests/mod-compacting.test.mjs | 6 +++-- tests/omp.test.mjs | 30 +++++++++++++++++++++++-- tests/opencode.test.mjs | 3 +++ tests/refresh-fixtures.sh | 10 ++++++++- 14 files changed, 200 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aafece5..de97954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 error ended still reports nothing: Claude Code's `StopFailure` carries no `background_tasks`, so the mod cannot tell it left nothing running. Needs a lich that accepts `unanswered`. +- **Every state report names the conversation it comes from**, in + `provider_session_id`, so lich can drop one from an agent CLI the session + runs as a tool (`claude -p`, `cursor-agent -p`), which inherits the session's + `LICH_*` variables and used to report as the card. The scripts, opencode, omp + and the compacting mod all send it; one the harness did not hand over is left + out. `report-state.sh` now reads stdin for every state, with a one-second + bound so a pipe nobody writes to cannot hold the turn. ## [0.19.2] - 2026-10-09 diff --git a/docs/session-state.md b/docs/session-state.md index 20ea612..0d5ae60 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -27,6 +27,20 @@ every turn passes through it, including the ones no `input` precedes. **Crush is absent from the table on purpose**: its only event is `PreToolUse`, and a `busy` nothing can end would pin a spinner to a card. See [providers.md](providers.md). +**Every report names the conversation it comes from** in +`provider_session_id`, the id [session-start](session-start.md) reports: an +agent CLI the session runs as a tool (`claude -p`, `cursor-agent -p`) inherits +the `LICH_*` variables, and lich drops a report naming any conversation but the +one bound to the card (Nested agent CLIs in the contract). The scripts read it +off the stdin payload (`.conversationId // .session_id`, with +report-session-start.sh's `sed` fallback when jq is absent), opencode off the +event's `sessionID`, omp off `ctx.sessionManager.getSessionId()`, and +`mod-compacting.js` off `$.session.id()`. An id the harness did not hand over +is left out, and lich takes the report as before. Because `busy`, `done` and +`idle` now read stdin too, `report-state.sh` reads it with the one-second bound +curl has: a harness that left the pipe open and unwritten still cannot hold the +turn. + **omp reports no `waiting`.** It raises `tool_approval_requested` when it asks to run something, but that event was never observed on a real run here, and a name taken off a type declaration is a report that silently never fires. An omp card diff --git a/hooks/mod-compacting.js b/hooks/mod-compacting.js index 52ef6e8..ac93103 100644 --- a/hooks/mod-compacting.js +++ b/hooks/mod-compacting.js @@ -45,16 +45,21 @@ async function linkFromEnv($) { } /** + * Posts one state, naming the conversation it comes from (`$.session.id()`, the + * id SessionStart hands lich) so lich can tell it from an agent CLI nested in + * the session (docs/session-state.md, Nested agent CLIs). + * * @param {Engine} $ * @param {Link} link * @param {'compacting' | Closing} sessionState */ async function report($, link, sessionState) { try { + const conversation = await $.session.id() await $.http.fetch(`${link.base}/hook?token=${link.token}`, { method: "POST", headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION }, - body: JSON.stringify({ session_id: link.session, state: sessionState }), + body: JSON.stringify({ session_id: link.session, state: sessionState, provider_session_id: conversation }), }) } catch { // The contract drops a report that cannot be sent; it never retries one. diff --git a/hooks/report-state.sh b/hooks/report-state.sh index 182180a..c474310 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -1,7 +1,8 @@ #!/bin/sh # Reports session state to lich. Contract: docs/session-state.md # Usage: report-state.sh -# stdin: the hook payload — read for `waiting` alone, for the reason below. +# stdin: the hook payload — read for the conversation id, and on `waiting` for +# the reason below. # Outside lich (vars absent) → no-op. Safe to install globally. [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 @@ -12,13 +13,21 @@ plugin_version=0.19.2 state=$1 body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}" +# Read with the bound curl has below: `busy`, `done` and `idle` ride events +# whose payload only names the conversation, and a harness that left the pipe +# open and unwritten must not hold the turn. The reader's input is fd 3 because +# an asynchronous command's stdin is /dev/null unless redirected, and the timer's +# output goes nowhere so the substitution never waits on it. +exec 3<&0 +payload=$(cat <&3 & reader=$!; (exec >/dev/null 2>&1; sleep 1 && kill "$reader") & wait "$reader") +exec 3<&- + # `reason` says what the agent is blocked on, and belongs to `waiting` alone — -# lich drops it on every other state, so no other branch reads stdin. It is +# lich drops it on every other state, so no other branch looks for one. It is # optional in both directions: an absent one still rings the bell, and a value # lich finds too long is capped there rather than refused, so nothing here # measures it. if [ "$state" = waiting ]; then - payload=$(cat) if command -v jq >/dev/null 2>&1; then # Claude Code's Notification is the one payload carrying a sentence written # for a human ("Claude needs your permission to use Bash"), and it writes one @@ -53,6 +62,19 @@ if [ "$state" = waiting ]; then fi fi +# The conversation that fired the hook, so lich can drop a report from an agent +# CLI nested in the session (docs/session-state.md, Nested agent CLIs). Parsed as +# report-session-start.sh parses it; an id needs no escaping, and none is left +# out rather than sent empty. +if command -v jq >/dev/null 2>&1; then + provider_session_id=$(printf '%s' "$payload" | jq -r '.conversationId // .session_id // empty' 2>/dev/null) +else + provider_session_id=$(printf '%s' "$payload" | \ + sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +fi +[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" + 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' \ diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 8953780..5c0e845 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -46,6 +46,19 @@ else body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"busy\",\"tool\":\"${tool}\"}" fi +# The conversation that fired the hook, so lich can drop a report from an agent +# CLI nested in the session (docs/session-state.md, Nested agent CLIs). Parsed as +# report-session-start.sh parses it; an id needs no escaping, and none is left +# out rather than sent empty. +if [ -n "$has_jq" ]; then + provider_session_id=$(printf '%s' "$payload" | jq -r '.conversationId // .session_id // empty' 2>/dev/null) +else + provider_session_id=$(printf '%s' "$payload" | \ + sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) +fi +[ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" + 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' \ diff --git a/omp/lich.js b/omp/lich.js index 958de20..280c849 100644 --- a/omp/lich.js +++ b/omp/lich.js @@ -67,6 +67,19 @@ function detailOf(input) { // it front-runs. const WRITERS = new Set(["write", "edit", "bash", "notebook"]) +// The id of the session the manager holds, or undefined when it holds none. +function sessionIdOf(ctx) { + const id = ctx?.sessionManager?.getSessionId?.() + return typeof id === "string" && id !== "" ? id : undefined +} + +// The conversation a state report comes from, so lich can drop one from an +// agent CLI nested in the session (docs/session-state.md, Nested agent CLIs). +function conversation(ctx) { + const id = sessionIdOf(ctx) + return id === undefined ? {} : { provider_session_id: id } +} + // omp catches what a handler throws, but a throwing extension is still a // visible startup warning and a line in its log. Nothing here is worth either. const safely = (fn) => (event, ctx) => { @@ -105,10 +118,8 @@ export default function lichPlugin(pi) { pi.on( "session_start", safely((_event, ctx) => { - const id = ctx?.sessionManager?.getSessionId?.() - if (typeof id === "string" && id !== "") { - report("session-start", { provider_session_id: id, provider: "omp" }) - } + const id = sessionIdOf(ctx) + if (id !== undefined) report("session-start", { provider_session_id: id, provider: "omp" }) }), ) @@ -116,7 +127,7 @@ export default function lichPlugin(pi) { // session, which is the only kind lich spawns. pi.on( "input", - safely(() => report("hook", { state: "busy" })), + safely((_event, ctx) => report("hook", { state: "busy", ...conversation(ctx) })), ) // Every turn passes through here, including the ones no `input` precedes — a @@ -126,7 +137,7 @@ export default function lichPlugin(pi) { pi.on( "turn_start", safely((_event, ctx) => { - report("hook", { state: "busy" }) + report("hook", { state: "busy", ...conversation(ctx) }) reportTitle(ctx) }), ) @@ -136,9 +147,9 @@ export default function lichPlugin(pi) { // nothing, always. pi.on( "tool_call", - safely((event) => { + safely((event, ctx) => { if (typeof event?.toolName !== "string" || event.toolName === "") return - report("hook", { state: "busy", tool: event.toolName, detail: detailOf(event.input) }) + report("hook", { state: "busy", tool: event.toolName, detail: detailOf(event.input), ...conversation(ctx) }) }), ) @@ -162,7 +173,7 @@ export default function lichPlugin(pi) { pi.on( "session_stop", safely((_event, ctx) => { - report("hook", { state: "done" }) + report("hook", { state: "done", ...conversation(ctx) }) reportTitle(ctx) }), ) diff --git a/opencode/lich.js b/opencode/lich.js index 32ae88c..14f1c70 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -28,6 +28,13 @@ function report(path, body) { } catch {} } +// The conversation a state report comes from, so lich can drop one from an +// agent CLI nested in the session (docs/session-state.md, Nested agent CLIs). +// An id opencode did not give is left out rather than sent empty. +function conversation(sessionID) { + return typeof sessionID === "string" && sessionID !== "" ? { provider_session_id: sessionID } : {} +} + // The name a card shows under a session's label, and the words for what it acts // on. opencode's args are per-tool; these are the keys that identify a call. function detailOf(args) { @@ -504,8 +511,10 @@ export const LichPlugin = async ({ $ } = {}, helper) => { // lich's own `idle` means the CLI has left, which nothing here can // report — the plugin dies with the server that would say it. const type = properties.status?.type - if (type === "idle") report("hook", { state: "done" }) - else if (type === "busy" || type === "retry") report("hook", { state: "busy" }) + if (type === "idle") report("hook", { state: "done", ...conversation(properties.sessionID) }) + else if (type === "busy" || type === "retry") { + report("hook", { state: "busy", ...conversation(properties.sessionID) }) + } return } case "file.edited": @@ -522,7 +531,7 @@ export const LichPlugin = async ({ $ } = {}, helper) => { // next status report, which follows a reply within ~100ms; a missing // one costs the user the session. if (event?.type?.endsWith(".asked") && !subSessions.has(properties.sessionID)) { - report("hook", { state: "waiting", reason: reasonOf(properties) }) + report("hook", { state: "waiting", reason: reasonOf(properties), ...conversation(properties.sessionID) }) } } }, @@ -531,7 +540,12 @@ export const LichPlugin = async ({ $ } = {}, helper) => { // report, so this one only decorates the state. "tool.execute.before": async (input, output) => { if (!input?.tool || subSessions.has(input.sessionID)) return - report("hook", { state: "busy", tool: input.tool, detail: detailOf(output?.args) }) + report("hook", { + state: "busy", + tool: input.tool, + detail: detailOf(output?.args), + ...conversation(input.sessionID), + }) }, // Absent outside lich, and absent when the helper cannot be imported: the diff --git a/tests/contract.mjs b/tests/contract.mjs index 9b75712..ac96a9f 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -174,15 +174,26 @@ export function isJsonObject(raw) { * Returns the name of the case it matches, or null. */ export function matchingAcceptCase(endpoint, body) { - const shape = (o) => - Object.keys(o) - .sort() - .map((k) => `${k}:${blank(o[k]) ? typeof o[k] + '(blank)' : typeof o[k]}`) - .join(',') - const sent = shape(body) + const field = (o, k) => `${k}:${blank(o[k]) ? typeof o[k] + '(blank)' : typeof o[k]}` + const shape = (o) => Object.keys(o).sort().map((k) => field(o, k)).join(',') + const rest = { ...body } + for (const k of ANY_SHAPE[endpoint] ?? []) { + if (!(k in rest)) continue + if (!accepted(endpoint).some((c) => c.body && k in c.body && field(c.body, k) === field(rest, k))) return null + delete rest[k] + } + const sent = shape(rest) return accepted(endpoint).find((c) => c.body && shape(c.body) === sent)?.name ?? null } +/** + * Fields the contract makes optional on every report to an endpoint, so they + * ride any accepted shape rather than the one case that shows them; the value + * must still match that case's. session-state.md: `provider_session_id` names + * the conversation on any state report, and one without it is taken as before. + */ +const ANY_SHAPE = { '/hook': ['provider_session_id'] } + export function assertContractHonoured(endpoint, request) { assert.equal(request.method, 'POST') assert.equal( diff --git a/tests/fixtures/session-state.jsonl b/tests/fixtures/session-state.jsonl index 85e627d..404d594 100644 --- a/tests/fixtures/session-state.jsonl +++ b/tests/fixtures/session-state.jsonl @@ -13,6 +13,8 @@ {"name":"a reason is trimmed","body":{"session_id":"s1","state":"waiting","reason":" edit "},"accept":{"session_id":"s1","state":"waiting","reason":"edit"}} {"name":"whitespace is not a reason","body":{"session_id":"s1","state":"waiting","reason":" "},"accept":{"session_id":"s1","state":"waiting","reason":""}} {"name":"a reason outside waiting names nothing","body":{"session_id":"s1","state":"busy","reason":"Claude needs your permission to use Bash"},"accept":{"session_id":"s1","state":"busy","reason":""}} +{"name":"names the conversation that reported","body":{"session_id":"s1","state":"busy","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","state":"busy","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"}} +{"name":"a plugin older than the conversation id","body":{"session_id":"s1","state":"idle"},"accept":{"session_id":"s1","state":"idle","provider_session_id":""}} {"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, compacting"} diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index b4c96c2..ed73eb1 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -254,7 +254,7 @@ function antigravityTranscript(content, name = 'antigravity.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', 'cat', 'curl', 'jq']) { + for (const tool of ['sh', 'sed', 'grep', 'cut', 'head', 'tail', 'cat', 'sleep', 'curl', 'jq']) { if (tool === missing) continue const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim() if (!found) continue @@ -584,6 +584,11 @@ for (const registration of REGISTRATIONS) { if (argument === 'waiting') assert.equal(body.reason, WAITING_REASON[provider]) else assert.ok(!('reason' in body), `${argument} sent a reason: ${stub.requests[0].raw}`) } + if (script === 'report-state.sh' || script === 'report-tool.sh') { + // Names the conversation that fired the hook, so lich can drop a report + // from an agent CLI nested in the session. + assert.equal(body.provider_session_id, PROVIDER_SESSION_ID[provider]) + } if (script === 'report-tool.sh') { assert.equal(body.state, 'busy') assert.equal(body.tool, TOOL_CALL[provider].tool_name ?? TOOL_CALL[provider].toolCall?.name) @@ -683,6 +688,34 @@ for (const state of ['busy', 'done', 'idle']) { }) } +for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh', '']]) { + const command = `"${ROOT}/hooks/${script}" ${argument}` + test(`${script} names the conversation without jq`, async () => { + await withStub(async (stub) => { + const result = await runHook(command, { + env: { ...lichEnv(stub.port), PATH: binWithout('jq') }, + stdin: stdinFor({ provider: 'claude', event: 'PreToolUse' }), + }) + assertHookSucceeded(result) + const { body } = assertContractHonoured('/hook', stub.requests[0]) + assert.equal(body.provider_session_id, PROVIDER_SESSION_ID.claude) + }) + }) + + test(`${script} leaves the conversation out when the payload names none`, async () => { + await withStub(async (stub) => { + const { session_id: _, ...payload } = JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' })) + const result = await runHook(command, { + env: lichEnv(stub.port), + stdin: JSON.stringify(payload), + }) + assertHookSucceeded(result) + const { body } = assertContractHonoured('/hook', stub.requests[0]) + assert.ok(!('provider_session_id' in body), `sent an empty conversation: ${stub.requests[0].raw}`) + }) + }) +} + test('session-start parses the payload without jq', async () => { // Windows ships sed (Git Bash) but rarely jq; the script falls back to sed. await withStub(async (stub) => { diff --git a/tests/mod-compacting.test.mjs b/tests/mod-compacting.test.mjs index f537abc..349f2b7 100644 --- a/tests/mod-compacting.test.mjs +++ b/tests/mod-compacting.test.mjs @@ -29,6 +29,7 @@ const COMPACTED = { } const NO_CONTENT = { status: 204, ok: true, headers: {}, text: '' } +const CONVERSATION = '9f1c7c1e-8f2a-4c3b-9d5e-2a7b6c4d1e05' /** Waits for `predicate` to hold, failing after a second of real time. */ async function until(predicate, what) { @@ -56,6 +57,7 @@ function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) const requests = [] const $ = { env: { get: async (name) => env[name] }, + session: { id: async () => CONVERSATION }, http: { fetch: (url, init = {}) => { const { pathname, search } = new URL(url) @@ -90,8 +92,8 @@ test('a manual /compact is bracketed by compacting and done, as the contract spe assert.deepEqual( mod.requests.map((r) => JSON.parse(r.raw)), [ - { session_id: LICH_SESSION_ID, state: 'compacting' }, - { session_id: LICH_SESSION_ID, state: 'done' }, + { session_id: LICH_SESSION_ID, state: 'compacting', provider_session_id: CONVERSATION }, + { session_id: LICH_SESSION_ID, state: 'done', provider_session_id: CONVERSATION }, ], ) }) diff --git a/tests/omp.test.mjs b/tests/omp.test.mjs index a61eeec..1abc1e2 100644 --- a/tests/omp.test.mjs +++ b/tests/omp.test.mjs @@ -135,7 +135,9 @@ test('input reports busy', async () => { emit('input', { type: 'input', text: 'ship it', source: 'interactive' }, ctxWith()), ) assert.equal(requests.length, 1) - assert.equal(assertContractHonoured('/hook', requests[0]).body.state, 'busy') + const { body } = assertContractHonoured('/hook', requests[0]) + assert.equal(body.state, 'busy') + assert.equal(body.provider_session_id, OMP_SESSION_ID) }) // A turn omp starts on its own — a continuation, a retry, print mode — has no @@ -145,7 +147,9 @@ test('turn_start reports busy', async () => { emit('turn_start', { type: 'turn_start', timestamp: 0 }, ctxWith()), ) assert.equal(requests.length, 1) - assert.equal(assertContractHonoured('/hook', requests[0]).body.state, 'busy') + const { body } = assertContractHonoured('/hook', requests[0]) + assert.equal(body.state, 'busy') + assert.equal(body.provider_session_id, OMP_SESSION_ID) }) test('session_stop reports done', async () => { @@ -154,6 +158,28 @@ test('session_stop reports done', async () => { const { body } = assertContractHonoured('/hook', requests[0]) assert.equal(body.state, 'done') assert.equal(body.session_id, LICH_SESSION_ID) + assert.equal(body.provider_session_id, OMP_SESSION_ID) +}) + +// omp 17.3.4 hands tool_call the same ctx as every other handler +// (ExtensionRunner.emitToolCall, createContext). +test('tool_call names the conversation it runs in', async () => { + const requests = await reportsFor(({ emit }) => + emit('tool_call', { type: 'tool_call', toolCallId: 'c', toolName: 'bash', input: { command: 'ls' } }, ctxWith()), + ) + assert.equal(assertContractHonoured('/hook', requests[0]).body.provider_session_id, OMP_SESSION_ID) +}) + +test('a report without a session id at hand leaves the conversation out', async () => { + const requests = await reportsFor(({ emit }) => { + emit('turn_start', { type: 'turn_start', timestamp: 0 }, ctxWith({ id: '' })) + emit('tool_call', { type: 'tool_call', toolCallId: 'c', toolName: 'bash', input: { command: 'ls' } }) + }) + assert.equal(requests.length, 2) + for (const request of requests) { + const { body } = assertContractHonoured('/hook', request) + assert.ok(!('provider_session_id' in body), `sent an empty conversation: ${request.raw}`) + } }) // --------------------------------------------------------------- tool line -- diff --git a/tests/opencode.test.mjs b/tests/opencode.test.mjs index 810d072..a97534b 100644 --- a/tests/opencode.test.mjs +++ b/tests/opencode.test.mjs @@ -106,6 +106,7 @@ for (const [type, state] of STATUS_STATES) { assert.equal(requests.length, 1) const { body } = assertContractHonoured('/hook', requests[0]) assert.equal(body.state, state) + assert.equal(body.provider_session_id, OPENCODE_SESSION_ID) }) } @@ -163,6 +164,7 @@ for (const [type, properties] of Object.entries(ASKED_EVENTS)) { const { body } = assertContractHonoured('/hook', requests[0]) assert.equal(body.state, 'waiting') assert.equal(body.reason, ASKED_REASON[type]) + assert.equal(body.provider_session_id, OPENCODE_SESSION_ID) }) } @@ -220,6 +222,7 @@ test('tool.execute.before names the tool and what it acts on', async () => { assert.equal(body.state, 'busy') assert.equal(body.tool, 'write') assert.equal(body.detail, 'oc-probe.txt') + assert.equal(body.provider_session_id, OPENCODE_SESSION_ID) }) // The detail is optional in the contract: absent, never blank. diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 077f0bf..847ab00 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -19,7 +19,7 @@ ref=$(tr -d '[:space:]' <"$here/lich-ref") raw=https://raw.githubusercontent.com/omartelo/lich/$ref base=$raw/docs/hooks/fixtures -for file in session-start.jsonl session-state.jsonl session-title.jsonl \ +for file in session-start.jsonl session-title.jsonl \ session-touched.jsonl mod-usage.jsonl mod-control.jsonl mod-commands.json \ mod-status.json; do curl -fsSL "$base/$file" -o "$here/fixtures/$file" @@ -34,6 +34,14 @@ curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hoo -o "$here/fixtures/mod-answer.jsonl" echo "refreshed mod-answer.jsonl at $unreleased (unreleased)" +# session-state gained `provider_session_id` in lich's commit faa40def, in no +# release yet, so it is read at that commit. Once a release ships it, move +# tests/lich-ref to that release and put the file back in the loop above. +unreleased=faa40defc2eedf550843aa4a817de308b9afe563 +curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hooks/fixtures/session-state.jsonl" \ + -o "$here/fixtures/session-state.jsonl" +echo "refreshed session-state.jsonl at $unreleased (unreleased)" + # validate.mjs reads its token sets off this copy, so a token lich adds reaches # the validator by moving tests/lich-ref, not by hand. curl -fsSL "$raw/themes/template.json" -o "$here/../skills/theme/template.json" From 6c8b2aa2fba4b4dab7f4ed4972a61e3515936582 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 17:57:45 -0300 Subject: [PATCH 3/5] refactor(hooks): read the conversation id in one sourced helper report-session-start.sh, report-state.sh and report-tool.sh each parsed .conversationId // .session_id with the same sed fallback. They now source hooks/conversation-id.sh and call conversation_id on the payload. A script shipped without the helper still reports, without the conversation. --- CLAUDE.md | 1 + hooks/conversation-id.sh | 13 +++++++++++++ hooks/report-session-start.sh | 16 ++++++++-------- hooks/report-state.sh | 18 ++++++++---------- hooks/report-tool.sh | 19 +++++++++---------- 5 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 hooks/conversation-id.sh diff --git a/CLAUDE.md b/CLAUDE.md index 0dff177..14f48ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ hooks/codex-hooks.json # hook registration, Codex hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json) hooks/ # hook scripts (all four run them; only how they are addressed differs) hooks/detail.jq # what a tool call acts on, shared by two of those scripts +hooks/conversation-id.sh # the conversation a payload comes from, sourced by three of them hooks/win-run.cmd # runs one of those scripts on Windows (Codex) hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the mods below hooks/mod-control.js # Claude Code mod: lich drives the session diff --git a/hooks/conversation-id.sh b/hooks/conversation-id.sh new file mode 100644 index 0000000..e9f52bd --- /dev/null +++ b/hooks/conversation-id.sh @@ -0,0 +1,13 @@ +# Sourced by the report scripts, not run: conversation_id reads a hook payload on +# stdin and prints the provider's id for the conversation that fired it, or +# nothing. Antigravity names it `conversationId`, every other harness +# `session_id`. Prefers jq; falls back to sed so it works on Windows, where jq is +# usually absent but sed (Git Bash) is not. +conversation_id() { + if command -v jq >/dev/null 2>&1; then + jq -r '.conversationId // .session_id // empty' 2>/dev/null + else + sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 + fi +} diff --git a/hooks/report-session-start.sh b/hooks/report-session-start.sh index 3d8888f..66a542c 100755 --- a/hooks/report-session-start.sh +++ b/hooks/report-session-start.sh @@ -9,14 +9,14 @@ # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). plugin_version=0.19.2 provider=${1:-claude} -# Parse session_id from the stdin payload. Prefer jq; fall back to sed so the -# hook works on Windows, where jq is usually absent but sed (Git Bash) is not. -if command -v jq >/dev/null 2>&1; then - provider_session_id=$(jq -r '.conversationId // .session_id // empty' 2>/dev/null) -else - provider_session_id=$(sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) -fi + +# A file-shipped install that lacks conversation-id.sh still reports, only +# without the conversation: sourcing a missing file would end the script. +# `${0%/*}` rather than dirname: it needs no tool on PATH. +[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" + +payload=$(cat) +provider_session_id=$(printf '%s' "$payload" | conversation_id) [ -n "$provider_session_id" ] || exit 0 # Values are ids (UUID / lich-controlled / this file's own literal), no JSON diff --git a/hooks/report-state.sh b/hooks/report-state.sh index c474310..ec72afa 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -10,6 +10,11 @@ # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). plugin_version=0.19.2 +# A file-shipped install that lacks conversation-id.sh still reports, only +# without the conversation: sourcing a missing file would end the script. +# `${0%/*}` rather than dirname: it needs no tool on PATH. +[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" + state=$1 body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}" @@ -63,16 +68,9 @@ if [ "$state" = waiting ]; then fi # The conversation that fired the hook, so lich can drop a report from an agent -# CLI nested in the session (docs/session-state.md, Nested agent CLIs). Parsed as -# report-session-start.sh parses it; an id needs no escaping, and none is left -# out rather than sent empty. -if command -v jq >/dev/null 2>&1; then - provider_session_id=$(printf '%s' "$payload" | jq -r '.conversationId // .session_id // empty' 2>/dev/null) -else - provider_session_id=$(printf '%s' "$payload" | \ - sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) -fi +# CLI nested in the session (docs/session-state.md, Nested agent CLIs). An id +# needs no escaping, and none is left out rather than sent empty. +provider_session_id=$(printf '%s' "$payload" | conversation_id) [ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" curl -s -o /dev/null --max-time 1 \ diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 5c0e845..95b9987 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -14,6 +14,12 @@ plugin_version=0.19.2 here=$(dirname "$0") + +# A file-shipped install that lacks conversation-id.sh still reports, only +# without the conversation: sourcing a missing file would end the script. +# `${0%/*}` rather than dirname: it needs no tool on PATH. +[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" + payload=$(cat) command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq= @@ -47,16 +53,9 @@ else fi # The conversation that fired the hook, so lich can drop a report from an agent -# CLI nested in the session (docs/session-state.md, Nested agent CLIs). Parsed as -# report-session-start.sh parses it; an id needs no escaping, and none is left -# out rather than sent empty. -if [ -n "$has_jq" ]; then - provider_session_id=$(printf '%s' "$payload" | jq -r '.conversationId // .session_id // empty' 2>/dev/null) -else - provider_session_id=$(printf '%s' "$payload" | \ - sed -n -e 's/.*"conversationId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ - -e 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) -fi +# CLI nested in the session (docs/session-state.md, Nested agent CLIs). An id +# needs no escaping, and none is left out rather than sent empty. +provider_session_id=$(printf '%s' "$payload" | conversation_id) [ -n "$provider_session_id" ] && body="${body%\}},\"provider_session_id\":\"${provider_session_id}\"}" curl -s -o /dev/null --max-time 1 \ From 69bd8890748fbc9c96cfe9601d47f78625fde5ef Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 18:05:40 -0300 Subject: [PATCH 4/5] fix(hooks): say on stderr when conversation-id.sh is missing A file-shipped install without the helper ran on silently. Each script now writes one line naming the missing file and what is lost, then reports as before: state and tool without provider_session_id, session-start not at all. --- hooks/report-session-start.sh | 13 +++++++++---- hooks/report-state.sh | 13 +++++++++---- hooks/report-tool.sh | 13 +++++++++---- tests/hooks.test.mjs | 31 ++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/hooks/report-session-start.sh b/hooks/report-session-start.sh index 66a542c..a1d8d50 100755 --- a/hooks/report-session-start.sh +++ b/hooks/report-session-start.sh @@ -10,10 +10,15 @@ plugin_version=0.19.2 provider=${1:-claude} -# A file-shipped install that lacks conversation-id.sh still reports, only -# without the conversation: sourcing a missing file would end the script. -# `${0%/*}` rather than dirname: it needs no tool on PATH. -[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" +# A file-shipped install that lacks conversation-id.sh says so and still +# runs: sourcing a missing file would end the script. `${0%/*}` rather than +# dirname: it needs no tool on PATH. +if [ -r "${0%/*}/conversation-id.sh" ]; then + . "${0%/*}/conversation-id.sh" +else + echo "lich-plugin: hooks/conversation-id.sh missing; session-start not reported" >&2 + conversation_id() { :; } +fi payload=$(cat) provider_session_id=$(printf '%s' "$payload" | conversation_id) diff --git a/hooks/report-state.sh b/hooks/report-state.sh index ec72afa..7fb1bda 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -10,10 +10,15 @@ # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). plugin_version=0.19.2 -# A file-shipped install that lacks conversation-id.sh still reports, only -# without the conversation: sourcing a missing file would end the script. -# `${0%/*}` rather than dirname: it needs no tool on PATH. -[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" +# A file-shipped install that lacks conversation-id.sh says so and still +# runs: sourcing a missing file would end the script. `${0%/*}` rather than +# dirname: it needs no tool on PATH. +if [ -r "${0%/*}/conversation-id.sh" ]; then + . "${0%/*}/conversation-id.sh" +else + echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2 + conversation_id() { :; } +fi state=$1 body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}" diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index 95b9987..3f947c0 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -15,10 +15,15 @@ plugin_version=0.19.2 here=$(dirname "$0") -# A file-shipped install that lacks conversation-id.sh still reports, only -# without the conversation: sourcing a missing file would end the script. -# `${0%/*}` rather than dirname: it needs no tool on PATH. -[ -r "${0%/*}/conversation-id.sh" ] && . "${0%/*}/conversation-id.sh" +# A file-shipped install that lacks conversation-id.sh says so and still +# runs: sourcing a missing file would end the script. `${0%/*}` rather than +# dirname: it needs no tool on PATH. +if [ -r "${0%/*}/conversation-id.sh" ]; then + . "${0%/*}/conversation-id.sh" +else + echo "lich-plugin: hooks/conversation-id.sh missing; reporting without provider_session_id" >&2 + conversation_id() { :; } +fi payload=$(cat) command -v jq >/dev/null 2>&1 && has_jq=1 || has_jq= diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index ed73eb1..831dd0e 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -10,7 +10,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { spawn, spawnSync } from 'node:child_process' -import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, symlinkSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, symlinkSync, copyFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' @@ -716,6 +716,35 @@ for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh' }) } +// lich writes the scripts of a file-shipped harness one by one (Antigravity, +// Kiro), so an install can carry them without conversation-id.sh. Each says so +// on stderr and reports what it can without it. +const WITHOUT_HELPER = [ + ['report-state.sh', 'busy', 'reporting without provider_session_id', 1], + ['report-tool.sh', '', 'reporting without provider_session_id', 1], + ['report-session-start.sh', 'claude', 'session-start not reported', 0], +] + +for (const [script, argument, lost, reports] of WITHOUT_HELPER) { + test(`${script} without conversation-id.sh says so and ${reports ? 'reports without the id' : 'reports nothing'}`, async () => { + const dir = mkdtempSync(path.join(TMP, 'no-helper-')) + copyFileSync(path.join(ROOT, 'hooks', script), path.join(dir, script)) + await withStub(async (stub) => { + const result = await runHook(`"${dir}/${script}" ${argument}`, { + env: lichEnv(stub.port), + stdin: stdinFor({ provider: 'claude', event: 'PreToolUse' }), + }) + assertHookSucceeded(result) + assert.equal(result.stderr, `lich-plugin: hooks/conversation-id.sh missing; ${lost}\n`) + assert.equal(stub.requests.length, reports) + if (reports) { + const { body } = assertContractHonoured('/hook', stub.requests[0]) + assert.ok(!('provider_session_id' in body), `sent a conversation it cannot read: ${stub.requests[0].raw}`) + } + }) + }) +} + test('session-start parses the payload without jq', async () => { // Windows ships sed (Git Bash) but rarely jq; the script falls back to sed. await withStub(async (stub) => { From 0363c5bcf8faf885e759ec223276476a0f004b00 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 19:00:26 -0300 Subject: [PATCH 5/5] chore: pin the unanswered-turn and provider_session_id fixtures to lich main --- tests/refresh-fixtures.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 847ab00..afd66b9 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -26,18 +26,18 @@ for file in session-start.jsonl session-title.jsonl \ echo "refreshed $file at $ref" done -# mod-answer gained `unanswered` in lich's commit 040195f5, in no release yet, +# mod-answer gained `unanswered` in lich's merge commit 19e808e4 (#705), in no release yet, # so it is read at that commit. Once a release ships it, move tests/lich-ref to # that release and put the file back in the loop above. -unreleased=040195f593ae213154d4485a13ed2bdb905311fb +unreleased=19e808e4dc177cbeedd4a289f7d8fac739254c05 curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hooks/fixtures/mod-answer.jsonl" \ -o "$here/fixtures/mod-answer.jsonl" echo "refreshed mod-answer.jsonl at $unreleased (unreleased)" -# session-state gained `provider_session_id` in lich's commit faa40def, in no +# session-state gained `provider_session_id` in lich's merge commit ce8c36a6 (#709), in no # release yet, so it is read at that commit. Once a release ships it, move # tests/lich-ref to that release and put the file back in the loop above. -unreleased=faa40defc2eedf550843aa4a817de308b9afe563 +unreleased=ce8c36a6bd9f28786b45a6be527f81bb3a4a3634 curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hooks/fixtures/session-state.jsonl" \ -o "$here/fixtures/session-state.jsonl" echo "refreshed session-state.jsonl at $unreleased (unreleased)"