The agent side of the integration with lich — a harness that orchestrates agent CLI sessions. This companion plugin is how lich observes and acts inside a running session. It ships hooks — each following a contract documented in docs/ — plus skills for work that targets lich itself. One plugin, packaged for Claude Code, OpenAI Codex, Antigravity CLI, opencode, omp (oh-my-pi) and Crush from the same repository root.
.claude-plugin/plugin.json # plugin manifest, Claude Code
.claude-plugin/marketplace.json # marketplace, Claude Code
.codex-plugin/plugin.json # plugin manifest, Codex
.agents/plugins/marketplace.json # marketplace, Codex
plugin.json # plugin manifest, Antigravity — name and place both fixed
hooks.json # hook registration, Antigravity — likewise
hooks/hooks.json # hook registration, Claude Code
hooks/codex-hooks.json # hook registration, Codex
hooks/crush-hooks.json # hook registration, Crush (hand-merged into crush.json)
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/conversation-id.sh # the conversation a payload comes from, sourced by three of them
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the mods below
hooks/mod-control.js # Claude Code mod: lich drives the session
hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session
hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost
hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file
hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task
hooks/mod-status.js # Claude Code mod: the session's relay errands in the prompt footer
hooks/mod-compacting.js # Claude Code mod: the card says when the conversation is being compacted
hooks/self-command.js # Claude Code mod: the session runs a built-in slash command the user asked for
hooks/status-line.js # the lich line in the prompt footer, shared by the mods above
opencode/lich.js # opencode client: a module, not a command
omp/lich.js # omp client: a module, not a command
docs/ # lich ⇄ plugin communication contracts
skills/ # skills (skills/<name>/SKILL.md, all)
tests/ # hook payloads asserted against lich's fixtures
Antigravity runs the scripts, but on its own terms, and both of them are in
the file layout above. Its plugins are directories — plugins/<name>/plugin.json
with hooks.json and skills/ beside it — and neither filename is
configurable, which is why those two sit at the repository root instead of in a
namespaced folder: the root is the plugin directory once the clone is linked in.
It also sets no plugin-root variable, running a hook through sh -c with the
working directory set to that root, so its registration alone spells relative
paths; and it reads a JSON verdict off the hook's stdout, so the registration
appends one per event while the scripts stay silent. agy plugin validate .
checks the bundle from a clone.
Three harnesses do not fit the script model, each its own way. opencode runs
no commands: a plugin there is a JavaScript module its server imports, so all
four reports live in opencode/lich.js. omp has a --hook flag but merges
it with --extension into one list it import()s, so its "hooks" are modules
too, and its client is omp/lich.js. Crush runs the scripts unchanged — its
hooks are Claude Code-compatible down to the stdin payload — but has exactly one
event, PreToolUse, so only the session id and the touched refresh are
registered for it.
Contracts are canonical in the lich repository (docs/hooks/ there); this plugin only implements the client side. Each hook has a doc in docs/ here that points at its contract and describes the plugin-side behavior. Read it before creating or changing a hook:
- docs/session-state.md — session-state reporting (
busy/done) viaUserPromptSubmit/Stop, the tool a turn is running viaPreToolUse, andcompactingthrough a mod bracketingsession.compact(Claude Code only) - docs/session-start.md — Claude session id via
SessionStart - docs/session-title.md — the provider's own session title, via
PostToolUse/PreInvocationandStop - docs/session-touched.md — git-status refresh signal via
PostToolUse(file-mutating tools only) - docs/mod-control.md: lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system
- docs/agent-cards.md: a general-purpose subagent Claude Code starts runs as a lich session, through the
lich openCLI rather than an HTTP contract; Claude Code only - docs/mod-usage.md: a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking
session.measure; Claude Code only - docs/mod-answer.md: a lich subagent worker answers the task it was handed with its turn's final message, through a mod reading the main loop's
classic.Stopandturn.complete; Claude Code only - docs/mod-status.md: a Claude Code session shows the relay errands it is part of (owed, out, ready) in its prompt footer, through a mod reading
/mod/status, which never collects; Claude Code only - docs/self-command.md: a Claude Code session runs a built-in slash command the user asked it for (
/compact), queued after the turn, through a mod answering the Skill tool's refusal of a built-in, and lich'scontrol_sessionrefusal of the session's own, by queueing the command itself; Claude Code only - docs/edit-guard.md: an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only
- 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 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.
- skills/theme — writing, porting and validating lich color themes, as a single file or as a versioned theme repository. Mirrors the theme contract in the lich repository (
docs/themes.mdthere);template.jsonis a copy of the starter lich itself hands out, vendored attests/lich-refbytests/refresh-fixtures.shand diffed by CI like the fixtures, andvalidate.mjsderives its token sets from that copy. A change to the theme shape, or to the repository contract, lands here too.
node --test tests/*.test.mjstests/hooks.test.mjs drives every hook script as a real
subprocess — the command line taken from the registration each harness reads —
against a stub HTTP server, and asserts the body it POSTs against lich's
contract fixtures: an accepted shape, no rejected one, the right endpoint and
?token=, the X-Lich-Plugin header naming the manifest's version, no
deprecated claude_session_id, plus the client rules (no lich
environment → no report, exit 0 on a 500 or a refused connection).
tests/opencode.test.mjs does the same for the
opencode module, which is imported rather than spawned, and fed the event
payloads a real opencode run emits — including the sub-session ones it must
drop and the listener that never answers, which must not hold the turn.
tests/omp.test.mjs does the same for the omp module,
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 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/mod-usage.test.mjs does the same for its
usage reports against the mod-usage fixtures,
tests/worker-answer.test.mjs for a worker's
answer against the mod-answer fixtures,
tests/mod-status.test.mjs for the footer line it
draws from the mod-status response, and
tests/mod-compacting.test.mjs for the
compacting bracket against the session-state fixtures.
tests/edit-guard.test.mjs hands the edit guard a
fake $ with an in-memory git dir and asserts the markers it leaves and the
note another session's marker adds, and
tests/status-line.test.mjs that the mods share
one lich line in the prompt footer.
tests/agent-cards.test.mjs hands the agent-cards
mod the same kind of fake $ and asserts the lich open argv
it runs, the Agent result it answers each outcome with, and that a call it does
not take, or one lich cannot take, reaches the native agent untouched.
tests/self-command.test.mjs does the same for
the self-command mod: which Skill calls it takes, that the command runs only
after the call is answered, and that /model and /effort never run.
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 and
tests/agent-cards.engine.test.ts and
tests/mod-usage.engine.test.ts and
tests/mod-status.engine.test.ts and
tests/worker-answer.engine.test.ts and
tests/edit-guard.engine.test.ts and
tests/self-command.engine.test.ts run the
mods under Claude Code's own engine (claude plugin test .); they need a
claude binary, so CI does not run them. The kit does not check a tool.call
result against the tool's output schema (measured on 2.1.289), so whether
Claude Code accepts the Agent result is a live run's to answer.
The fixtures are vendored in tests/fixtures/ from lich
(docs/hooks/fixtures/*.jsonl there) by
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. 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.
- The fixtures are upstream truth — never edit one to get a green run. A
payload that disagrees with a fixture is a bug in the script, or a contract
change. That one merges in lich first (prose, then fixtures, then its
endpoint),
tests/lich-refpins that merge commit, the plugin release that speaks it is tagged here next, and the lich release that installs it is cut last. - A hook must never block or fail the user's turn: short timeout, errors swallowed, always exit 0. On Codex the exit code is louder still —
2onPermissionRequestdenies the request, so silence and exit 0 are what keep a report an observation. - Outside lich (env vars absent) every hook is a no-op with exit 0 — the plugin must be safe to install globally.
- A skill must be useful from any working directory: the user runs the agent CLI on their own project, not on the lich checkout.
- One script per contract, shared by every provider that runs commands. Registration differs per harness, the report does not — extend a script to read a second transcript format before adding a second script. A harness that runs no commands at all is the one case for a client of its own, which is what
opencode/lich.jsandomp/lich.jsare; they still send the same payloads to the same endpoints. - opencode and omp both await their plugin handlers, so neither
lich.jsever awaits a report or throws — that is their version of "always exit 0". The omp one also subscribes to nothing outside lich: omp loads it on every run on the machine, so the gate has to be in the module. - A module client names only events it was measured emitting. A name read off a type declaration is a report that silently never fires, which is worse than an absent feature — the omp
waitingstate is left unreported for exactly this reason. - The same goes for every other name a registration carries — an event, a tool a matcher names, a payload field, a plugin-root variable. All of them fail the same way: quietly, on every event, with a green test suite, because a harness that never runs the hook reports nothing to disagree with.
- A name is measured off a real payload, or it is a guess. Not from a harness you already support, and not from a symbol table or an enum either — those are the implementation's names, and something may map them before they reach a hook. Antigravity is the worked example in both directions: its guide says a tool name is the step type lowercased with
CORTEX_STEP_TYPE_dropped, and the enum'sMCP_TOOLstill arrives ascall_mcp_tool. Its write tools really arewrite_to_fileandreplace_file_content, which the enum does not contain. Run the turn, print the payload, read the name off it — a matcher built any other way matches nothing, and losing a report costs no error, only the report. - A test that supplies what the harness supplies proves nothing. The suite exports
$CLAUDE_PLUGIN_ROOTbecause Claude Code and Codex do; running Antigravity's registration under it hid the fact that Antigravity does not. Each harness's registration is run the way that harness runs it — seepluginRootintests/hooks.test.mjs.
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 engineCodex 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.
Antigravity has none either, but it does have a checker: agy plugin validate .
reads the clone as a plugin and says what it loaded (skills: 1, hooks: 1) —
a missing or misplaced plugin.json fails it outright. To run it for real,
symlink the clone into ~/.gemini/config/plugins/lich.
Same standard as the lich repository (Keep a Changelog + SemVer). Releases are cut by tagging vX.Y.Z:
- Move the
[Unreleased]entries inCHANGELOG.mdunder the new version heading (with date) and refresh the compare links at the bottom. - 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. A contract change is tagged here once lich has merged it and before the lich release that installs it, since that lich may fetch files only this release ships; an older lich never picks it up, because its ceiling stops below this minor.
docs/hooks/README.mdin lich, Versioning, is the rule. - Align
versionin.claude-plugin/plugin.jsonand.codex-plugin/plugin.jsonwith the tag, andplugin_versionin eachhooks/report-*.shandPLUGIN_VERSIONinopencode/lich.js,omp/lich.js,hooks/mod-control.js,hooks/mod-usage.js,hooks/mod-status.js,hooks/mod-compacting.jsandhooks/worker-answer.js, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The rootplugin.jsonis not a third one to bump: Antigravity's manifest takes only aname, and lich writes its own carrying the tag when it installs a release. - Annotated tag
vX.Y.Z+ push with the tag. gh release create vX.Y.Zwith the notes taken from the matchingCHANGELOG.mdsection.