lich drives an agent CLI inside a PTY and injects the same three variables
(LICH_PORT, LICH_TOKEN, LICH_SESSION_ID) into every one it spawns. The
hook contracts
are therefore provider-agnostic: what changes per provider is only how the
harness registers hooks and what its lifecycle events are called. A Claude
Code session also carries LICH_BIN; LICH_SUBAGENT_DEPTH, how many
--subagent levels down the session was opened (0 for one nobody opened as a
worker); and LICH_SUBAGENT_CARDS=off when "Subagents as lich sessions" is off
in lich's Settings › Providers › Claude Code, or in a worker at lich's depth
limit. The agent-cards mod (agent-cards.md) reads all three,
and the worker-answer mod (mod-answer.md) the last two.
This repository is one plugin, packaged for each harness it supports:
| File | Read by | Purpose |
|---|---|---|
.claude-plugin/plugin.json |
Claude Code | plugin manifest |
.claude-plugin/marketplace.json |
Claude Code | marketplace, points at ./ |
.codex-plugin/plugin.json |
Codex | plugin manifest |
.agents/plugins/marketplace.json |
Codex | marketplace, points at ./ |
plugin.json |
Antigravity | plugin manifest |
hooks/hooks.json |
Claude Code | hook registration |
hooks/codex-hooks.json |
Codex | hook registration |
hooks.json |
Antigravity | hook registration, name and place both fixed |
hooks/crush-hooks.json |
Crush | hook registration, merged by hand |
hooks/*.sh |
the four | the reports themselves |
hooks/win-run.cmd |
Codex | runs a script on Windows |
hooks/lich.js |
Claude Code | the one module hooks/hooks.json names under modules; registers the mods below |
hooks/mod-control.js |
Claude Code | the mod-control client, a mod |
hooks/agent-cards.js |
Claude Code | runs a general-purpose subagent as a lich session, a mod |
hooks/mod-usage.js |
Claude Code | the mod-usage client, a mod |
hooks/mod-status.js |
Claude Code | the mod-status client, a mod |
hooks/edit-guard.js |
Claude Code | names the other lich session that edited a file, a mod |
hooks/worker-answer.js |
Claude Code | the mod-answer client: a worker's last message answers its task, a mod |
hooks/mod-compacting.js |
Claude Code | the session-state compacting report, a mod |
hooks/self-command.js |
Claude Code | runs a built-in slash command the user asked the session for, a mod |
opencode/lich.js |
opencode | the whole client, as a module |
omp/lich.js |
omp | the whole client, as a module |
skills/ |
all | skills, same layout |
The repository root is the plugin root for Claude Code, Codex and Antigravity,
so a single clone installs on any of the three. The scripts are shared because
those harnesses — and Crush — expose the same things a report needs: the payload
arrives as JSON on stdin naming the session (session_id, or conversationId
on Antigravity), the transcript (transcript_path / transcriptPath) and, on a
pre-tool event, the call (tool_name + tool_input + cwd, or toolCall +
workspacePaths). Every script reads both spellings, so one script still serves
every harness.
How the command finds the script is where Antigravity parts company. Claude
Code, Codex and Crush all give the command line a plugin root to start from:
$CLAUDE_PLUGIN_ROOT for the first two, a <lich-plugin> placeholder the user
replaces for Crush. Antigravity sets no such variable — it runs the command
through sh -c with the working directory set to the folder holding
hooks.json, which for a plugin is the plugin root. So its registration alone
spells relative paths (hooks/report-state.sh busy), and a ${CLAUDE_PLUGIN_ROOT}
copied in from the Claude Code file expands to nothing there: every hook then
runs /hooks/<script> and exits 127, silently, on every event.
opencode and omp are the exceptions, and both are packaging ones. Neither
has a notion of a hook command. In opencode a plugin is a JavaScript module the
server imports, which subscribes to an event bus instead of being spawned per
event. omp arrives at the same place from the other side: it has a --hook
flag, but --hook and --extension are one list and every entry is loaded with
import() — even the files it calls hooks are modules, never scripts fed a
payload on stdin. So each gets one file, opencode/lich.js and omp/lich.js,
posting the same payloads to the same endpoints. Nothing about the contracts
changes; what changes is that there is no process to exit 0 from, so the module
swallows its own errors and never awaits a report.
| Report | Claude Code | Codex | Antigravity | opencode | omp | Crush |
|---|---|---|---|---|---|---|
| session id | SessionStart |
SessionStart |
PreInvocation |
session.created |
session_start, session_switch |
PreToolUse |
busy |
UserPromptSubmit, PostToolUse |
UserPromptSubmit, PostToolUse |
PreInvocation |
session.status (busy) |
input, turn_start |
— |
busy + tool |
PreToolUse |
PreToolUse |
PreToolUse |
tool.execute.before |
tool_call |
— |
waiting + reason |
Notification |
PermissionRequest |
— (not measured) | any *.asked |
— (see below) | — |
done |
Stop, StopFailure |
Stop |
Stop |
session.status (idle) |
session_stop |
— |
compacting |
session.compact (a mod) |
— | — | — | — | — |
| title | Stop |
Stop |
Stop |
session.updated |
session_stop, turn_start |
— |
idle |
SessionEnd |
— (registered, never fires) | — (never fires) | — (nothing outlives it) | — (nothing outlives it) | — |
| touched | PostToolUse (write tools) |
PostToolUse (write tools) |
PostToolUse (write tools) |
file.edited |
tool_result (write tools) |
PreToolUse (write tools) |
Every cell above was taken off a real run of that harness against a stub listener, not off its documentation.
Two differences matter. Codex has no Notification, so "your turn" arrives as
PermissionRequest — where a hook exiting 2 would deny the request, which
is why every script exits 0 and prints nothing. And Codex has no SessionEnd
either: its events are PreToolUse, PermissionRequest, PostToolUse,
PreCompact, PostCompact, SessionStart, UserPromptSubmit,
SubagentStart, SubagentStop and Stop — 0.144.5 carries a payload schema
for each of those and none for SessionEnd — so nothing reports idle there.
The registration keeps its SessionEnd entry anyway: an unregistered event name
is ignored rather than rejected, so the report starts working the day Codex adds
one. Until then a Codex card holds its last indicator (and its provider mark)
until lich respawns that PTY.
opencode's waiting is the one cell that is a rule rather than an event name.
It asks the user in more than one way — permission.asked and question.asked,
each with a .v2. spelling published beside it — and its catalogue is not
exhaustive: a real run emits types (server.heartbeat) absent from its own
schema. Enumerating names there is how a prompt goes unreported, so the module
matches the suffix, and reads what the prompt is about by field for the same
reason. See session-state.md.
A waiting report says what the session is blocked on, and each harness hands
over something different to say it with — a sentence, a tool name, a permission.
The per-harness table is in session-state.md;
omp and Crush have no cell there, because neither reports waiting at all.
The session id carries the provider that reported it (report-session-start.sh codex), which is what puts Codex's icon rather than Claude's on the card, and
what lets lich resume the conversation with codex resume <id>. Both need lich
≥ 0.28.0; an older lich ignores the field and treats the report as Claude's.
opencode and Crush report the same field, so their cards wear their own icon
too — but lich does not resume either yet, so for them the id is stored and
waits.
- Hooks need to be trusted once. Codex asks you to review the exact hook
definitions of a non-managed plugin before it runs them —
/hooksinside a session. Until then the plugin is installed but silent: untrusted hooks are skipped without a word, so "nothing reports" is the expected first run, not a broken install. Trust is recorded against the hook's hash, so an update that changes a command asks again. - Hooks are a feature flag. Stable and on by default in current Codex; on
older versions,
[features] hooks = truein~/.codex/config.toml. - Start a new session after installing — bundled hooks and skills load at session start.
codex execis not the whole picture. A non-interactive run reports the session id,busy, touched, the title anddone— all verified against a real session — but neverwaiting: it runs with approvals off, so noPermissionRequestis ever raised. That path needs an interactive session, which is what lich spawns anyway.- Only the manifest's hook file is read. Codex falls back to a default
hooks/hooks.jsonwhen a plugin manifest declares none; this one declares./hooks/codex-hooks.json, so the Claude Code registration sitting beside it is not also loaded — a session reports each event once, not twice. - The plugin root is spelled
${CLAUDE_PLUGIN_ROOT}, braces included. Codex substitutes the braced form and nothing else before handing the line to a shell, so a bare$CLAUDE_PLUGIN_ROOTonly survives where the shell itself expands it. That is every Unix run — hooks go to$SHELL -lc— and no Windows one, where they go tocmd.exe /C. - Windows runs the scripts through
hooks/win-run.cmd.cmd.execannot execute a.sh, so each hook registers acommandWindowshanding the same script and the same argument to that wrapper, which looks for Git Bash — onPATH, then at its default install — and always exits 0, bash or no bash. Codex pickscommandWindowsovercommandon Windows and ignores it everywhere else, so one registration still serves both.
- It is a module, not a command.
opencode/lich.jsgoes in opencode's plugin directory —~/.config/opencode/plugin/globally,.opencode/plugin/per project (plugins/works too; opencode globs both). There is no marketplace to install from and no manifest: the file being there is the install. - One module, one card. The plugin is loaded by the opencode server and
reads
LICH_SESSION_IDfrom the environment it was started in. A session lich spawns gets its own server, so that is one card; a server shared between two top-level sessions would report both onto the same one. - Sub-sessions are filtered out. The
tasktool runs a sub-agent as its own session with its ownsession.status, and itsidleis not the turn ending. They are recognised by theparentIDtheir session events carry and dropped. - Nothing reports
idle. The event that would say "the CLI has left" is the server exiting with this module inside it, so there is nobody left to send it. Like a Codex card, an opencode card holds its last indicator until lich respawns its terminal. - The title is handed over, not read.
session.updatedcarries the whole session including its title. The one at creation is a placeholder built from the timestamp, so the module holds it and only reports a title that differs from it — no matching on its wording. - Never awaited. opencode awaits its plugin hooks, so a report that blocked
would sit in front of the agent's next step. Every
fetchhere is fired and dropped, with a 1s timeout, and its failure is swallowed — the module's version of "always exit 0".
Everything below was measured against omp v17.3.0
(@oh-my-pi/pi-coding-agent), driving a real omp --hook … -p … run at a stub
listener. 17.x moves fast — re-measure before trusting a name.
--hookis--extension. omp merges both flags into one list of extension paths and imports every entry, so a "hook" there is a module exporting a default factory, called with omp's API object. Its other hooks —hooks/pre/<tool>.sh— are imported the same way rather than run, so there is no script-and-stdin path anywhere in omp to reuse thehooks/*.shscripts from.- It loads only
module.default. Unlike opencode, which calls every export of a plugin file, omp reads one and warns if it is not a function. - The session id is on the context, not in the environment. omp exports
nothing about the running session to its own process env; every handler gets
ctx.sessionManager, whosegetSessionId()is the id, andgetSessionFile()the transcript path.omp -r <id>resumes from that id. - The gate is inside the module. omp discovers configuration from
~/.omp,~/.claude,~/.codexand~/.geminiwholesale, and there is no per-harness registration to key on, so a global install is loaded by every omp run on the machine. Outside lich the factory subscribes to nothing at all. inputis interactive-only. It fires when the user submits a prompt, which is what lich spawns — but a-prun has noinputat all, soturn_startcarriesbusythere. It is also the recovery the script harnesses get fromPostToolUse: every turn passes through it.- Nothing reports
waiting. omp raisestool_approval_requestedwhen it asks to run something, but that was not observed on a real run here (the measured runs never reached an approval), and an unmeasured event name is a report that silently never fires. Until it is measured, an omp card shows the spinner while the agent waits on you. - Nothing reports
idleeither. The event that would say "the CLI has left" is the process exiting with this module inside it. Like a Codex or an opencode card, an omp card holds its last indicator until lich respawns its terminal. - Never awaited. omp awaits its extension handlers, so a report that blocked
would sit in front of the agent's next step. Every
fetchis fired and dropped, with a 1s timeout, and its failure is swallowed — the module's version of "always exit 0". Handlers are synchronous and return nothing:tool_callreturning{block: true}would refuse the user's tool call. - Install is a file plus a line. Drop the module anywhere and name it in
~/.omp/agent/config.ymlunderextensions:, or drop it straight into~/.omp/agent/extensions/, which omp scans without being told. Both were measured; there is no manifest and no marketplace entry of omp's own. - omp reads Claude Code plugins, but not their hooks. It loads
~/.claude/plugins/cache/and takes skills, slash commands, custom tools and MCP servers from them — honouring.omp-plugin/plugin.jsonbefore.claude-plugin/plugin.json.hooks/hooks.jsonis read by nothing there, so installing this repository as a Claude plugin gives an omp session the skills and none of the reports. - MCP has no command-line flag. omp reads Claude-Desktop-shaped
{"mcpServers": …}from~/.omp/agent/mcp.json(and.omp/mcp.json,<cwd>/.mcp.json), expanding${VAR}and${VAR:-default}from the process environment — so a session token can ride in on the variables lich already injects. The--configoverlay does not take MCP servers.
- One event exists:
PreToolUse. Crush's hooks are Claude Code-compatible down to the stdin payload and the exit codes, so the scripts run unchanged — but with nothing that fires at the end of a turn, only two of the four reports can be honest. Crush reports its session id and refreshes git status; it shows no spinner, no check, no bell, and keeps the card's own name. busyis deliberately not registered. Abusywith noStopbehind it would pin a spinner to the card until the next turn — wrong for longer than it is right. The day Crush ships an end-of-turn event,report-state.shandreport-tool.share already here.- The session id arrives late. It rides the first tool call, because that is the first event there is. A conversation that answers without a tool never reports one.
- The touched refresh runs one tool early, for the same reason:
PreToolUsefires before the write, so the immediate git fetch sees the tree the tool has not changed yet. What it front-runs in practice is the previous tool's write. - Registration is a hand-merge. Crush has no plugin system: its hooks live in
the user's own
crush.json, andcommandis resolved against the working directory, not the config file — so a global install needs an absolute path.hooks/crush-hooks.jsonis that block with<lich-plugin>where the clone's path goes. - Tool names are lower case:
bash,edit,write,multiedit,view,ls,grep,glob,mcp_*.viewis the read tool — the matcher for the touched report lists the writers only.
Antigravity is the one harness whose packaging the plugin has to satisfy as
well as its events. Everything below comes off the guide the CLI itself ships
(~/.gemini/antigravity-cli/builtin/skills/agy-customizations/docs/) and a run
against a stub listener — not off a name that reads plausibly.
- A plugin is a directory, and both its files are named for it. Antigravity
discovers
plugins/<name>/plugin.jsonunder a customization root and loads thehooks.json,skills/andrules/sitting beside it. Neither name is configurable: ahooks/<harness>-hooks.jsonnamed the way every other registration here is would be discovered by nothing at all. That is why the manifest and the registration are the two files in this repository that live at the root rather than in a namespaced folder — the root is the plugin directory once the clone is installed as one, andskills/is picked up from there for free. - The registration is keyed by hook name, not by
hooks. Every top-level key in ahooks.jsonis one integration's name, so several merge into one file; lich's islich.PreToolUseandPostToolUsegroup handlers behind amatcher;PreInvocationandStopare flat lists of handlers. - Commands are relative to the plugin root. See above — no plugin-root variable exists.
- A hook answers on stdout, and the answer is acted on. Antigravity reads a
JSON object back:
PreToolUserequires adecision, andStoptakes one too, wherecontinuerefuses to let the loop end. Silence is not the neutral answer it is on every other harness, so the registration appends the verdict the event asks for —{"decision":"allow"}before a tool,{"decision":""}onStop,{}elsewhere. The scripts stay silent, because on Codex their stdout would answer a permission request instead. - Payloads are camelCase (protojson):
conversationIdis the session id,workspacePaths[0]the session's directory,transcriptPaththe JSONL log. Tool calls arrive astoolCall.namewith PascalCase arguments undertoolCall.args(CommandLine,TargetFile,AbsolutePath,Query,Url). - The title is the first
USER_INPUTline of the transcript, unwrapped from its<USER_REQUEST>tags — the same shape Codex's first user message has, soreport-title.shreads it in the same pass. PreInvocationfires before every model call, not once per turn. Session id andbusytherefore go out several times a turn. Both are idempotent, so the card is right either way, and it is what makesbusycheap enough to leave on the same event as the session id. The title rides it too — this is Antigravity's earliest event, and the only one beforeStopthat is not matched to write tools — and pays for the repetition withonce, which stops it re-reading the transcript after lich has taken a title.- Tool names can only be measured, never derived. The CLI's own guide says
a tool name is its step type lowercased with the
CORTEX_STEP_TYPE_prefix dropped — and that is not what arrives: the enum'sMCP_TOOLreaches a hook ascall_mcp_tool, so something maps names on top of it. What a real turn sends isrun_command,write_to_file,replace_file_content,view_file,list_dirandcall_mcp_tool, with PascalCase arguments (CommandLine,TargetFile,AbsolutePath,DirectoryPath,ServerName/ToolName). A matcher built from the enum instead matches nothing at all, silently — which is how the git-status refresh can stop firing on a write with the suite still green. - Every MCP tool arrives as the one tool
call_mcp_tool, the server and the tool it called being arguments.detail.jqreads them, so the card can tell two MCP calls apart — lich's own tools reach a session this way. - Not reported:
waitingandidle. No event has been measured for either.PermissionRequesthas no counterpart here —PreToolUsecould gate a call, but a hook that answersaskto ring a bell would be changing the turn rather than observing it.Stopends one execution loop, not the conversation, so it isdoneand neveridle.PostInvocationexists and is unregistered: nothing is left for it to say. - The provider id is
antigravity, registered in lich and enumerated bytests/fixtures/session-start.jsonllike every other one. It landed there first: the contract moves in lich — prose, then fixtures, then the endpoint — and only then here.
- Confirm the harness exposes the session lifecycle to something it will run or load, and that it names the session id.
- Add its manifest and marketplace file if it has them, and a hook-registration file mapping its events onto the existing scripts. Take the filenames, the event names, the payload fields and the tool names a matcher lists from the harness itself — its own docs, its binary, a run against a stub — never from what they resemble on a harness already supported here. Every one of those fails the same silent way: a hook that does not run reports nothing to disagree with.
- Answer the harness on the terms it addresses the command with. Two things
differ per harness and neither has a default worth assuming: how the command
finds the script (
$CLAUDE_PLUGIN_ROOT, a placeholder, or a working directory and relative paths), and what it expects back (silence, an exit code, or a JSON verdict on stdout). Run its registration in the suite the way that harness runs it — a test that exports what the harness does not is a test that proves nothing. - Register the provider id in lich before the client reports it: prose, then
fixtures, then the endpoint, then here. A fixture is never edited to make a
run green — until lich accepts the id, declare it in
PENDING_UPSTREAM(tests/contract.mjs), which fails the moment a refreshed fixture registers it. - Only write a new script if a report cannot be derived from what the existing
ones read — extend one instead (
report-title.shreads three transcript formats). A harness that takes no commands at all is the one case for a client of its own, which is whatopencode/lich.jsis. - Register only the reports the harness can actually close. A state nothing can end is worse on a card than no state.
- Add the provider's column to each doc in this directory.