diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 31138bc..825d027 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "lich", "description": "Claude Code plugin for the lich harness", - "version": "0.13.2", + "version": "0.14.0", "author": { "name": "omartelo" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 789ef6e..91e195b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lich", - "version": "0.13.2", + "version": "0.14.0", "description": "Codex CLI plugin for the lich harness", "author": { "name": "omartelo" diff --git a/CHANGELOG.md b/CHANGELOG.md index defca52..f515838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.0] - 2026-10-03 + +### Added + +- **lich can drive a Claude Code session from its card.** A Claude Code mod + takes the commands lich sends: a prompt that starts a turn, stopping the + running turn, a model or effort override for every request, and compacting + the context. It acks each one, so the card says when a command could not be + applied. Needs a lich that serves the mod-control contract and Claude Code + 2.1.280 or later with mods turned on; an older Claude Code keeps every report + and only goes without the controls. Only interactive sessions take commands, + never a `claude -p` run. + ## [0.13.2] - 2026-10-03 ### Added @@ -471,7 +484,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 plugin is safe to install globally. Requests time out after ~1s and errors are swallowed — the hook never blocks or fails the turn. -[Unreleased]: https://github.com/omartelo/lich-plugin/compare/v0.13.2...HEAD +[Unreleased]: https://github.com/omartelo/lich-plugin/compare/v0.14.0...HEAD +[0.14.0]: https://github.com/omartelo/lich-plugin/compare/v0.13.2...v0.14.0 [0.13.2]: https://github.com/omartelo/lich-plugin/compare/v0.13.1...v0.13.2 [0.13.1]: https://github.com/omartelo/lich-plugin/compare/v0.13.0...v0.13.1 [0.13.0]: https://github.com/omartelo/lich-plugin/compare/v0.12.0...v0.13.0 diff --git a/CLAUDE.md b/CLAUDE.md index bab58d7..dc01cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ 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/mod-control.js # Claude Code mod: lich drives the session, a module hooks/hooks.json names opencode/lich.js # opencode client: a module, not a command omp/lich.js # omp client: a module, not a command docs/ # lich ⇄ plugin communication contracts @@ -52,6 +53,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [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) +- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, compact) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report Each doc carries the event mapping for **every** provider, one column each — a contract is implemented once and registered per harness. [docs/providers.md](docs/providers.md) is the map: which file each harness reads, where the event vocabularies differ, and what adding another provider takes. A harness that cannot close a state does not get that report registered: an unendable `busy` is worse on a card than a card with no indicator. @@ -81,16 +83,24 @@ drop and the listener that never answers, which must not hold the turn. against the events and `ctx.sessionManager` methods measured on omp v17.3.0 — pinned in a comment there, because 17.x moves fast — plus the rule that outside lich it subscribes to nothing at all. +[tests/mod-control.test.mjs](tests/mod-control.test.mjs) does the same for the +Claude Code mod, handing it a fake `$` and asserting its acks against the +mod-control fixtures and its polls against the commands lich sends. [tests/contract.mjs](tests/contract.mjs) is the fixtures, the assertions and the stub, shared so every client answers to the same lines. Node only, no dependencies. +[tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) runs the +mod under Claude Code's own engine (`claude plugin test .`); it needs a `claude` +binary, so CI does not run it. The fixtures are vendored in `tests/fixtures/` from lich (`docs/hooks/fixtures/*.jsonl` there) by [tests/refresh-fixtures.sh](tests/refresh-fixtures.sh), so the suite never needs the network. They are read at the lich release named in `tests/lich-ref`, never at `main`: a contract merged there is one no released lich speaks yet. CI diffs -the copies against that release and fails on drift. +the copies against that release and fails on drift. A contract lich has not +released at all is the one exception: `refresh-fixtures.sh` reads it at the +commit it pins until a release ships it. ## Rules @@ -112,6 +122,8 @@ the copies against that release and fails on drift. ```bash claude --plugin-dir . +claude plugin validate .claude-plugin/plugin.json # the manifest, hooks.json and the mod +claude plugin test . # the mod under Claude Code's engine ``` Codex has no equivalent one-shot flag; install the clone as a local marketplace instead (`codex plugin marketplace add .` then `codex plugin add lich@lich-plugin`), and trust the hooks once with `/hooks`. @@ -127,6 +139,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` and `omp/lich.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` and `hooks/mod-control.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. diff --git a/README.md b/README.md index 4759a93..8ff029d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,15 @@ be told about a server on its command line, so they are defined in the module instead. [docs/opencode-tools.md](docs/opencode-tools.md) has the list and the two cases where they are deliberately absent. +On **Claude Code** it carries the other direction too, from lich to the session: +a mod, a module Claude Code runs in its own process, takes the commands lich +sends from the session's card (start a turn with a prompt, stop the running +turn, override the model or the effort, compact the context) and reports how +each one went. It needs Claude Code 2.1.280 or later with mods turned on; an +older one keeps every report above and only goes without the controls. +[docs/mod-control.md](docs/mod-control.md) has what it does and which releases +run it. No other harness has a mod system, so none of them gets the controls. + It also ships skills for the parts of lich you configure from inside a session: - **theme** (`/lich:theme` in Claude Code; on Codex and Antigravity the `theme` skill loads from its description) — write, port or fix a lich color theme: the app tokens, the xterm palette, where the file goes, and a validator for the rules that otherwise fail silently @@ -38,6 +47,7 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni hooks/report-session-start.sh # session-start hook hooks/report-title.sh # session-title hook hooks/report-touched.sh # session-touched hook +hooks/mod-control.js # mod-control client, a Claude Code mod opencode/lich.js # opencode client: all four reports plus the seven tools, one module omp/lich.js # omp client: the reports, one module docs/ # client-side docs, one per contract @@ -159,4 +169,4 @@ claude --plugin-dir . node --test tests/*.test.mjs ``` -Every hook script runs as a real subprocess, from the command line its registration spells, against a stub HTTP server — and the body it POSTs is asserted against [lich's contract fixtures](https://github.com/omartelo/lich/tree/main/docs/hooks/fixtures): an accepted shape, never a rejected one, the right endpoint, token and `X-Lich-Plugin` header, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The opencode and omp modules are imported instead of spawned and fed the events a real run of each emits, against the same fixtures. All three share `tests/contract.mjs`. The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`, at the lich release named in `tests/lich-ref`; CI diffs them against that release so a contract that moves in lich goes red here. +Every hook script runs as a real subprocess, from the command line its registration spells, against a stub HTTP server — and the body it POSTs is asserted against [lich's contract fixtures](https://github.com/omartelo/lich/tree/main/docs/hooks/fixtures): an accepted shape, never a rejected one, the right endpoint, token and `X-Lich-Plugin` header, plus the client rules (no lich environment → no report; exit 0 when lich answers 500 or refuses the connection). The opencode and omp modules and the Claude Code mod are imported instead of spawned and fed the events a real run of each emits, against the same fixtures. All of them share `tests/contract.mjs`. The fixtures are vendored in `tests/fixtures/` by `tests/refresh-fixtures.sh`, at the lich release named in `tests/lich-ref`; CI diffs them against that release so a contract that moves in lich goes red here. diff --git a/docs/mod-control.md b/docs/mod-control.md new file mode 100644 index 0000000..6efb68e --- /dev/null +++ b/docs/mod-control.md @@ -0,0 +1,100 @@ +# Mod: control + +Client side of the [mod-control contract](https://github.com/omartelo/lich/blob/main/docs/hooks/mod-control.md) +(the endpoints, the commands and the ack are defined in the lich repository; +this plugin only implements them). + +Lets lich drive a Claude Code session from its card: start a turn with a +prompt, stop the running turn, override the model or the effort of every +request, and compact the context. It runs the other way from the hooks: instead +of reporting what happened, it holds a long poll open on lich, applies the +commands that come back, and acks each one. + +| client | Claude Code | Codex | Antigravity | opencode | omp | Crush | +|------------------------|-----------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| +| `hooks/mod-control.js` | mod, `hooks/hooks.json` `modules` | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system | + +A Claude Code mod is a module Claude Code runs inside its own process, listed +under `modules` in the same `hooks/hooks.json` that registers the scripts. Both +kinds live in one file and both fire. lich refuses to queue a command for a +session whose mod is not polling, so on every other harness the card says the +controls are unavailable instead of waiting on nothing. + +| command | applied with | ack `ok: true` once | +|-----------|--------------------------------------|-----------------------------------------| +| `prompt` | `$.prompt.submit({ text })` | its turn started | +| `abort` | `$.turn.abort({ turnId })`, the id `turn.start` gave | the turn ended | +| `model` | every `turn.step` sent with `model` | the override is set, or dropped when absent | +| `effort` | every `turn.step` sent with `effort` | the override is set, or dropped when absent | +| `compact` | `$.session.compact({ instructions })` | the compaction ran | + +`GET /mod/commands?token=…&session_id=…` to fetch, `POST /mod/acks` with +`{"session_id", "id", "kind", "ok", "error"?}` to ack, `X-Lich-Plugin` on both. + +## How it behaves + +- **Outside lich it never polls.** It reads the three variables in + `session.start` and stops there when one is missing. Its `turn.step` hook + still runs on every request and passes it on unchanged. +- **Only an interactive session polls.** A `claude -p` with a parked poll stays + open until the poll returns, up to 25 seconds after its turn ended, and one run + from a tool inside a lich session inherits that session's variables, so it + would also take the parent's commands. `session.start` says which one it is + (`isInteractive`). +- **Polling never waits on a command.** Commands are applied one after another, + apart from the poll, which goes out again at once. A `prompt` resolves only + when the session is idle and its turn starts, and lich stops queueing for a + mod that has not polled in 30 seconds: a poll held behind that prompt would + get the abort meant for the running turn refused. +- **Nor does a command wait on a `prompt`.** The prompt is submitted in its + place and acked when its turn starts, but what comes after it runs at once: + an `abort` held behind it would reach the running turn only after that turn + ended, and then cancel the prompt's own turn instead. A `model` or `effort` + sent meanwhile likewise applies to the running turn's next request. +- **Every other ack is awaited before the next command.** That is the + contract's rule for an `abort`: lich ends the turn it has open when the ack + lands, so a late one would end the turn the next `prompt` opened. An ack that + cannot be sent is dropped, and the next command runs. +- **An effort outside `low`, `medium`, `high`, `xhigh` and `max` is refused** + (`unknown effort`). Claude Code would take it and fail the hook only at the + next model request, long after an `ok` ack. A model name has no such check + before a request uses it, so it is acked as set. +- **A `404` stops polling for the session**, a network error or a `5xx` backs off + 1 second doubling to 10, and a `200` resets it. Any other status backs off the + same way rather than poll in a tight loop. +- **A kind it does not know is acked `ok: false`, `unknown kind`**, its kind + echoed so a lich newer than the plugin hears of it. + +## Which Claude Code runs it + +Measured against a stub lich on each release: + +- **2.1.280 and later** load and run the module. +- **2.1.260 to 2.1.278** load the plugin and skip the module: the switch Claude + Code rolls mods out under (`tengu_plugin_hooks_modules`) is off for installed + plugins there. +- **2.1.250** knows mods with an older event set: `claude plugin validate` + fails on `session.start`, but a session loads the plugin and skips the module. +- **2.1.200** ignores the `modules` key. + +On every one of them the scripts keep reporting as before. What an older Claude +Code loses is the card's controls, which lich reports as unavailable for the +session. The same happens on a current one when the rollout switch is off: it is +a server-side flag Claude Code caches on disk, and an older `claude` run under +the same home can save it off for the next session. + +A mod also loads only once the folder is trusted. The scripts register before +the trust prompt is answered, the module after. + +## Known ceilings + +- **Overrides live in the module.** A reload of the module (a new plugin + release, a hot reload while developing) starts it fresh, with no model or + effort override and without telling lich. The reload itself cancels a parked + poll, so lich's "client gone" path covers it. +- **The classic hooks do not see the effort override.** `Stop`'s payload still + names the session's own effort. The transcript's assistant rows carry the one + the request used. +- **An effort sent to a model without effort is dropped by Claude Code**, and + the ack still says `ok`: the override is set, it just changes nothing for that + model. diff --git a/docs/providers.md b/docs/providers.md index 0b8cb10..8ba4ba7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -21,6 +21,7 @@ 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/mod-control.js` | Claude Code | the mod-control client, a module `hooks/hooks.json` names under `modules` | | `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 | diff --git a/hooks/hooks.json b/hooks/hooks.json index ac1f0f2..1bd32a2 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,4 +1,5 @@ { + "modules": ["./mod-control.js"], "hooks": { "SessionStart": [ { diff --git a/hooks/mod-control.js b/hooks/mod-control.js new file mode 100644 index 0000000..2a05785 --- /dev/null +++ b/hooks/mod-control.js @@ -0,0 +1,193 @@ +// Lets lich drive a Claude Code session from its card. Contract: ../docs/mod-control.md, +// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/mod-control.md +// +// A Claude Code mod, not a hook script: hooks/hooks.json names this file under +// `modules`, and Claude Code runs it inside its own process. It reports nothing. +// It holds a long poll open on lich, applies the commands that come back and +// acks each one. A Claude Code that does not run mods ignores the `modules` +// key, and the scripts beside this file keep reporting as before. +// +// Everything outside the module is reached through `$`, which is why every +// function that takes it 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.14.0" + +// The contract's client rule: after a network error or a 5xx, wait 1 second, +// doubling up to 10, and start over after a 200. +const FIRST_BACKOFF_MS = 1000 +const MAX_BACKOFF_MS = 10000 + +// The levels `turn.step` takes. The engine checks an effort only when the next +// model request goes out, long after the command was acked `ok`, and then skips +// the hook, so a level outside these is refused here, where lich hears of it. +const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]) + +/** + * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort + * @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, instructions?: string }} Command + * @typedef {{ + * base: string, + * token: string, + * session: string, + * backoffMs: number, + * applying: Promise, + * }} Link + * @typedef {{ + * link?: Link, + * turnId?: string, + * model?: string, + * effort?: Effort, + * }} State + */ + +/** + * @param {Engine} $ + * @returns {Promise} + */ +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, backoffMs: 0, applying: Promise.resolve() } +} + +/** + * One poll, then the next one scheduled: at once after a 200, later after a + * failure, never after a 404 (a lich older than the contract). + * + * @param {Engine} $ + * @param {State} state + * @param {Link} link + */ +async function poll($, state, link) { + const url = `${link.base}/mod/commands?token=${link.token}&session_id=${link.session}` + /** @type {Command[]} */ + let commands + try { + const response = await $.http.fetch(url, { headers: { "X-Lich-Plugin": PLUGIN_VERSION } }) + if (response.status === 404) return + if (!response.ok) throw new Error(`lich answered ${response.status}`) + commands = JSON.parse(response.text) + } catch { + link.backoffMs = Math.min(link.backoffMs * 2 || FIRST_BACKOFF_MS, MAX_BACKOFF_MS) + $.clock.after(link.backoffMs, () => void poll($, state, link)) + return + } + link.backoffMs = 0 + for (const command of commands) { + link.applying = link.applying.then(() => { + const acked = applyAndAck($, state, link, command) + // A prompt settles only once the session is idle and its turn starts. + // Waiting on it would hold an abort meant for the running turn until that + // turn ended, and the abort would then cancel the prompt's own turn. + if (command.kind !== "prompt") return acked + }) + } + $.clock.after(0, () => void poll($, state, link)) +} + +/** + * Applies one command and acks it. Every command but a `prompt` holds the next + * one until its ack is answered: an `abort` ack makes lich end the turn it has + * open, so one landing after the next `prompt` opened a turn would end that turn + * instead. + * + * @param {Engine} $ + * @param {State} state + * @param {Link} link + * @param {Command} command + */ +async function applyAndAck($, state, link, command) { + /** @type {{ ok: boolean, error?: string }} */ + let outcome + try { + await apply($, state, command) + outcome = { ok: true } + } catch (error) { + outcome = { ok: false, error: error instanceof Error ? error.message : String(error) } + } + try { + await $.http.fetch(`${link.base}/mod/acks?token=${link.token}`, { + method: "POST", + headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION }, + body: JSON.stringify({ session_id: link.session, id: command.id, kind: command.kind, ...outcome }), + }) + } catch { + // The contract drops an ack that cannot be sent: lich keeps no table to retry against. + } +} + +/** + * @param {Engine} $ + * @param {State} state + * @param {Command} command + */ +async function apply($, state, command) { + switch (command.kind) { + case "prompt": { + const submitted = await $.prompt.submit({ text: command.text ?? "" }) + if (submitted.drop !== undefined) throw new Error(submitted.drop) + return + } + case "abort": + if (state.turnId === undefined) throw new Error("no turn is running") + await $.turn.abort({ turnId: state.turnId }) + return + case "model": + state.model = command.model + return + case "effort": + if (command.effort !== undefined && !EFFORTS.has(command.effort)) throw new Error("unknown effort") + state.effort = /** @type {Effort | undefined} */ (command.effort) + return + case "compact": { + const compacted = await $.session.compact({ instructions: command.instructions }) + if (compacted.skip !== undefined) throw new Error(compacted.skip) + return + } + default: + throw new Error("unknown kind") + } +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + /** @type {State} */ + const state = {} + + on("session.start", async ($, e, next) => { + // A `claude -p` holds its run open until a parked poll returns, up to 25 + // seconds, and one started from a tool inside a lich session inherits that + // session's variables, so it would also take the parent's commands. + if (e.isInteractive && state.link === undefined) { + state.link = await linkFromEnv($) + const link = state.link + if (link) $.clock.after(0, () => void poll($, state, link)) + } + return next(e) + }) + + on("turn.start", ($, e, next) => { + state.turnId = e.turnId + return next(e) + }) + + on("turn.complete", ($, e, next) => { + if (e.turnId === state.turnId) state.turnId = undefined + return next(e) + }) + + on("turn.step", async function* ($, e, next) { + return yield* next({ + ...e, + ...(state.model !== undefined && { model: state.model }), + ...(state.effort !== undefined && { effort: state.effort }), + }) + }) +} diff --git a/hooks/report-session-start.sh b/hooks/report-session-start.sh index ac51dc6..66b3620 100755 --- a/hooks/report-session-start.sh +++ b/hooks/report-session-start.sh @@ -7,7 +7,7 @@ [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -plugin_version=0.13.2 +plugin_version=0.14.0 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. diff --git a/hooks/report-state.sh b/hooks/report-state.sh index 1097c53..cc0939f 100755 --- a/hooks/report-state.sh +++ b/hooks/report-state.sh @@ -7,7 +7,7 @@ [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -plugin_version=0.13.2 +plugin_version=0.14.0 state=$1 body="{\"session_id\":\"${LICH_SESSION_ID}\",\"state\":\"${state}\"}" diff --git a/hooks/report-title.sh b/hooks/report-title.sh index dc65db4..d50e1b8 100755 --- a/hooks/report-title.sh +++ b/hooks/report-title.sh @@ -11,7 +11,7 @@ [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -plugin_version=0.13.2 +plugin_version=0.14.0 command -v jq >/dev/null 2>&1 || exit 0 # Checked before stdin is read, because it is the whole point: finding a title diff --git a/hooks/report-tool.sh b/hooks/report-tool.sh index caeadf4..f29a4a4 100755 --- a/hooks/report-tool.sh +++ b/hooks/report-tool.sh @@ -11,7 +11,7 @@ [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -plugin_version=0.13.2 +plugin_version=0.14.0 here=$(dirname "$0") payload=$(cat) diff --git a/hooks/report-touched.sh b/hooks/report-touched.sh index b2cc221..8f1870c 100755 --- a/hooks/report-touched.sh +++ b/hooks/report-touched.sh @@ -6,7 +6,7 @@ [ -n "$LICH_PORT" ] && [ -n "$LICH_TOKEN" ] && [ -n "$LICH_SESSION_ID" ] || exit 0 # Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -plugin_version=0.13.2 +plugin_version=0.14.0 curl -s -o /dev/null --max-time 1 \ -X POST "http://127.0.0.1:${LICH_PORT}/session-touched?token=${LICH_TOKEN}" \ diff --git a/omp/lich.js b/omp/lich.js index bb04daa..1ab3647 100644 --- a/omp/lich.js +++ b/omp/lich.js @@ -24,7 +24,7 @@ // environment is read per report rather than at import, so a module loaded // outside lich stays a no-op instead of a cached decision. // Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -const PLUGIN_VERSION = "0.13.2" +const PLUGIN_VERSION = "0.14.0" function report(path, body) { const port = process.env.LICH_PORT diff --git a/opencode/lich.js b/opencode/lich.js index d637b34..abdfae2 100644 --- a/opencode/lich.js +++ b/opencode/lich.js @@ -11,7 +11,7 @@ // environment is read per report rather than at import, so a module loaded // outside lich stays a no-op instead of a cached decision. // Sent as X-Lich-Plugin on every report; bumped at release (CLAUDE.md, Release). -const PLUGIN_VERSION = "0.13.2" +const PLUGIN_VERSION = "0.14.0" function report(path, body) { const port = process.env.LICH_PORT diff --git a/tests/contract.mjs b/tests/contract.mjs index ad73f2d..16cd26f 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -37,6 +37,7 @@ export const FIXTURE_OF = { '/session-start': 'session-start', '/session-title': 'session-title', '/session-touched': 'session-touched', + '/mod/acks': 'mod-control', } const readFixture = (name) => @@ -56,6 +57,7 @@ export const rejected = (endpoint) => CASES[endpoint].filter((c) => c.reject) // state or a provider that lich stops accepting stops being accepted below too. 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)) /** * Providers this plugin already reports for, and lich does not accept yet. @@ -114,6 +116,14 @@ export const REJECT_RULES = { 'empty session_id': (b) => b.session_id === '', 'malformed json': (b, raw) => !isJsonObject(raw), }, + '/mod/acks': { + 'missing session_id': (b) => !('session_id' in b), + 'missing id': (b) => !('id' in b), + 'missing kind': (b) => !('kind' in b), + 'unknown kind': (b) => 'kind' in b && !MOD_KINDS.has(b.kind), + 'ok is not a bool': (b) => 'ok' in b && typeof b.ok !== 'boolean', + 'malformed json': (b, raw) => !isJsonObject(raw), + }, } export function isJsonObject(raw) { diff --git a/tests/fixtures/mod-commands.json b/tests/fixtures/mod-commands.json new file mode 100644 index 0000000..a5184d5 --- /dev/null +++ b/tests/fixtures/mod-commands.json @@ -0,0 +1,10 @@ +[ + {"id": "m1", "kind": "prompt", "text": "run the tests"}, + {"id": "m2", "kind": "abort"}, + {"id": "m3", "kind": "model", "model": "claude-opus-4-1"}, + {"id": "m4", "kind": "model"}, + {"id": "m5", "kind": "effort", "effort": "high"}, + {"id": "m6", "kind": "effort"}, + {"id": "m7", "kind": "compact", "instructions": "keep the test plan"}, + {"id": "m8", "kind": "compact"} +] diff --git a/tests/fixtures/mod-control.jsonl b/tests/fixtures/mod-control.jsonl new file mode 100644 index 0000000..8c52973 --- /dev/null +++ b/tests/fixtures/mod-control.jsonl @@ -0,0 +1,12 @@ +{"name":"abort ok","body":{"session_id":"s1","id":"m1","kind":"abort","ok":true},"accept":{"session_id":"s1","id":"m1","kind":"abort","ok":true}} +{"name":"failure carries its error","body":{"session_id":"s1","id":"m3","kind":"effort","ok":false,"error":"unknown effort"},"accept":{"ok":false,"error":"unknown effort"}} +{"name":"missing ok reads as failure","body":{"session_id":"s1","id":"m4","kind":"model"},"accept":{"ok":false}} +{"name":"long error is clamped","body":{"session_id":"s1","id":"m5","kind":"compact","ok":false,"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},"accept":{"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}} +{"name":"id from a previous process","body":{"session_id":"s1","id":"m999","kind":"prompt","ok":true},"accept":{"id":"m999"}} +{"name":"extra fields are ignored","body":{"session_id":"s1","id":"m6","kind":"abort","ok":true,"turn_id":"t"},"accept":{"id":"m6"}} +{"name":"missing session_id","body":{"id":"m1","kind":"abort","ok":true},"reject":"session_id is required"} +{"name":"missing id","body":{"session_id":"s1","kind":"abort","ok":true},"reject":"id is required"} +{"name":"missing kind","body":{"session_id":"s1","id":"m1","ok":true},"reject":"kind is required"} +{"name":"unknown kind","body":{"session_id":"s1","id":"m1","kind":"type","ok":true},"reject":"kind must be prompt, abort, model, effort or compact"} +{"name":"ok is not a bool","body":{"session_id":"s1","id":"m1","kind":"abort","ok":"true"},"reject":"ok must be a boolean"} +{"name":"malformed json","raw":"{","reject":"body must be a JSON object"} diff --git a/tests/fixtures/session-start.jsonl b/tests/fixtures/session-start.jsonl index 2b5d688..e513a73 100644 --- a/tests/fixtures/session-start.jsonl +++ b/tests/fixtures/session-start.jsonl @@ -6,8 +6,6 @@ {"name":"cursor chat id","body":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"},"accept":{"session_id":"s1","provider_session_id":"bec68d79-a208-4e40-8d2f-f8f8964da216","provider":"cursor"}} {"name":"kiro session id","body":{"session_id":"s1","provider_session_id":"f5d98acc-da1a-4029-a2eb-ecc1f666af06","provider":"kiro"},"accept":{"session_id":"s1","provider_session_id":"f5d98acc-da1a-4029-a2eb-ecc1f666af06","provider":"kiro"}} {"name":"absent provider defaults to claude","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}} -{"name":"legacy claude_session_id is folded","body":{"session_id":"s1","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02","provider":"claude"}} -{"name":"provider_session_id wins over legacy","body":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","claude_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c02"},"accept":{"session_id":"s1","provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01","provider":"claude"}} {"name":"missing session_id","body":{"provider_session_id":"018f9c5a-0000-7000-9a3b-1c2d3e4f5c01"},"reject":"session_id is required"} {"name":"missing provider_session_id","body":{"session_id":"s1"},"reject":"provider_session_id is required"} {"name":"empty provider_session_id","body":{"session_id":"s1","provider_session_id":""},"reject":"provider_session_id is required"} diff --git a/tests/lich-ref b/tests/lich-ref index f6460f6..7e16869 100644 --- a/tests/lich-ref +++ b/tests/lich-ref @@ -1 +1 @@ -v0.50.0 +v0.57.0 diff --git a/tests/mod-control.engine.test.ts b/tests/mod-control.engine.test.ts new file mode 100644 index 0000000..b1109ed --- /dev/null +++ b/tests/mod-control.engine.test.ts @@ -0,0 +1,112 @@ +// The mod under Claude Code's own engine: `claude plugin test .` loads +// hooks/mod-control.js from hooks/hooks.json the way a session does and runs +// these against it, the test's hooks standing for lich (`http.fetch`) and for +// the engine's bottom (`prompt.submit`, `turn.abort`, `turn.step`). +// +// tests/mod-control.test.mjs is the suite CI runs, against the contract +// fixtures; this one needs a claude binary, and proves the module loads and its +// hooks chain in the engine that runs it. + +import { expect, mock, test } from 'claude-code/testing' +import type { HttpResponse, On } from 'claude-code' + +const ENV = { LICH_PORT: '47999', LICH_TOKEN: 'tok', LICH_SESSION_ID: 'lich-1' } +const json = (body: unknown): HttpResponse => ({ status: 200, ok: true, headers: {}, text: JSON.stringify(body) }) + +/** + * Answers the first polls with `batches`, one each, parks the rest, and records + * every ack; also stands for the engine's own session start, which the kit + * leaves to the test. + */ +function lich(on: On, ...batches: unknown[][]) { + const acks: Record[] = [] + let polls = 0 + on('session.start', async (_$, e) => ({ cwd: e.cwd })) + on('http.fetch', async (_$, e) => { + if (e.url.includes('/mod/acks')) { + acks.push(JSON.parse(e.init?.body ?? '{}')) + return { value: { status: 204, ok: true, headers: {}, text: '' } } + } + const batch = batches[polls++] + if (batch) return { value: json(batch) } + return new Promise(() => {}) + }) + return { acks, polls: () => polls } +} + +test('outside lich nothing is fetched', async ($, on) => { + mock.env(on, {}) + const clock = mock.clock(on) + const world = lich(on, []) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.advance(1000) + expect(world.polls()).toBe(0) +}) + +test('a prompt from lich is submitted as the plugin and acked', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on, [{ id: 'm1', kind: 'prompt', text: 'run the tests' }]) + const submitted: unknown[] = [] + on('prompt.submit', async (_$, e) => { + submitted.push({ text: e.text, origin: e.origin }) + return { text: e.text } + }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(submitted).toEqual([{ text: 'run the tests', origin: { kind: 'plugin', name: 'lich' } }]) + expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm1', kind: 'prompt', ok: true }]) +}) + +test('an abort from lich ends the turn turn.start named', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on, [{ id: 'm2', kind: 'abort' }]) + const aborted: string[] = [] + on('turn.start', async (_$, e) => ({ turnId: e.turnId })) + on('turn.abort', async (_$, e) => { + aborted.push(e.turnId) + return { value: undefined } + }) + await $.turn.start({ text: 'go', turnId: 't1' }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(aborted).toEqual(['t1']) + expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }]) +}) + +test('the model and effort overrides reach the request the engine sends', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + lich(on, [ + { id: 'm3', kind: 'model', model: 'claude-opus-4-1' }, + { id: 'm5', kind: 'effort', effort: 'high' }, + ]) + const sent: { model: string; effort?: unknown }[] = [] + on('turn.step', async function* (_$, e) { + sent.push({ model: e.model, effort: e.effort }) + return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null } + }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + for await (const _ of $.turn.step({ turnId: 't1', index: 0, model: 'claude-sonnet-5-5', effort: 'medium', messageCount: 1 })); + expect(sent).toEqual([{ model: 'claude-opus-4-1', effort: 'high' }]) +}) + +test('an abort reaches the running turn while a prompt waits for the session', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on, [{ id: 'm1', kind: 'prompt', text: 'next' }], [{ id: 'm2', kind: 'abort' }]) + const aborted: string[] = [] + on('turn.start', async (_$, e) => ({ turnId: e.turnId })) + on('prompt.submit', () => new Promise(() => {})) + on('turn.abort', async (_$, e) => { + aborted.push(e.turnId) + return { value: undefined } + }) + await $.turn.start({ text: 'go', turnId: 't1' }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(aborted).toEqual(['t1']) + expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }]) +}) diff --git a/tests/mod-control.test.mjs b/tests/mod-control.test.mjs new file mode 100644 index 0000000..703fbd1 --- /dev/null +++ b/tests/mod-control.test.mjs @@ -0,0 +1,439 @@ +// Pins the Claude Code mod (hooks/mod-control.js) to lich's mod-control contract. +// +// A mod runs inside Claude Code and reaches everything through `$`, so like the +// opencode and omp suites this one imports the module and hands it a fake +// engine: the `$` calls the module makes, answered from memory. Every fetch it +// makes is recorded as contract.mjs reads a request, so the acks answer to the +// same fixtures (mod-control.jsonl) and the polls read the response body lich +// sends (mod-commands.json). +// +// The event names, the `e` fields and the `$` calls were measured on Claude Code +// 2.1.288 against a stub lich; the API is early access and moves between +// builds, so a name that changes there fails here. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { LICH_SESSION_ID, PLUGIN_VERSION, ROOT, TOKEN, assertContractHonoured, lichEnv } from './contract.mjs' +import { register } from '../hooks/mod-control.js' + +const PORT = 47999 +const COMMANDS = JSON.parse(readFileSync(path.join(ROOT, 'tests', 'fixtures', 'mod-commands.json'), 'utf8')) + +const ok = (body) => () => + Promise.resolve({ status: 200, ok: true, headers: {}, text: JSON.stringify(body) }) +const status = (code) => () => + Promise.resolve({ status: code, ok: code >= 200 && code < 300, headers: {}, text: '' }) +const refused = () => () => Promise.reject(new Error('connect ECONNREFUSED 127.0.0.1')) +const ACKED = { status: 204, ok: true, headers: {}, text: '' } + +function deferred() { + let resolve, reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** Waits for `predicate` to hold, failing after a second of real time. */ +async function until(predicate, what) { + const deadline = Date.now() + 1000 + while (!predicate()) { + if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}`) + await new Promise((r) => setImmediate(r)) + } +} + +/** + * Registers the module against a fake engine. `polls` answers each poll in + * turn; once it runs out a poll stays parked, as lich holds one with nothing + * queued. `engine` overrides what the prompt, turn and session calls answer. + */ +function load({ env = lichEnv(PORT), polls = [], engine = {} } = {}) { + const hooks = new Map() + register((event, hook) => hooks.set(event, hook)) + + const requests = [] + const delays = [] + const calls = [] + const answers = [...polls] + let ackWith = () => Promise.resolve(ACKED) + let parked = 0 + + const $ = { + env: { get: async (name) => env[name] }, + clock: { + after: (ms, fn) => { + delays.push(ms) + setImmediate(fn) + return { cancel() {} } + }, + }, + http: { + fetch: (url, init = {}) => { + const { pathname, search } = new URL(url) + const headers = Object.fromEntries(Object.entries(init.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])) + const request = { method: init.method ?? 'GET', url: pathname + search, headers, raw: init.body ?? '' } + requests.push(request) + if (pathname === '/mod/acks') return ackWith(request) + const answer = answers.shift() + if (answer) return answer() + parked++ + return new Promise(() => {}) + }, + }, + prompt: { + submit: async (args) => { + calls.push(['prompt', args]) + return engine.submit ? engine.submit(args) : { text: args.text } + }, + }, + turn: { + abort: async (args) => { + calls.push(['abort', args]) + if (engine.abort) return engine.abort(args) + }, + }, + session: { + compact: async (args) => { + calls.push(['compact', args]) + return engine.compact ? engine.compact(args) : { messages: [] } + }, + }, + } + + const passOn = async (e) => e + return { + requests, + delays, + calls, + parked: () => parked, + polls: () => requests.filter((r) => r.url.startsWith('/mod/commands?')), + acks: () => requests.filter((r) => r.url.startsWith('/mod/acks?')).map((r) => JSON.parse(r.raw)), + ackRequests: () => requests.filter((r) => r.url.startsWith('/mod/acks?')), + holdAcks: (fn) => { + ackWith = fn + }, + start: (isInteractive = true) => + hooks.get('session.start')($, { cwd: '/w', surface: isInteractive ? 'terminal' : null, isInteractive }, passOn), + turnStart: (turnId) => hooks.get('turn.start')($, { text: 'go', turnId }, passOn), + turnComplete: (turnId) => + hooks.get('turn.complete')($, { turnId, answer: '', durationMs: 1, isAborted: false, reason: 'answer' }, passOn), + /** Runs one model request through the module and returns what it sent below. */ + async step(e = { turnId: 't1', index: 0, model: 'claude-sonnet-5-5', effort: 'medium', messageCount: 1 }) { + let sent + const below = async function* (stepped) { + sent = stepped + yield { kind: 'text', text: 'hi' } + return { stop: 'end_turn' } + } + const stream = hooks.get('turn.step')($, e, below) + while (!(await stream.next()).done); + return sent + }, + } +} + +/** Loads the module with one poll answering `commands`, and waits for `n` acks. */ +async function applied(commands, { n = commands.length, before, ...options } = {}) { + const mod = load({ polls: [ok(commands)], ...options }) + await before?.(mod) + await mod.start() + await until(() => mod.acks().length >= n, `${n} acks`) + return mod +} + +// ---------------------------------------------------------------- the gate -- + +test('outside lich the mod never polls', async () => { + for (const missing of ['LICH_PORT', 'LICH_TOKEN', 'LICH_SESSION_ID']) { + const env = { ...lichEnv(PORT) } + delete env[missing] + const mod = load({ env }) + await mod.start() + await new Promise((r) => setImmediate(r)) + assert.equal(mod.requests.length, 0, `polled without ${missing}`) + assert.equal(mod.delays.length, 0) + } +}) + +// A `claude -p` with a parked poll stays open until the poll returns, and one +// run from a tool inherits the parent session's variables, so it would take the +// parent's commands (measured on 2.1.280, 2.1.286 and 2.1.288). +test('a non-interactive run never polls', async () => { + const mod = load() + await mod.start(false) + await new Promise((r) => setImmediate(r)) + assert.equal(mod.requests.length, 0) +}) + +test('outside lich a model request goes out untouched', async () => { + const mod = load({ env: {} }) + await mod.start() + const e = { turnId: 't1', index: 0, model: 'claude-sonnet-5-5', effort: 'medium', messageCount: 1 } + assert.deepEqual(await mod.step(e), e) +}) + +// ------------------------------------------------------------------ polling -- + +test('the poll names the session, the token and the plugin release', async () => { + const mod = load() + await mod.start() + await until(() => mod.parked() === 1, 'the first poll') + const [poll] = mod.polls() + assert.equal(poll.method, 'GET') + assert.equal(poll.url, `/mod/commands?token=${TOKEN}&session_id=${LICH_SESSION_ID}`) + assert.equal(poll.headers['x-lich-plugin'], PLUGIN_VERSION) +}) + +test('session.start starts one poll however often it fires', async () => { + const mod = load() + await mod.start() + await mod.start() + await until(() => mod.parked() === 1, 'the first poll') + await new Promise((r) => setImmediate(r)) + assert.equal(mod.polls().length, 1) +}) + +test('it re-polls at once after a 200, empty or not', async () => { + const mod = load({ polls: [ok([]), ok([]), ok([{ id: 'm1', kind: 'model', model: 'x' }])] }) + await mod.start() + await until(() => mod.parked() === 1, 'the fourth poll') + assert.equal(mod.polls().length, 4) + assert.deepEqual(mod.delays, [0, 0, 0, 0]) +}) + +test('a network error or a 5xx backs off 1s doubling to 10s, reset by a 200', async () => { + const mod = load({ + polls: [refused(), status(500), refused(), status(503), refused(), refused(), ok([]), refused()], + }) + await mod.start() + await until(() => mod.parked() === 1, 'the ninth poll') + assert.deepEqual(mod.delays, [0, 1000, 2000, 4000, 8000, 10000, 10000, 0, 1000]) +}) + +test('a 200 that is not JSON backs off instead of ending the loop', async () => { + const mod = load({ polls: [() => Promise.resolve({ status: 200, ok: true, headers: {}, text: '' })] }) + await mod.start() + await until(() => mod.parked() === 1, 'the next poll') + assert.deepEqual(mod.delays, [0, 1000]) +}) + +test('a 404 is a lich older than the contract: polling stops for the session', async () => { + const mod = load({ polls: [status(404)] }) + await mod.start() + await until(() => mod.polls().length === 1, 'the first poll') + await new Promise((r) => setTimeout(r, 20)) + assert.equal(mod.polls().length, 1) + assert.deepEqual(mod.delays, [0]) +}) + +// A prompt resolves only once the session is idle and its turn starts. lich +// stops queueing for a mod that has not polled in 30 seconds, so a poll held +// behind a waiting prompt would refuse the abort meant for the running turn. +test('the poll stays open while a command waits on the session', async () => { + const idle = deferred() + const mod = load({ polls: [ok([{ id: 'm1', kind: 'prompt', text: 'next' }])], engine: { submit: () => idle.promise } }) + await mod.start() + await until(() => mod.parked() === 1, 'the poll after the prompt') + assert.equal(mod.acks().length, 0) + idle.resolve({ text: 'next' }) + await until(() => mod.acks().length === 1, 'the prompt ack') + assert.equal(mod.acks()[0].ok, true) +}) + +// The abort a user clicks to stop the running turn must reach that turn, not +// wait for a queued prompt to start and then cancel the prompt's own turn. +test('a prompt waiting on the session holds back none of the commands after it', async () => { + const idle = deferred() + const mod = load({ + polls: [ + ok([{ id: 'm1', kind: 'prompt', text: 'next' }]), + ok([ + { id: 'm3', kind: 'model', model: 'claude-opus-4-1' }, + { id: 'm2', kind: 'abort' }, + ]), + ], + engine: { submit: () => idle.promise }, + }) + await mod.turnStart('t1') + await mod.start() + await until(() => mod.acks().length === 2, 'the model and abort acks') + assert.deepEqual(mod.calls, [ + ['prompt', { text: 'next' }], + ['abort', { turnId: 't1' }], + ]) + assert.deepEqual( + mod.acks().map(({ id, ok }) => ({ id, ok })), + [ + { id: 'm3', ok: true }, + { id: 'm2', ok: true }, + ], + ) + assert.equal((await mod.step()).model, 'claude-opus-4-1') + idle.resolve({ text: 'next' }) + await until(() => mod.acks().length === 3, 'the prompt ack') + assert.deepEqual(mod.acks()[2], { session_id: LICH_SESSION_ID, id: 'm1', kind: 'prompt', ok: true }) +}) + +// ----------------------------------------------------------------- commands -- + +test('every command shape lich sends is applied in order and acked ok', async () => { + const mod = await applied(COMMANDS, { before: (m) => m.turnStart('t1') }) + assert.deepEqual(mod.calls, [ + ['prompt', { text: 'run the tests' }], + ['abort', { turnId: 't1' }], + ['compact', { instructions: 'keep the test plan' }], + ['compact', { instructions: undefined }], + ]) + assert.deepEqual( + mod.acks().map(({ id, kind, ok }) => ({ id, kind, ok })), + COMMANDS.map(({ id, kind }) => ({ id, kind, ok: true })), + ) + for (const request of mod.ackRequests()) { + const { body } = assertContractHonoured('/mod/acks', request) + assert.equal(body.session_id, LICH_SESSION_ID) + } +}) + +test('a model or effort override reaches every model request', async () => { + const mod = await applied([ + { id: 'm3', kind: 'model', model: 'claude-opus-4-1' }, + { id: 'm5', kind: 'effort', effort: 'high' }, + ]) + for (const index of [0, 1]) { + const sent = await mod.step({ turnId: 't1', index, model: 'claude-sonnet-5-5', effort: 'medium', messageCount: 1 }) + assert.equal(sent.model, 'claude-opus-4-1') + assert.equal(sent.effort, 'high') + assert.equal(sent.index, index) + } +}) + +test('an override dropped by its absence restores the session\'s own model and effort', async () => { + const mod = load({ + polls: [ + ok([{ id: 'm3', kind: 'model', model: 'claude-opus-4-1' }, { id: 'm5', kind: 'effort', effort: 'high' }]), + ok([{ id: 'm4', kind: 'model' }, { id: 'm6', kind: 'effort' }]), + ], + }) + await mod.start() + await until(() => mod.acks().length === 4, 'four acks') + const e = { turnId: 't2', index: 1, model: 'claude-sonnet-5-5', effort: 'medium', messageCount: 3 } + assert.deepEqual(await mod.step(e), e) +}) + +// Claude Code takes the level and fails the hook at the next model request, +// long after the ack (measured on 2.1.288), so the mod refuses it up front. +test('an effort outside the five levels is refused and leaves the override alone', async () => { + const mod = await applied([ + { id: 'm1', kind: 'effort', effort: 'low' }, + { id: 'm2', kind: 'effort', effort: 'bogus' }, + ]) + const [, refusedAck] = mod.acks() + assert.deepEqual(refusedAck, { session_id: LICH_SESSION_ID, id: 'm2', kind: 'effort', ok: false, error: 'unknown effort' }) + assertContractHonoured('/mod/acks', mod.ackRequests()[1]) + assert.equal((await mod.step()).effort, 'low') +}) + +test('an abort with no turn running is acked as a failure', async () => { + const mod = await applied([{ id: 'm2', kind: 'abort' }], { + before: async (m) => { + await m.turnStart('t1') + await m.turnComplete('t1') + }, + }) + assert.deepEqual(mod.calls, []) + const [ack] = mod.acks() + assert.equal(ack.ok, false) + assert.equal(ack.error, 'no turn is running') + assertContractHonoured('/mod/acks', mod.ackRequests()[0]) +}) + +test('a subagent turn ending does not forget the main turn', async () => { + const mod = await applied([{ id: 'm2', kind: 'abort' }], { + before: async (m) => { + await m.turnStart('t1') + await m.turnComplete('sub-1') + }, + }) + assert.deepEqual(mod.calls, [['abort', { turnId: 't1' }]]) + assert.equal(mod.acks()[0].ok, true) +}) + +test('the next command waits for the abort ack to be answered', async () => { + const answered = deferred() + const mod = load({ + polls: [ok([{ id: 'm2', kind: 'abort' }, { id: 'm1', kind: 'prompt', text: 'go on' }])], + }) + mod.holdAcks(() => answered.promise) + await mod.turnStart('t1') + await mod.start() + await until(() => mod.ackRequests().length === 1, 'the abort ack') + await new Promise((r) => setTimeout(r, 20)) + assert.deepEqual(mod.calls, [['abort', { turnId: 't1' }]], 'the prompt ran before lich answered the abort ack') + answered.resolve(ACKED) + await until(() => mod.calls.length === 2, 'the prompt') + assert.deepEqual(mod.calls[1], ['prompt', { text: 'go on' }]) +}) + +test('an ack that cannot be sent is dropped and the next command still runs', async () => { + const mod = load({ + polls: [ok([{ id: 'm2', kind: 'abort' }, { id: 'm1', kind: 'prompt', text: 'go on' }])], + }) + mod.holdAcks(() => Promise.reject(new Error('connect ECONNREFUSED 127.0.0.1'))) + await mod.turnStart('t1') + await mod.start() + await until(() => mod.ackRequests().length === 2, 'both acks') + assert.deepEqual( + mod.calls.map(([kind]) => kind), + ['abort', 'prompt'], + ) +}) + +test('what Claude Code refuses is acked with its reason', async () => { + const mod = await applied( + [ + { id: 'm1', kind: 'prompt', text: 'blocked' }, + { id: 'm7', kind: 'compact' }, + { id: 'm8', kind: 'compact' }, + { id: 'm2', kind: 'abort' }, + ], + { + before: (m) => m.turnStart('t1'), + engine: { + submit: () => ({ drop: 'a plugin dropped it' }), + compact: (() => { + let n = 0 + return () => (n++ === 0 ? { skip: 'PreCompact blocked it' } : Promise.reject(new Error('a turn is running'))) + })(), + abort: () => Promise.reject(new Error('t1 is not the running turn')), + }, + }, + ) + assert.deepEqual( + mod.acks().map(({ ok, error }) => ({ ok, error })), + [ + { ok: false, error: 'a plugin dropped it' }, + { ok: false, error: 'PreCompact blocked it' }, + { ok: false, error: 'a turn is running' }, + { ok: false, error: 't1 is not the running turn' }, + ], + ) + for (const request of mod.ackRequests()) assertContractHonoured('/mod/acks', request) +}) + +// The contract's own rule, so a lich newer than the mod hears of it. This lich +// would refuse the ack, since it validates `kind` against the five it knows; +// a lich that sends a sixth kind knows six. +test('a kind the mod does not know is acked as unknown, its kind echoed', async () => { + const mod = await applied([{ id: 'm9', kind: 'rewind' }]) + assert.deepEqual(mod.acks(), [ + { session_id: LICH_SESSION_ID, id: 'm9', kind: 'rewind', ok: false, error: 'unknown kind' }, + ]) + assert.equal(mod.ackRequests()[0].headers['x-lich-plugin'], PLUGIN_VERSION) +}) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 5cd7897..33ef26c 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -17,7 +17,8 @@ here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) ref=$(tr -d '[:space:]' <"$here/lich-ref") base=https://raw.githubusercontent.com/omartelo/lich/$ref/docs/hooks/fixtures -for name in session-state session-start session-title session-touched; do - curl -fsSL "$base/$name.jsonl" -o "$here/fixtures/$name.jsonl" - echo "refreshed $name.jsonl at $ref" +for file in session-state.jsonl session-start.jsonl session-title.jsonl \ + session-touched.jsonl mod-control.jsonl mod-commands.json; do + curl -fsSL "$base/$file" -o "$here/fixtures/$file" + echo "refreshed $file at $ref" done