From a310fb39c457498fd7458dde6bf998463bac16b4 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 15 Oct 2026 11:42:45 -0300 Subject: [PATCH] fix(compacting): close an idle compaction with done Claude Code 2.1.300 compacts an idle conversation before its prompt cache expires and reports it as `auto`, the trigger of a compaction inside a turn. The mod closed every `auto` with `busy`, and with no turn open and no Stop to follow, the card sat on the spinner until the next prompt. The closing state is now the turn's: the mod follows turn.start and turn.complete and closes with `busy` while a turn is open, `done` when none is. The existing `auto` tests open a turn first, since the contract now keys the closing state on it (omartelo/lich#767). --- CHANGELOG.md | 9 ++++++++ docs/session-state.md | 10 +++++--- hooks/mod-compacting.js | 32 ++++++++++++++++++-------- tests/mod-compacting.test.mjs | 43 +++++++++++++++++++++++++++++++---- 4 files changed, 76 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf3d02..e1b422b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **A Claude Code card no longer shows its session running after it compacts + while idle.** Claude Code 2.1.300 compacts an idle conversation before its + prompt cache expires, and reports it as an automatic compaction, the kind + that runs inside a turn. The compacting mod closed it with `busy`, and the + card sat on the spinner until the next prompt. It now closes with the turn's + own state: `busy` while a turn is open, `done` when none is. + ## [0.20.1] - 2026-10-10 ### Fixed diff --git a/docs/session-state.md b/docs/session-state.md index 0d5ae60..6c0b477 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -145,11 +145,15 @@ Claude Code 2.1.295: |----------|------------------------------------------|---------------------------------------------------------------------------|-------------| | `manual` | `/compact`, at an idle prompt | resolves after the summary (~6s), or rejects on Esc ("Request was aborted") | `done` | | `auto` | inside a turn, after `UserPromptSubmit`, before the model request | resolves after the summary (~14s), or rejects within ~40ms when the engine gives up ("reactive compaction did not settle ok") and the turn goes on | `busy` | +| `auto` | at an idle prompt, before the prompt cache expires ("Compacted while idle", seen on 2.1.300) | resolves after a summary prepared ahead of time | `done` | `compacting` goes out before `next(e)` is called and the closing state once it -settles, either way; the error, if any, goes on to Claude Code untouched. An -automatic compaction closes with `busy` because its turn is still running; the -turn's own `Stop` reports `done`. Esc during an automatic compaction did not +settles, either way; the error, if any, goes on to Claude Code untouched. The +closing state is the turn's, not the trigger's: `busy` while a turn is open, +whose own `Stop` reports `done`, and `done` when none is. The mod follows the +turn through `turn.start` and `turn.complete`, because the trigger cannot tell: +the idle compaction prepares its summary under `precompute` and applies it as +`auto`, with no turn open and no `Stop` to follow. Esc during an automatic compaction did not abort it on the measured run: the summary finished and the turn ended normally. The rest follows the other mods: diff --git a/hooks/mod-compacting.js b/hooks/mod-compacting.js index 1a7e08d..1eb2ac0 100644 --- a/hooks/mod-compacting.js +++ b/hooks/mod-compacting.js @@ -4,10 +4,11 @@ // // A Claude Code mod, registered by hooks/lich.js. It brackets `session.compact`: // `compacting` before the compaction runs, and the state the session goes back -// to once `next(e)` settles, whether the compaction stood or failed. A manual -// /compact runs at an idle prompt, so it closes with `done`; an automatic one -// runs inside a turn, ahead of the model request that follows, so it closes -// with `busy` and the turn's own Stop reports `done` later. +// to once `next(e)` settles, whether the compaction stood or failed: `busy` +// while a turn is open, whose own Stop reports `done` later, and `done` when +// none is. The trigger cannot tell them apart: Claude Code 2.1.300 applies the +// compaction it runs at an idle prompt, before the prompt cache expires, as +// `auto`, the same trigger as one inside a turn. // // A settings hook could not do this: `PostCompact` never fires for a // compaction that fails, and two measured ones do (Claude Code 2.1.295). Esc @@ -27,7 +28,7 @@ const PLUGIN_VERSION = "0.20.1" * @typedef {import('claude-code').SessionCompactResult} Compacted * @typedef {{ base: string, token: string, session: string }} Link * @typedef {'done' | 'busy'} Closing - * @typedef {{ link?: Link, sending: Promise }} State + * @typedef {{ link?: Link, turnId?: string, sending: Promise }} State */ /** @@ -84,9 +85,8 @@ function queue($, state, link, sessionState) { * @param {State} state * @param {Compaction} e * @param {(e: Compaction) => Promise} next - * @param {Closing} closing */ -async function bracket($, state, e, next, closing) { +async function bracket($, state, e, next) { const link = state.link // A subagent compacting its own transcript leaves the session's alone. if (!link || e.agentId !== undefined) return next(e) @@ -94,7 +94,7 @@ async function bracket($, state, e, next, closing) { try { return await next(e) } finally { - queue($, state, link, closing) + queue($, state, link, state.turnId === undefined ? "done" : "busy") } } @@ -110,8 +110,20 @@ export function register(on) { return next(e) }) + // `/^/` matches every turn: Claude Code refuses one plugin's second hook on + // an event with no matcher, and mod-control.js already holds those two. + on("turn.start", { turnId: /^/ }, ($, e, next) => { + state.turnId = e.turnId + return next(e) + }) + + on("turn.complete", { turnId: /^/ }, ($, e, next) => { + if (e.turnId === state.turnId) state.turnId = undefined + return next(e) + }) + // Only the two triggers measured. `precompute` runs ahead of time, out of // sight, and a plugin's own `$.session.compact` was never observed. - on("session.compact", { trigger: "manual" }, ($, e, next) => bracket($, state, e, next, "done")) - on("session.compact", { trigger: "auto" }, ($, e, next) => bracket($, state, e, next, "busy")) + on("session.compact", { trigger: "manual" }, ($, e, next) => bracket($, state, e, next)) + on("session.compact", { trigger: "auto" }, ($, e, next) => bracket($, state, e, next)) } diff --git a/tests/mod-compacting.test.mjs b/tests/mod-compacting.test.mjs index 349f2b7..91b4d1d 100644 --- a/tests/mod-compacting.test.mjs +++ b/tests/mod-compacting.test.mjs @@ -5,7 +5,8 @@ // recording every fetch as contract.mjs reads a request, so the reports answer // to the same fixtures lich asserts (session-state.jsonl). The // `session.compact` payloads and the errors `next(e)` rejects with below are -// what Claude Code 2.1.295 delivered against a stub lich. +// what Claude Code 2.1.295 delivered against a stub lich; the idle compaction +// is the one Claude Code 2.1.300 runs before the prompt cache expires. // // Run: node --test tests/ @@ -28,6 +29,7 @@ const COMPACTED = { messages: [{ role: 'user', text: 'This session is being continued from a previous conversation that ran out of context.' }], } +const TURN = 'e0b6a2c4-1f3d-4e5a-8b7c-9d0e1f2a3b4c' const NO_CONTENT = { status: 204, ok: true, headers: {}, text: '' } const CONVERSATION = '9f1c7c1e-8f2a-4c3b-9d5e-2a7b6c4d1e05' @@ -44,9 +46,9 @@ const settle = () => new Promise((r) => setTimeout(r, 20)) /** * Registers the module against a fake engine. A hook registered with a - * matcher runs only for an event that matches it, as the engine runs it, and - * an event no hook matches goes straight to `compact`, Claude Code's own - * compaction. `answer` answers each report. + * matcher runs only for an event that matches it, a RegExp value testing the + * field as the engine does, and an event no hook matches goes straight to + * `compact`, Claude Code's own compaction. `answer` answers each report. */ function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) } = {}) { const hooks = [] @@ -69,7 +71,8 @@ function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) }, } const fire = (event, e, last) => { - const found = hooks.find((h) => h.event === event && Object.entries(h.matcher).every(([key, value]) => e[key] === value)) + const matches = ([key, value]) => (value instanceof RegExp ? value.test(e[key]) : e[key] === value) + const found = hooks.find((h) => h.event === event && Object.entries(h.matcher).every(matches)) return found ? found.hook($, e, last) : last(e) } return { @@ -77,6 +80,8 @@ function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) states: () => requests.map((r) => JSON.parse(r.raw).state), start: (isInteractive = true) => fire('session.start', { cwd: '/w', surface: 'terminal', isInteractive }, async (e) => e), compact: (e, compact = async () => COMPACTED) => fire('session.compact', e, compact), + turnStart: (turnId = TURN) => fire('turn.start', { text: '', turnId }, async () => ({ turnId })), + turnComplete: (turnId = TURN) => fire('turn.complete', { turnId, reason: 'answer' }, async () => ({ text: 'hi' })), } } @@ -103,6 +108,33 @@ test('a manual /compact is bracketed by compacting and done, as the contract spe test('an automatic compaction hands the card back to the turn', async () => { const mod = load() await mod.start() + await mod.turnStart() + + await mod.compact(AUTO) + await until(() => mod.requests.length === 2, 'both reports') + + assert.deepEqual(mod.states(), ['compacting', 'busy']) +}) + +// The idle compaction reaches the mod as `auto` too, but with no turn open and +// no Stop to follow it: closing with busy left the card on the spinner. +test('an automatic compaction at an idle prompt closes with done', async () => { + const mod = load() + await mod.start() + await mod.turnStart() + await mod.turnComplete() + + await mod.compact(AUTO) + await until(() => mod.requests.length === 2, 'both reports') + + assert.deepEqual(mod.states(), ['compacting', 'done']) +}) + +test('a turn other than the open one ending leaves it open', async () => { + const mod = load() + await mod.start() + await mod.turnStart() + await mod.turnComplete('5c1d7e9a-2b3f-4a6c-8d0e-1f2a3b4c5d6e') await mod.compact(AUTO) await until(() => mod.requests.length === 2, 'both reports') @@ -133,6 +165,7 @@ for (const [e, error, closing] of [ test(`${e.trigger} compaction that fails still closes with ${closing}, and its error goes on`, async () => { const mod = load() await mod.start() + if (closing === 'busy') await mod.turnStart() await assert.rejects( mod.compact(e, async () => {