Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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`.
Expand All @@ -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.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
100 changes: 100 additions & 0 deletions docs/mod-control.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"modules": ["./mod-control.js"],
"hooks": {
"SessionStart": [
{
Expand Down
Loading
Loading