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]

### Added

- **A Claude Code card says when its conversation is being compacted.** A new
mod, `hooks/mod-compacting.js`, reports the session-state contract's
`compacting` when `session.compact` starts and closes it when the compaction
settles, whether it stood or failed: `done` after a manual `/compact`, `busy`
after an automatic one, whose turn goes on. Until now a manual `/compact` left
the card on `done` and an automatic one on a bare spinner. A lich older than
the contract answers `compacting` with a 400 and still takes the closing state.

## [0.18.4] - 2026-10-08

### Added
Expand Down
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c
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/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 six mods below
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
hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session
hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost
hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file
hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task
hooks/mod-status.js # Claude Code mod: the session's relay errands in the prompt footer
hooks/mod-compacting.js # Claude Code mod: the card says when the conversation is being compacted
hooks/status-line.js # the lich line in the prompt footer, shared by the mods above
opencode/lich.js # opencode client: a module, not a command
omp/lich.js # omp client: a module, not a command
Expand Down Expand Up @@ -56,7 +57,7 @@ registered for it.

Contracts are **canonical in the lich repository** (`docs/hooks/` there); this plugin only implements the client side. Each hook has a doc in `docs/` here that points at its contract and describes the plugin-side behavior. Read it before creating or changing a hook:

- [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-state.md](docs/session-state.md) — session-state reporting (`busy`/`done`) via `UserPromptSubmit`/`Stop`, the tool a turn is running via `PreToolUse`, and `compacting` through a mod bracketing `session.compact` (Claude Code only)
- [docs/session-start.md](docs/session-start.md) — Claude session id via `SessionStart`
- [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)
Expand Down Expand Up @@ -101,9 +102,11 @@ mod-control fixtures and its polls against the commands lich sends;
[tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its
usage reports against the mod-usage fixtures,
[tests/worker-answer.test.mjs](tests/worker-answer.test.mjs) for a worker's
answer against the mod-answer fixtures, and
answer against the mod-answer fixtures,
[tests/mod-status.test.mjs](tests/mod-status.test.mjs) for the footer line it
draws from the mod-status response.
draws from the mod-status response, and
[tests/mod-compacting.test.mjs](tests/mod-compacting.test.mjs) for the
`compacting` bracket against the session-state fixtures.
[tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a
fake `$` with an in-memory git dir and asserts the markers it leaves and the
note another session's marker adds, and
Expand Down Expand Up @@ -173,6 +176,6 @@ Same standard as the `lich` repository (Keep a Changelog + SemVer). Releases are

1. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version heading (with date) and refresh the compare links at the bottom.
2. Pick the number by what changed: a release that sends anything lich's contract did not already accept (a new endpoint, header, field or value) bumps the **minor** (the major, from 1.0); anything else bumps the **patch**. lich accepts every release below the next minor of the contract it implements, so a contract change released as a patch reaches a lich that refuses it. `docs/hooks/README.md` in lich, Versioning, is the rule.
3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js`, `hooks/mod-status.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js`, `hooks/mod-status.js`, `hooks/mod-compacting.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
4. Annotated tag `vX.Y.Z` + push with the tag.
5. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section.
4 changes: 3 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ This repository is one plugin, packaged for each harness it supports:
| `hooks/crush-hooks.json` | Crush | hook registration, merged by hand |
| `hooks/*.sh` | the four | the reports themselves |
| `hooks/win-run.cmd` | Codex | runs a script on Windows |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the five below |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the mods below |
| `hooks/mod-control.js` | Claude Code | the mod-control client, a mod |
| `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod |
| `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod |
| `hooks/mod-status.js` | Claude Code | the mod-status client, a mod |
| `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod |
| `hooks/worker-answer.js` | Claude Code | the mod-answer client: a worker's last message answers its task, a mod |
| `hooks/mod-compacting.js` | Claude Code | the session-state `compacting` report, a mod |
| `opencode/lich.js` | opencode | the whole client, as a module |
| `omp/lich.js` | omp | the whole client, as a module |
| `skills/` | all | skills, same layout |
Expand Down Expand Up @@ -74,6 +75,7 @@ swallows its own errors and never awaits a report.
| `busy` + tool | `PreToolUse` | `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` | — |
| `waiting` + reason | `Notification` | `PermissionRequest` | — (not measured) | any `*.asked` | — (see below) | — |
| `done` | `Stop`, `StopFailure` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — |
| `compacting` | `session.compact` (a mod) | — | — | — | — | — |
| title | `Stop` | `Stop` | `Stop` | `session.updated` | `session_stop`, `turn_start` | — |
| `idle` | `SessionEnd` | — (registered, never fires) | — (never fires) | — (nothing outlives it) | — (nothing outlives it) | — |
| touched | `PostToolUse` (write tools) | `PostToolUse` (write tools) | `PostToolUse` (write tools) | `file.edited` | `tool_result` (write tools) | `PreToolUse` (write tools) |
Expand Down
41 changes: 41 additions & 0 deletions docs/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ toast) when the agent is blocked on the user.
| `report-state.sh done` | `done` | `Stop` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` |
| `report-state.sh waiting` | `waiting`+`reason`| `Notification` | `PermissionRequest` | — (not measured) | any `*.asked` | — (not measured)|
| `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) | — (never fires) | — (never fires) | — (never fires) |
| `mod-compacting.js` | `compacting` | `session.compact` (mod) | — | — | — | — |

opencode and omp run no scripts: `opencode/lich.js` and `omp/lich.js` send the
same payloads off the events their harness hands a loaded module. opencode's
Expand Down Expand Up @@ -115,6 +116,46 @@ the turn without another tool call stays `waiting` until `Stop`. Codex recovers
the same way: an approved request runs the tool it was asking about, and that
tool's `PostToolUse` re-arms the spinner.

## Compacting

`hooks/mod-compacting.js` reports `compacting` while Claude Code compacts the
conversation, so the card says so instead of sitting on `done` through a manual
`/compact` (no `UserPromptSubmit` fires for it) or on a bare spinner through an
automatic one. It is a mod, registered by `hooks/lich.js`, because it has to
see the compaction end whichever way it ends: `session.compact`'s `next(e)`
settles when it does, and a settings hook has nothing to match that with.
`PostCompact` never fires for a compaction that fails, and two do, measured on
Claude Code 2.1.295:

| trigger | when | ends | closed with |
|----------|------------------------------------------|---------------------------------------------------------------------------|-------------|
| `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` |

`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
abort it on the measured run: the summary finished and the turn ended normally.

The rest follows the other mods:

- **Outside lich, or in a `claude -p`, it never reports.** It reads the three
variables in a `session.start` hook matching `isInteractive: true`.
- **Only `manual` and `auto` are reported**, each by a hook matching its
`trigger`. `precompute` runs ahead of time, out of sight, and `plugin` (a
mod's own `$.session.compact`) was never observed.
- **A subagent's compaction is left alone.** It carries `agentId`; the main
conversation's does not. That field is read off Claude Code's type
declarations, not a payload: no subagent compaction was measured.
- **The compaction is never held up.** Reports go out beside the chain, one at
a time so the closing one never lands first, and one that fails is dropped.

Known ceiling: an automatic compaction the engine gives up on still puts
`compacting` on the card for the ~40ms before `busy`. With the threshold forced
low (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=1`) that happened on most turns; how
often it happens at the default threshold was not measured.

## The tool report

`report-tool.sh` rides `PreToolUse` and reports the same `busy`, with the tool
Expand Down
2 changes: 2 additions & 0 deletions hooks/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// from here.

import { register as registerAgentCards } from "./agent-cards.js"
import { register as registerCompacting } from "./mod-compacting.js"
import { register as registerEditGuard } from "./edit-guard.js"
import { register as registerModControl } from "./mod-control.js"
import { register as registerModStatus } from "./mod-status.js"
Expand All @@ -18,5 +19,6 @@ export function register(on) {
registerEditGuard(on)
registerWorkerAnswer(on)
registerModStatus(on)
registerCompacting(on)
registerStatusLine(on)
}
112 changes: 112 additions & 0 deletions hooks/mod-compacting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Reports a Claude Code conversation being compacted to lich, as the
// session-state contract's `compacting`. Contract: ../docs/session-state.md,
// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/session-state.md
//
// 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.
//
// 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
// on a manual /compact rejects `next(e)` with "Request was aborted", and an
// automatic one the engine gives up on rejects it within ~40ms with "reactive
// compaction did not settle ok" while the turn goes on.
//
// Every function that takes `$` is declared here at the top level: the loader
// refuses a module that hands `$` to a nested function.

// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
const PLUGIN_VERSION = "0.18.4"

/**
* @typedef {import('claude-code').EngineInterface} Engine
* @typedef {import('claude-code').SessionCompactInput} Compaction
* @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
*/

/**
* @param {Engine} $
* @returns {Promise<Link | undefined>}
*/
async function linkFromEnv($) {
const [port, token, session] = await Promise.all([
$.env.get("LICH_PORT"),
$.env.get("LICH_TOKEN"),
$.env.get("LICH_SESSION_ID"),
])
if (!port || !token || !session) return undefined
return { base: `http://127.0.0.1:${port}`, token, session }
}

/**
* @param {Engine} $
* @param {Link} link
* @param {'compacting' | Closing} sessionState
*/
async function report($, link, sessionState) {
try {
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 }),
})
} catch {
// The contract drops a report that cannot be sent; it never retries one.
}
}

/**
* Reports beside the chain, one at a time: lich keeps the latest state, so a
* closing report landing before its `compacting` would leave the card stuck.
*
* @param {Engine} $
* @param {State} state
* @param {Link} link
* @param {'compacting' | Closing} sessionState
*/
function queue($, state, link, sessionState) {
state.sending = state.sending.then(() => report($, link, sessionState))
}

/**
* @param {Engine} $
* @param {State} state
* @param {Compaction} e
* @param {(e: Compaction) => Promise<Compacted>} next
* @param {Closing} closing
*/
async function bracket($, state, e, next, closing) {
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)
}
}

/** @param {import('claude-code').On} on */
export function register(on) {
/** @type {State} */
const state = { sending: Promise.resolve() }

// Interactive sessions only: a `claude -p` started from a tool inside a lich
// session inherits its variables, and its compactions are not the card's.
on("session.start", { isInteractive: true }, async ($, e, next) => {
state.link = await linkFromEnv($)
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"))
}
Loading
Loading