Client side of the mod-control contract (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, through lich control on the command
line or the control_session MCP tool an agent calls: start a turn with a
prompt, stop the running turn, override the model or the effort of every
request, and run one of its slash commands; and ask it a side question it
answers without stopping, through lich ask or the ask_session MCP tool. 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, registered by hooks/lich.js (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. Claude Code takes one entry there, so the
entry is hooks/lich.js, which registers this module and
agent-cards. lich refuses to queue a command for a
session whose mod is not polling, so on every other harness lich control and
control_session fail at once, naming what to fix, instead of waiting on
nothing.
| command | applied with | ack ok: true once |
|---|---|---|
prompt |
$.prompt.submit({ text }), or with notification $.prompt.submit({ text: <task-notification>, asUser: true }) |
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 |
command |
$.command.run({ command: name, args }) |
it ran, once the session was idle |
ask |
$.model.fork({ prompt }), the question behind a preamble |
the fork answered; answer carries it |
GET /mod/commands?token=…&session_id=… to fetch, POST /mod/acks with
{"session_id", "id", "kind", "ok", "error"?, "answer"?} to ack, X-Lich-Plugin on both.
- Outside lich it never polls. It reads the three variables in
session.startand stops there when one is missing. Itsturn.stephook still runs on every request and passes it on unchanged. - Only an interactive session polls. A
claude -pwith 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.startsays 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
promptresolves 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
promptor a slash command. The prompt is submitted in its place and acked when its turn starts, and acommandis acked once Claude Code ran it, which is only when the session is idle. What comes after either runs at once: anabortheld behind a prompt, or a/compactqueued during a turn, would reach the running turn only after that turn ended, and behind a prompt it would then cancel the prompt's own turn instead. Amodeloreffortsent meanwhile likewise applies to the running turn's next request. - An
askwaits on nothing and nothing waits on it. A fork runs beside the turn and can take over a minute (measured at 109 seconds for a long answer on 2.1.289), so it starts the moment its poll returns and acks whenever it answers, outside the order the other commands keep. Several run at once. - A prompt with a
notificationreads like a background agent's completion. It is submitted as the user in the shape Claude Code gives its own:<task-notification>with<status>,<summary>and the prompt's text as<result>, no task id. Claude Code shows the summary as one dim line, and the model reads the text and starts its turn (measured on 2.1.289). Status and summary are XML-escaped, which Claude Code decodes for the line; the text is not, because the model reads it as written and an escaped&&would reach it as&&. - The question goes behind a preamble. It says the question is a side one, that the answer stays out of the conversation, and that tools are unavailable. Without it, a fork made mid-turn reaches for a tool, is refused, and answers in a second request: twice the latency and the uncached tokens (measured on 2.1.289).
- An answer is cut at 16,000 UTF-16 units, with
\n[truncated]after it, which keeps the ack under lich's 64 KiB body limit. A fork that does not answer acksok: falsewith its reason:nothing-to-fork,api-error <status> <kind>,empty-replyoraborted. - 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 nextpromptopened. An ack that cannot be sent is dropped, and the next command runs. - An effort outside
low,medium,high,xhighandmaxis refused (unknown effort). Claude Code would take it and fail the hook only at the next model request, long after anokack. A model name has no such check before a request uses it, so it is acked as set. - A
404stops polling for the session, a network error or a5xxbacks off 1 second doubling to 10, and a200resets 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.
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 validatefails onsession.start, but a session loads the plugin and skips the module. - 2.1.200 ignores the
moduleskey.
On every one of them the scripts keep reporting as before. What an older Claude
Code loses is the controls: lich control and control_session refuse the
session, naming what to fix. 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.
- 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. - A slash command that opens a dialog never acks until someone closes it.
Measured on 2.1.288 and 2.1.289:
/costholds Claude Code's command queue until Esc is pressed in the session's terminal, and every later slash command waits behind it.abort,modelandeffortstill apply, since the mod does not wait on acommand, and a dialog opens only on an idle session, so there is no turn behind it to abort. /modeland/effortare refused by lich, not by the mod. Run through a mod, Claude Code saves what they set as the default for every new session; themodelandeffortcommands are the per-session route.- A fork sees the conversation as of the session's last finished model response. A reply being written, a tool call running, or a prompt whose first reply is still being written is not in it. Measured on 2.1.289.
- A fork cannot be cancelled, and survives an Esc on the turn. It takes no signal; measured on 2.1.289, it answered in full after the turn it ran beside was interrupted. A module reload is what kills one, and then nothing is acked: lich's wait runs out.
- A fork's tokens are not in the transcript, so nothing that reads cost
from it counts an
ask.