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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 22 additions & 10 deletions hooks/mod-compacting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> }} State
* @typedef {{ link?: Link, turnId?: string, sending: Promise<void> }} State
*/

/**
Expand Down Expand Up @@ -84,17 +85,16 @@ function queue($, state, link, sessionState) {
* @param {State} state
* @param {Compaction} e
* @param {(e: Compaction) => Promise<Compacted>} 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)
queue($, state, link, "compacting")
try {
return await next(e)
} finally {
queue($, state, link, closing)
queue($, state, link, state.turnId === undefined ? "done" : "busy")
}
}

Expand All @@ -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))
}
43 changes: 38 additions & 5 deletions tests/mod-compacting.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand All @@ -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'

Expand All @@ -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 = []
Expand All @@ -69,14 +71,17 @@ 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 {
requests,
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' })),
}
}

Expand All @@ -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')
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading