Client side of the session-state contract — the contract (transport, endpoint, payload, accepted values) is defined in the lich repository; this plugin only implements it.
Reports the session's processing state so the lich card shows a spinner while the agent works, a check when the turn ends, and a bell (plus an actionable toast) when the agent is blocked on the user.
| script | state | Claude Code hook | Codex hook | Antigravity hook | opencode event | omp event |
|---|---|---|---|---|---|---|
report-state.sh busy |
busy |
UserPromptSubmit |
UserPromptSubmit |
PreInvocation |
session.status (busy) |
input |
report-tool.sh |
busy + tool |
PreToolUse |
PreToolUse |
PreToolUse |
tool.execute.before |
tool_call |
report-state.sh busy |
busy |
PostToolUse |
PostToolUse |
— | session.status (busy) |
turn_start |
report-state.sh done |
done |
Stop |
Stop |
Stop |
session.status (idle) |
session_stop |
report-state.sh waiting |
waiting+reason |
Notification |
PermissionRequest |
— (not measured) | any *.asked |
— (not measured) |
report-state.sh idle |
idle |
SessionEnd |
— (never fires) | — (never fires) | — (never fires) | — (never fires) |
mod-compacting.js |
compacting |
session.compact (mod) |
— | — | — | — |
opencode and omp run no scripts: opencode/lich.js and omp/lich.js send the
same payloads off the events their harness hands a loaded module. opencode's
session.status carries the state rather than implying it — idle there is the
turn ending, which is this contract's done. omp implies it like the script
harnesses do, and its turn_start is the PostToolUse → busy of that column:
every turn passes through it, including the ones no input precedes. Crush is
absent from the table on purpose: its only event is PreToolUse, and a busy
nothing can end would pin a spinner to a card. See providers.md.
Every report names the conversation it comes from in
provider_session_id, the id session-start reports: an
agent CLI the session runs as a tool (claude -p, cursor-agent -p) inherits
the LICH_* variables, and lich drops a report naming any conversation but the
one bound to the card (Nested agent CLIs in the contract). The scripts read it
off the stdin payload (.conversationId // .session_id, with
report-session-start.sh's sed fallback when jq is absent), opencode off the
event's sessionID, omp off ctx.sessionManager.getSessionId(), and
mod-compacting.js off $.session.id(). An id the harness did not hand over
is left out, and lich takes the report as before. Because busy, done and
idle now read stdin too, report-state.sh reads it with the one-second bound
curl has: a harness that left the pipe open and unwritten still cannot hold the
turn.
omp reports no waiting. It raises tool_approval_requested when it asks to
run something, but that event was never observed on a real run here, and a name
taken off a type declaration is a report that silently never fires. An omp card
therefore shows a spinner, not a bell, while the agent waits on you — one state
late, never wrong. The event is the first thing to measure when someone can drive
an approval prompt.
The endpoint, the accepted states and the optional fields (tool / detail
on the pre-tool report, reason on the waiting one) are spelled out once, in
lich's contract.
What is below is only what each harness gives this plugin to fill them with.
Notification fires when Claude needs a permission decision or has been idle
waiting for input — both mean "your turn". A later busy or done clears it.
Codex has no Notification; the same meaning arrives as PermissionRequest,
which fires when Codex asks to run a command or write outside its sandbox.
There, the hook's own exit code is an approval decision — exit 2 would deny
the request — so report-state.sh staying silent and always exiting 0 is what
keeps the report an observation instead of an answer.
opencode's waiting is matched by suffix, not by name. It asks the user in
more than one way — a permission and an interactive question, each with a .v2.
spelling alongside — and its event catalogue is not exhaustive: a real run emits
types (server.heartbeat) that its own published schema does not list. So
lich.js reports waiting for any event type ending in .asked rather than for
a list of names. The failure modes are not symmetric: a surplus bell is cleared
by the next status report, which follows a reply within ~100ms, while a name
nobody enumerated leaves a session waiting behind a card that shows a spinner.
Answering (question.replied → session.status busy) and dismissing
(question.rejected → session.status idle) both re-arm the card on their own,
which is why neither is registered here.
A waiting report carries what the agent is blocked on, so the bell on the card
says why rather than only that. It rides waiting alone — lich drops it on
every other state — and it is optional in both directions: the bell has to land
whether or not a reason could be built, so no shape below is ever a reason to
refuse the report.
| harness | where the reason comes from |
|---|---|
| Claude Code | the Notification payload's message |
| Codex | tool_name, qualified with the same detail a busy report would carry |
| opencode | permission, the v2 action, or a question's header |
| omp, Crush | — neither reports waiting at all |
Claude Code is the only harness that hands over a sentence written for a human
("Claude needs your permission to use Bash"), and it writes one for the plain
idle-at-the-prompt nudge too — which is a reason as much as a permission is, so
both go out unchanged and lich still decides which arrived. Codex's
PermissionRequest has no message field at all: its payload is the PreToolUse
envelope plus model, permission_mode and turn_id, so the report is the
tool it is asking about with the words the busy report already puts on the card
behind it — apply_patch: internal/terminal/usage.go. That second half is
detail.jq, shared with report-tool.sh so the two
readings of a tool call cannot drift.
opencode's four .asked events spell it four ways, so lich.js reads it by
field the way it reads a tool call's detail: permission, then the v2 action,
then the first question's header — opencode's own ≤30-character label, built
for exactly this kind of surface — with the full question behind it when a
caller left the header out. A field rule covers the next prompt type too, and
one that carries none of them still reports waiting with nothing attached,
which is the documented degrade rather than a bug.
Nothing here measures the reason's length: lich caps it, and a cap is not a
refusal. Without jq (Windows, usually) the message stays home for the same
reason the detail does — a hand-built body cannot escape arbitrary text — but
Codex's tool name is an identifier and goes out alone, and Codex is the harness
that needs the Windows wrapper.
SessionEnd → idle clears the card's indicator (no spinner/check/bell). It
fires when the session ends or is reset, so a stale state does not linger on a
dead session, and a /clear starts the next session with a clean card. Codex
has no such event — see providers.md — so a Codex card holds its
last indicator until lich respawns its terminal. The registration keeps the
SessionEnd entry: an unknown event name is ignored rather than rejected, so
the report starts working the day Codex adds one.
PostToolUse is the recovery from waiting: answering a permission request,
approving a plan (ExitPlanMode) or answering a question (AskUserQuestion)
does not fire UserPromptSubmit, but the tool that resumes work fires
PostToolUse — so the spinner comes back. It posts busy on every tool call
(loopback, idempotent). Known ceiling: a denied permission where Claude ends
the turn without another tool call stays waiting until Stop. Codex recovers
the same way: an approved request runs the tool it was asking about, and that
tool's PostToolUse re-arms the spinner.
hooks/mod-compacting.js reports compacting while Claude Code compacts the
conversation, so the card says so instead of sitting on done through a manual
/compact (no UserPromptSubmit fires for it) or on a bare spinner through an
automatic one. It is a mod, registered by hooks/lich.js, because it has to
see the compaction end whichever way it ends: session.compact's next(e)
settles when it does, and a settings hook has nothing to match that with.
PostCompact never fires for a compaction that fails, and two do, measured on
Claude Code 2.1.295:
| trigger | when | ends | closed with |
|---|---|---|---|
manual |
/compact, at an idle prompt |
resolves after the summary (~6s), or rejects on Esc ("Request was aborted") | done |
auto |
inside a turn, after UserPromptSubmit, before the model request |
resolves after the summary (~14s), or rejects within ~40ms when the engine gives up ("reactive compaction did not settle ok") and the turn goes on | busy |
auto |
at an idle prompt, before the prompt cache expires ("Compacted while idle", seen on 2.1.300) | resolves after a summary prepared ahead of time | done |
compacting goes out before next(e) is called and the closing state once it
settles, either way; the error, if any, goes on to Claude Code untouched. The
closing state is the turn's, not the trigger's: busy while a turn is open,
whose own Stop reports done, and done when none is. The mod follows the
turn through turn.start and turn.complete, because the trigger cannot tell:
the idle compaction prepares its summary under precompute and applies it as
auto, with no turn open and no Stop to follow. Esc during an automatic compaction did not
abort it on the measured run: the summary finished and the turn ended normally.
The rest follows the other mods:
- Outside lich, or in a
claude -p, it never reports. It reads the three variables in asession.starthook matchingisInteractive: true. - Only
manualandautoare reported, each by a hook matching itstrigger.precomputeruns ahead of time, out of sight, andplugin(a mod's own$.session.compact) was never observed. - A subagent's compaction is left alone. It carries
agentId; the main conversation's does not. That field is read off Claude Code's type declarations, not a payload: no subagent compaction was measured. - The compaction is never held up. Reports go out beside the chain, one at a time so the closing one never lands first, and one that fails is dropped.
Known ceiling: an automatic compaction the engine gives up on still puts
compacting on the card for the ~40ms before busy. With the threshold forced
low (CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=1) that happened on most turns; how
often it happens at the default threshold was not measured.
report-tool.sh rides PreToolUse and reports the same busy, with the tool
the agent is about to run: tool is the harness's own name for it, detail its
own words for what it acts on. lich shows the pair under the session's label and
drops it when the state leaves busy, so this script never has to report an
end.
This is the one hook on the agent's critical path. In both harnesses a
PreToolUse hook exiting 2 blocks the tool call — the client rules (silent,
always exit 0) stop being merely polite here.
What the two harnesses actually send, taken off a real run of each against a stub listener rather than off their documentation:
| Action | Claude Code | Codex | Antigravity | opencode | omp |
|---|---|---|---|---|---|
| run a command | Bash |
Bash |
run_command |
bash |
bash |
| edit a file | Edit / Write |
apply_patch |
write_to_file / replace_file_content |
edit / write |
edit / write |
| read a file | Read |
— (goes through Bash) |
view_file |
read |
read |
| search | Grep / Glob |
— (goes through Bash) |
grep_search |
grep / glob |
grep / glob |
The detail is read by field, never by tool name — command / CommandLine,
then file_path / path / TargetFile, then url / query / Query — one
chain over both spellings, which is what makes one
rule cover both: a Codex shell call arrives as Bash carrying the same
command string Claude Code sends. opencode spells the same fields in camel
case (filePath) and hands over paths already relative to the session, so
lich.js reads the same list and skips the shortening below. omp's built-ins
spell it path (read, write, edit, glob), command (bash) or pattern
(grep) — also already relative — and omp/lich.js keeps the camel-case and
query / url spellings after them, for the tools omp does not define itself:
an extension's or an MCP server's, named however their author named them.
Two shapes need more than the plain rule, and both live in
detail.jq — one copy, because the waiting report reads
the same tool call for the second half of its reason:
apply_patchpasses the whole patch as its command, so the plain rule would put*** Begin Patchon the card. The file its*** Add/Update/Delete File:line names is the readable half; a patch touching several shows the first, and one naming none sends no detail at all.- A path is shortened against the payload's
cwd, because both harnesses report absolute paths and a lich card is 240px wide. Outside that directory only the file name survives. Commands are never shortened — a leading slash there is a binary, not a path worth cutting.
Without jq (Windows, usually) the tool name still goes out, read with sed
like the session id is; the detail does not, because a hand-built body cannot
escape arbitrary text.
Claude Code fires the session's own PreToolUse and PostToolUse for every
tool a subagent runs, with the session's session_id and an agent_id the main
conversation's payloads never carry (measured on 2.1.302). Some of those tools
run after the session's Stop: a background agent still working (measured),
and most likely the fork Claude Code starts right after Stop to write the next
prompt suggestion, which is a subagent (its SubagentStop follows every Stop
by ~1.3s) and was seen once putting a card back on busy 2s after Stop, with
a command no transcript holds. Reported, each put a finished card back on a
spinner naming a tool nobody ran, until the next turn.
So report-tool.sh, and report-state.sh reporting busy, drop a payload that
carries agent_id. Inside a turn this changes nothing on the card but its tool
line, which keeps naming the Agent call while the subagent works. The other
states are left alone: a turn still opens, blocks and ends on the session's own
events. No other harness was seen sending the field. Without jq the match is
not anchored to the top level, so a tool input carrying its own agent_id goes
unreported too.