From 14e361b429bc0177a88cc512fefce2ce4b337217 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:13:30 -0300 Subject: [PATCH 1/6] test: vendor the mod-control fixtures lich has not released yet The mod-control contract is served by omartelo/lich#645 and is in no lich release, so tests/lich-ref has nothing to read it at. refresh-fixtures.sh reads its two files at that pull request's head commit instead, and CI's drift check covers them like the rest; once a release ships the contract, lich-ref moves there and the pinned block goes. contract.mjs maps /mod/acks to mod-control.jsonl and models each of its rejected cases, so the generic fixture tests cover it too. --- tests/contract.mjs | 10 ++++++++++ tests/fixtures/mod-commands.json | 10 ++++++++++ tests/fixtures/mod-control.jsonl | 12 ++++++++++++ tests/refresh-fixtures.sh | 12 ++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 tests/fixtures/mod-commands.json create mode 100644 tests/fixtures/mod-control.jsonl 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/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 5cd7897..8c71519 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -21,3 +21,15 @@ 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" done + +# mod-control is in no lich release yet: it is read at the head of the pull +# request that serves it (omartelo/lich#645), so the module is held to the +# contract that lich is about to ship rather than to none. Once a release +# carries it, move tests/lich-ref there, fold these files into the list above +# and delete this block. +pending=f013e26f98521a2837d4efdd98b54db609fbeb4f +for file in mod-control.jsonl mod-commands.json; do + curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$pending/docs/hooks/fixtures/$file" \ + -o "$here/fixtures/$file" + echo "refreshed $file at $pending (unreleased)" +done From 16e27febfa4d24c19bb3eac8c142c20be0a7ab8d Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:13:30 -0300 Subject: [PATCH 2/6] feat: let lich drive a Claude Code session through a mod hooks/mod-control.js is a Claude Code mod, named under `modules` in the same hooks/hooks.json as the scripts, which keep firing beside it. In an interactive session inside lich it long-polls GET /mod/commands, applies prompt, abort, model, effort and compact in order, and acks each one on POST /mod/acks, awaiting every ack before the next command so an abort's ack never ends the turn a later prompt opened. Polling runs apart from the commands, so a prompt waiting for the session to go idle does not let lich's 30 second attach window lapse. A network error or a 5xx backs off 1s doubling to 10s, a 404 stops polling, and an effort outside the five levels is refused up front rather than failing at the next request. Claude Code 2.1.280 and later run it; older releases ignore or skip the module and keep every report. A `claude -p` never polls: a parked poll held its run open and inherited the parent session's variables. --- CHANGELOG.md | 11 + CLAUDE.md | 16 +- README.md | 12 +- docs/mod-control.md | 95 ++++++++ docs/providers.md | 1 + hooks/hooks.json | 1 + hooks/mod-control.js | 186 ++++++++++++++ tests/mod-control.engine.test.ts | 94 +++++++ tests/mod-control.test.mjs | 405 +++++++++++++++++++++++++++++++ 9 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 docs/mod-control.md create mode 100644 hooks/mod-control.js create mode 100644 tests/mod-control.engine.test.ts create mode 100644 tests/mod-control.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index defca52..f2c7e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 diff --git a/CLAUDE.md b/CLAUDE.md index bab58d7..e560d8f 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, and today that is mod-control. ## 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..9a604f3 --- /dev/null +++ b/docs/mod-control.md @@ -0,0 +1,95 @@ +# 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 | + +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. +- **Each 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..b3606ea --- /dev/null +++ b/hooks/mod-control.js @@ -0,0 +1,186 @@ +// 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.13.2" + +// 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(() => applyAndAck($, state, link, command)) + } + $.clock.after(0, () => void poll($, state, link)) +} + +/** + * Applies one command and acks it. The ack is awaited before the next command + * runs: 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/tests/mod-control.engine.test.ts b/tests/mod-control.engine.test.ts new file mode 100644 index 0000000..61866fb --- /dev/null +++ b/tests/mod-control.engine.test.ts @@ -0,0 +1,94 @@ +// 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 poll with `commands`, 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, commands: 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: '' } } + } + polls++ + if (polls === 1) return { value: json(commands) } + 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' }]) +}) diff --git a/tests/mod-control.test.mjs b/tests/mod-control.test.mjs new file mode 100644 index 0000000..9dcca90 --- /dev/null +++ b/tests/mod-control.test.mjs @@ -0,0 +1,405 @@ +// 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) +}) + +// ----------------------------------------------------------------- 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) +}) From 75400618f1f1efca014bd55cd76c8e69713d9eb0 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:19:27 -0300 Subject: [PATCH 3/6] fix: let an abort reach the running turn past a waiting prompt Every command ran on one serial chain, and a prompt settles only once the session is idle and its turn starts. An abort, model, effort or compact that arrived while a lich prompt waited on a busy session was held behind it: the abort the user clicked never reached the running turn, and once that turn ended on its own it cancelled the prompt's new turn instead. A prompt is now submitted in its place on the chain and acked when its turn starts, without holding the commands after it. Every other command still waits for its ack, so an abort's ack lands before the next prompt runs. --- docs/mod-control.md | 13 ++++++++---- hooks/mod-control.js | 15 ++++++++++---- tests/mod-control.engine.test.ts | 30 ++++++++++++++++++++++------ tests/mod-control.test.mjs | 34 ++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/docs/mod-control.md b/docs/mod-control.md index 9a604f3..bbd1e66 100644 --- a/docs/mod-control.md +++ b/docs/mod-control.md @@ -46,10 +46,15 @@ controls are unavailable instead of waiting on nothing. 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. -- **Each 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. +- **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 diff --git a/hooks/mod-control.js b/hooks/mod-control.js index b3606ea..d2c89af 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -81,15 +81,22 @@ async function poll($, state, link) { } link.backoffMs = 0 for (const command of commands) { - link.applying = link.applying.then(() => applyAndAck($, state, link, command)) + 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. The ack is awaited before the next command - * runs: 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. + * 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 diff --git a/tests/mod-control.engine.test.ts b/tests/mod-control.engine.test.ts index 61866fb..b1109ed 100644 --- a/tests/mod-control.engine.test.ts +++ b/tests/mod-control.engine.test.ts @@ -14,11 +14,11 @@ 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 poll with `commands`, parks the rest, and records every - * ack; also stands for the engine's own session start, which the kit leaves to - * the test. + * 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, commands: unknown[]) { +function lich(on: On, ...batches: unknown[][]) { const acks: Record[] = [] let polls = 0 on('session.start', async (_$, e) => ({ cwd: e.cwd })) @@ -27,8 +27,8 @@ function lich(on: On, commands: unknown[]) { acks.push(JSON.parse(e.init?.body ?? '{}')) return { value: { status: 204, ok: true, headers: {}, text: '' } } } - polls++ - if (polls === 1) return { value: json(commands) } + const batch = batches[polls++] + if (batch) return { value: json(batch) } return new Promise(() => {}) }) return { acks, polls: () => polls } @@ -92,3 +92,21 @@ test('the model and effort overrides reach the request the engine sends', async 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 index 9dcca90..703fbd1 100644 --- a/tests/mod-control.test.mjs +++ b/tests/mod-control.test.mjs @@ -247,6 +247,40 @@ test('the poll stays open while a command waits on the session', async () => { 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 () => { From f36b1d0c64eec58c4459a4939b5c14bdb71c9480 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:19:27 -0300 Subject: [PATCH 4/6] docs: give each harness its own column in the mod-control table --- docs/mod-control.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mod-control.md b/docs/mod-control.md index bbd1e66..6efb68e 100644 --- a/docs/mod-control.md +++ b/docs/mod-control.md @@ -10,9 +10,9 @@ 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 | +| 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 From 40a7c1d48bffcf8750191902e1e9f78e5fce62dd Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:21:04 -0300 Subject: [PATCH 5/6] test: read the mod-control fixtures at lich v0.57.0 --- CLAUDE.md | 2 +- tests/fixtures/session-start.jsonl | 2 -- tests/lich-ref | 2 +- tests/refresh-fixtures.sh | 19 ++++--------------- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e560d8f..dc01cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ 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. 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, and today that is mod-control. +commit it pins until a release ships it. ## Rules 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/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 8c71519..33ef26c 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -17,19 +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" -done - -# mod-control is in no lich release yet: it is read at the head of the pull -# request that serves it (omartelo/lich#645), so the module is held to the -# contract that lich is about to ship rather than to none. Once a release -# carries it, move tests/lich-ref there, fold these files into the list above -# and delete this block. -pending=f013e26f98521a2837d4efdd98b54db609fbeb4f -for file in mod-control.jsonl mod-commands.json; do - curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$pending/docs/hooks/fixtures/$file" \ - -o "$here/fixtures/$file" - echo "refreshed $file at $pending (unreleased)" +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 From bf39766416d8d48c81fca9260e850d994a6a8177 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 3 Oct 2026 20:21:04 -0300 Subject: [PATCH 6/6] chore: release v0.14.0 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 5 ++++- hooks/mod-control.js | 2 +- hooks/report-session-start.sh | 2 +- hooks/report-state.sh | 2 +- hooks/report-title.sh | 2 +- hooks/report-tool.sh | 2 +- hooks/report-touched.sh | 2 +- omp/lich.js | 2 +- opencode/lich.js | 2 +- 11 files changed, 14 insertions(+), 11 deletions(-) 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 f2c7e23..f515838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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 @@ -482,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/hooks/mod-control.js b/hooks/mod-control.js index d2c89af..2a05785 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -12,7 +12,7 @@ // 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.13.2" +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. 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