Claude Code plugin for the lich harness

The agent side of the lich integration. lich is a harness that orchestrates agent CLI sessions; this companion plugin gives it eyes and hands inside each session. It installs on Claude Code, OpenAI Codex, Antigravity CLI, opencode, omp (oh-my-pi) and Crush from this same repository. Every hook implements a contract that is canonical in the lich repository (docs/hooks/ there); the docs in docs/ here point at each contract and describe the client side:
busy/done on the session card (UserPromptSubmit/PreInvocation/Stop)SessionStart/PreInvocation)Stop)PostToolUse)The four are what Claude Code, Codex, Antigravity, opencode and omp report. Crush reports two of them — its session id and the git-status refresh — because PreToolUse is the only event it has, and a state nothing can end is worse on a card than no state. omp reports all four minus the bell: no event of its own for "your turn" has been measured yet, so its card shows a spinner while the agent waits on you. docs/providers.md has the event mapping per harness.
On opencode it also carries the other direction: the seven operations lich offers a session for driving the sessions beside it — list, send, wait, reply, open, close, and read the worktrees — as tools of opencode's own. Claude Code and Codex get those as MCP tools lich registers when it spawns them; opencode cannot be told about a server on its command line, so they are defined in the module instead. 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 given with lich control or an agent's control_session tool (start a turn with a prompt, stop the running turn, override the model or the effort, run a slash command), answers a side question asked with lich ask or ask_session from a fork of the session's conversation without stopping its turn, 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 lich control refuses its session, naming what to fix. 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.
The same mod system turns a general-purpose subagent Claude Code starts into a lich session: a card you can watch and steer, working in the asking session's checkout as a native subagent does, or in a worktree of its own when the subagent asks for isolation. The Agent call returns at once as a background subagent, and the session's full report arrives at the asking session's prompt on its own as a [lich] note: the worker's last message is its report, as a native subagent's is (docs/mod-answer.md). Explore, Plan and other agent types stay inside Claude Code. docs/agent-cards.md has the rules and what happens when lich cannot be reached. Since that subagent shares the checkout, an edit to a file another lich session edited in the last ten minutes tells the model which session it was and how to reach it (docs/edit-guard.md). Asked to run a built-in slash command such as /compact, the session runs it on itself once its turn ends (docs/self-command.md).
It also ships skills for the parts of lich you configure from inside a session:
/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.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 fixed, must sit at the root)
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 (merged into crush.json by hand)
hooks/report-state.sh # session-state hook
hooks/report-tool.sh # session-state hook: the tool a turn is running
hooks/report-session-start.sh # session-start hook
hooks/report-title.sh # session-title hook
hooks/report-touched.sh # session-touched hook
hooks/lich.js # the Claude Code mods' entry, which registers the seven below
hooks/mod-control.js # mod-control client, a Claude Code mod
hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod
hooks/mod-usage.js # mod-usage client, a Claude Code mod
hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod
hooks/worker-answer.js # mod-answer client: a lich worker's last message answers its task, a Claude Code mod
hooks/mod-status.js # mod-status client: the session's errands in the prompt footer, a Claude Code mod
hooks/self-command.js # runs a built-in slash command the user asked the session for, 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
skills/theme/ # theme skill: SKILL.md, template.json, validate.mjs
tests/ # hook payloads asserted against lich's fixtures
One set of hook scripts serves Claude Code, Codex, Antigravity and Crush — they live in hooks/. Claude Code and Codex reach them through $CLAUDE_PLUGIN_ROOT/hooks/<script>, a variable Codex sets too; Crush ships a placeholder the user replaces. Antigravity sets no such variable — it runs a hook with the working directory set to the plugin root, so its registration spells hooks/<script> relative to that, and it is the reason plugin.json and hooks.json sit at the root: Antigravity fixes both names and looks for them nowhere else. opencode and omp run no commands: in both, what gets loaded is a JavaScript module, so each has a single-file client — opencode/lich.js and omp/lich.js — sending the same payloads to the same endpoints. docs/providers.md maps the layout and every harness's event names.
Add this repository as a marketplace and install the plugin:
claude plugin marketplace add omartelo/lich-plugin
claude plugin install lich@lich-plugin
The same works inside a session with /plugin marketplace add omartelo/lich-plugin followed by /plugin install lich@lich-plugin.
codex plugin marketplace add omartelo/lich-plugin
codex plugin add lich@lich-plugin
Then start a new session and run /hooks to review and trust the plugin's hooks — Codex does not run a plugin's hooks until you do, so until then the plugin is installed but silent. Hooks themselves are stable and on by default in current Codex; on older versions set [features] hooks = true in ~/.codex/config.toml.
A plugin is a directory holding a plugin.json, under a plugins/ folder in a customization root. Symlink the clone into one — globally, or into a project's .agents/ to share it with the team:
mkdir -p ~/.gemini/config/plugins
ln -s "$PWD" ~/.gemini/config/plugins/lich
That is the whole install: the hooks come from hooks.json beside the manifest, and the theme skill from skills/. agy plugin validate . checks the bundle from the clone before you link it, agy plugin list shows it afterwards, and agy plugin disable lich turns it off again.
Without the plugin, hooks.json can be merged into ~/.gemini/config/hooks.json (global) or .agents/hooks.json (per project) — but a hook's working directory is the folder holding the hooks.json that declared it, so replace each hooks/ prefix with the absolute path to this clone.
opencode has no marketplace: a plugin is a file in its plugin directory, so dropping it there is the install.
mkdir -p ~/.config/opencode/plugin
curl -fsSL -o ~/.config/opencode/plugin/lich.js \
https://raw.githubusercontent.com/omartelo/lich-plugin/main/opencode/lich.js
Per project instead of globally, use .opencode/plugin/lich.js. Updating means fetching the file again.
omp has no marketplace of its own either, and its --hook and --extension flags are one list of modules. It scans ~/.omp/agent/extensions/ without being told, so dropping the file there is the install:
mkdir -p ~/.omp/agent/extensions
curl -fsSL -o ~/.omp/agent/extensions/lich.js \
https://raw.githubusercontent.com/omartelo/lich-plugin/main/omp/lich.js
To keep it somewhere else, name the path instead — omp config set extensions '["/path/to/lich.js"]', which writes extensions: in ~/.omp/agent/config.yml — or pass --hook /path/to/lich.js for a single run. Per project, .omp/extensions/lich.js works the same way. Updating means fetching the file again.
Crush has no plugin system either: its hooks live in your own crush.json. Clone this repository, then merge hooks/crush-hooks.json into ~/.config/crush/crush.json (global) or the project's crush.json, replacing <lich-plugin> with the absolute path of the clone:
{
"hooks": {
"PreToolUse": [
{ "name": "lich session id", "command": "/home/you/src/lich-plugin/hooks/report-session-start.sh crush", "timeout": 5 },
{ "name": "lich git-status refresh", "matcher": "^(edit|write|multiedit|bash)$", "command": "/home/you/src/lich-plugin/hooks/report-touched.sh", "timeout": 5 }
]
}
}
command is resolved against the working directory, not the config file, so a global install needs the absolute path. Crush reports no session state and no title — see docs/providers.md for why.
git clone https://github.com/omartelo/lich-plugin.git
Then either load it for a single Claude Code session:
claude --plugin-dir ./lich-plugin
or register the clone as a local marketplace for a persistent install:
claude plugin marketplace add ./lich-plugin
claude plugin install lich@lich-plugin
codex plugin marketplace add ./lich-plugin
codex plugin add lich@lich-plugin
Outside lich (env vars absent) the hooks are a no-op — the plugin is safe to install globally.
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: 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.
hooks/lich.js 27 lines1// The one module hooks/hooks.json names under `modules`: Claude Code takes a
2// single entry there, so each mod lives in a file of its own and is registered
3// from here.
4
5import { register as registerAgentCards } from "./agent-cards.js"
6import { register as registerCompacting } from "./mod-compacting.js"
7import { register as registerEditGuard } from "./edit-guard.js"
8import { register as registerModControl } from "./mod-control.js"
9import { register as registerModStatus } from "./mod-status.js"
10import { register as registerModUsage } from "./mod-usage.js"
11import { register as registerSelfCommand } from "./self-command.js"
12import { register as registerStatusLine } from "./status-line.js"
13import { register as registerWorkerAnswer } from "./worker-answer.js"
14
15/** @param {import('claude-code').On} on */
16export function register(on) {
17 registerModControl(on)
18 registerAgentCards(on)
19 registerModUsage(on)
20 registerEditGuard(on)
21 registerWorkerAnswer(on)
22 registerModStatus(on)
23 registerCompacting(on)
24 registerSelfCommand(on)
25 registerStatusLine(on)
26}
27hooks/agent-cards.js 482 lines1// Runs a general-purpose subagent Claude Code starts as a lich session: a card
2// the user can watch and steer, instead of an agent hidden inside this session.
3// It works in this session's checkout, as a native subagent does, or in a
4// worktree of its own when the call asks for `isolation: "worktree"`.
5// Docs: ../docs/agent-cards.md. There is no HTTP contract behind it: it drives
6// the lich CLI (`$LICH_BIN open`), whose output and exit codes are docs/cli.md
7// in the lich repository.
8//
9// Once lich has the task the Agent call answers at once, as a background
10// subagent does, and nothing here waits on the worker: `--subagent` makes lich
11// type the worker's whole report at this session's prompt as a [lich] note.
12// While workers run, the status line counts them from `$LICH_BIN sessions
13// --json`, and Claude Code's TaskStop on one is taken here and done by lich.
14//
15// Claude Code fails a tool.call hook open: one that throws, overruns its budget
16// or lets a `$.process.run` reject is skipped and the native agent runs in its
17// place (measured on 2.1.289). Every failure is therefore caught here and
18// decided: before lich has the task, the native agent runs with a toast saying
19// why; once lich may have it, the call is denied, because a native agent beside
20// the worker would do the work twice.
21//
22// Every function that takes `$` is declared at the top level: the loader
23// refuses a module that hands `$` to a nested function or keeps it in a variable.
24
25import { setStatusPart } from "./status-line.js"
26
27// lich's `promptLimit` (internal/relay). lich checks it only after the session
28// was opened, so a task over it would leave an empty card behind.
29const PROMPT_LIMIT_BYTES = 8192
30
31// `lich open --prompt` waits up to `openCall` (60s) for the session and then
32// delivers like a send: `deliverWait` (20s) plus `callSlack` (30s), all in
33// internal/cli/cli.go. This bound only has to outlast lich's own.
34const OPEN_TIMEOUT_MS = 120000
35
36// The value lich writes into a session's environment to keep its subagents
37// native: when "Subagents as lich sessions" is off in Settings › Providers ›
38// Claude Code, and in a worker at lich's own depth limit for cards opened from
39// cards. A lich older than LICH_SUBAGENT_DEPTH writes it into every worker.
40const CARDS_OFF = "off"
41
42// The bounds lich's own worktree dialog slugs a typed name with
43// (`toBranchName`, frontend/src/lib/git/branch-name.ts there), so a branch
44// opened here reads like one a person named.
45const MIN_WORDS = 2
46const MAX_WORDS = 5
47const MIN_CHARS = 10
48const MAX_CHARS = 40
49const FALLBACK_SLUG = "agent"
50// Marks a worker's branch. Under a lich older than LICH_SUBAGENT_DEPTH, the mod
51// inside an isolated worker reads it to leave its own subagents native instead
52// of opening cards from cards; a lich that sets the depth decides that alone,
53// through LICH_SUBAGENT_CARDS.
54const WORKER_BRANCH_PREFIX = "subagent/"
55
56// How often the status line asks lich which workers still run. Claude Code's
57// own "N agents" hint moves as each agent ends; a worker's end reaches lich, not
58// this session, so it is read from `lich sessions --json`, one short run of the
59// CLI per period while any worker runs.
60const WORKER_POLL_MS = 5000
61
62// The end of the Agent call's id makes each branch new: lich checks out an
63// existing branch as it stands, so a bare slug could land on someone's work.
64const SUFFIX_CHARS = 4
65
66/**
67 * @typedef {import('claude-code').EngineInterface} Engine
68 * @typedef {{ ticket: string, target: string, status: string, answer: string }} Report
69 * @typedef {{ label: string, name: string, path: string, delivery?: Report }} Opened
70 * @typedef {{ label: string, name: string, description: string, shared: boolean }} Worker
71 * @typedef {{ interactive: boolean, workers: Map<string, Worker>, watching: boolean, lich: string }} State
72 * @typedef {{ label: string, name: string, state: string }} Peer
73 * @typedef {{ tool: "Agent", tool_use_id: string, agentId?: string, description: string, prompt: string,
74 * subagent_type?: string, model?: string, team_name?: string, isolation?: string }} AgentCall
75 */
76
77/** @param {unknown} error */
78function messageOf(error) {
79 return error instanceof Error ? error.message : String(error)
80}
81
82/**
83 * A call the model made on the main loop for a general-purpose agent, in an
84 * interactive session. Another plugin's `$.agent.spawn`, a subagent's own
85 * call, a typed agent and a teammate keep their native behaviour.
86 *
87 * @param {AgentCall} e
88 * @param {{ origin: { plugin: string } }} next
89 * @param {State} state
90 */
91function isDelegable(e, next, state) {
92 const isModelOnMainLoop = next.origin.plugin === "engine" && e.agentId === undefined
93 const isGeneralPurpose = e.subagent_type === undefined || e.subagent_type === "general-purpose"
94 const isLocal = e.team_name === undefined && e.isolation !== "remote"
95 return state.interactive && isModelOnMainLoop && isGeneralPurpose && isLocal
96}
97
98/** @param {string} text */
99function slugOf(text) {
100 const words = text
101 .toLowerCase()
102 .replace(/[^\p{L}\p{N}]+/gu, " ")
103 .trim()
104 .split(/\s+/)
105 .filter(Boolean)
106 /** @type {string[]} */
107 const picked = []
108 let length = 0
109 for (const word of words) {
110 const grown = length === 0 ? word.length : length + 1 + word.length
111 const hasMinimum = picked.length >= MIN_WORDS && length >= MIN_CHARS
112 if (picked.length >= MAX_WORDS || (grown > MAX_CHARS && hasMinimum)) break
113 picked.push(word)
114 length = grown
115 }
116 return picked.join("-").slice(0, MAX_CHARS).replace(/-+$/, "")
117}
118
119/** @param {AgentCall} e */
120function branchFor(e) {
121 const suffix = e.tool_use_id.replace(/[^A-Za-z0-9]/g, "").slice(-SUFFIX_CHARS).toLowerCase()
122 return `${WORKER_BRANCH_PREFIX}${slugOf(e.description ?? "") || FALLBACK_SLUG}-${suffix}`
123}
124
125/**
126 * The branch the asking session is on, so the worker starts from its work and
127 * not from the main checkout's branch; "" on a detached HEAD or when git
128 * cannot answer, which leaves the base to lich.
129 *
130 * @param {Engine} $
131 */
132async function currentBranch($) {
133 try {
134 const { exitCode, stdout } = await $.process.run(["git", "branch", "--show-current"])
135 return exitCode === 0 ? stdout.trim() : ""
136 } catch {
137 // A rejection here would fail the whole hook open; lich's default base is the answer instead.
138 return ""
139 }
140}
141
142/** @param {string} stdout */
143function parsedOrUndefined(stdout) {
144 try {
145 return JSON.parse(stdout)
146 } catch {
147 return undefined
148 }
149}
150
151/**
152 * Opens the worker and hands it the task. Throws when the task did not reach
153 * it, with lich's own reason.
154 *
155 * @param {Engine} $
156 * @param {string} lich
157 * @param {AgentCall} e
158 * @param {string} branch "" opens the worker in this session's checkout
159 * @param {string} base
160 * @returns {Promise<Opened & { delivery: Report }>}
161 */
162async function openWorker($, lich, e, branch, base) {
163 const argv = [
164 lich, "open", "--kind", "claude", "--subagent",
165 ...(branch ? ["--worktree", branch] : []),
166 ...(base ? ["--base", base] : []),
167 ...(e.model ? ["--model", e.model] : []),
168 "--prompt", e.prompt, "--json",
169 ]
170 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: OPEN_TIMEOUT_MS })
171 /** @type {Opened | undefined} */
172 const opened = parsedOrUndefined(stdout)
173 const reason = stderr.trim() || `lich open exited ${exitCode}`
174 if (opened === undefined) throw new Error(reason)
175 if (opened.delivery === undefined) throw new Error(`the task did not reach "${opened.label}": ${reason}`)
176 return /** @type {Opened & { delivery: Report }} */ (opened)
177}
178
179/**
180 * The Agent tool's `async_launched` arm, plus what the model reads after it.
181 * Claude Code's own text for this arm promises a task notification and points
182 * at SendMessage, and neither reaches a lich session, so `context` says how the
183 * report really comes back. `outputFile` is "", Claude Code's own value for an
184 * agent with no output file: without `canReadOutputFile` the model is never
185 * shown it.
186 *
187 * @param {AgentCall} e
188 * @param {Opened} opened
189 * @param {string} branch
190 */
191function backgrounded(e, opened, branch) {
192 const where = branch
193 ? `on branch ${branch} in ${opened.path}, not in this checkout`
194 : `in this same checkout, ${opened.path}, and edits its files as you do`
195 return {
196 result: {
197 status: /** @type {const} */ ("async_launched"),
198 agentId: opened.name,
199 description: e.description,
200 prompt: e.prompt,
201 outputFile: "",
202 },
203 context: [
204 `The agent runs as the lich session "${opened.label}", ${where}. ` +
205 `Its full report arrives at this prompt on its own as a [lich] note, not as a task notification, so there ` +
206 `is no need to call wait_for_answer (it still works). SendMessage cannot reach that session; ` +
207 `send_to_session or lich send can.`,
208 ],
209 }
210}
211
212/**
213 * The Agent tool's `completed` arm, which Claude Code checks a hook's result
214 * against. The worker's tokens and tools are its own session's, so none are
215 * counted here; a worker in this checkout names no worktree, as a native agent
216 * without isolation does.
217 *
218 * @param {AgentCall} e
219 * @param {Opened} opened
220 * @param {string} branch
221 * @param {string} text
222 * @param {number} durationMs
223 */
224function completed(e, opened, branch, text, durationMs) {
225 return {
226 status: /** @type {const} */ ("completed"),
227 agentId: opened.name,
228 content: [{ type: /** @type {const} */ ("text"), text }],
229 totalToolUseCount: 0,
230 totalDurationMs: durationMs,
231 totalTokens: 0,
232 usage: {
233 input_tokens: 0,
234 output_tokens: 0,
235 cache_creation_input_tokens: null,
236 cache_read_input_tokens: null,
237 server_tool_use: null,
238 service_tier: null,
239 cache_creation: null,
240 },
241 prompt: e.prompt,
242 ...(branch ? { worktreePath: opened.path, worktreeBranch: branch } : {}),
243 }
244}
245
246/**
247 * @param {AgentCall} e
248 * @param {Opened} opened
249 * @param {string} branch
250 * @param {Report} report
251 * @param {number} durationMs
252 */
253function answerFor(e, opened, branch, report, durationMs) {
254 switch (report.status) {
255 // A worker whose turn ended unanswered is usually still at it: it left a
256 // command running in the background and resumes when it finishes, and its
257 // report still arrives as a [lich] note.
258 case "pending":
259 case "unanswered":
260 return backgrounded(e, opened, branch)
261 case "answered": {
262 const where =
263 (branch
264 ? `The work is on branch ${branch} in ${opened.path} (lich session "${opened.label}"), not in this checkout. `
265 : `The work is in this same checkout, ${opened.path} (lich session "${opened.label}"). `) +
266 `Reach that session with send_to_session or lich send, not SendMessage.`
267 return { result: completed(e, opened, branch, `${report.answer}\n\n${where}`, durationMs) }
268 }
269 case "unread":
270 case "undelivered":
271 return { deny: `the task never reached "${opened.label}" (${report.status}): open its card.` }
272 default:
273 return { deny: `lich answered "${report.status}" about "${opened.label}"${branch ? `, on branch ${branch}` : ""}: open its card.` }
274 }
275}
276
277/**
278 * A worker lich opened with `--subagent`, `depth` levels below a session nobody
279 * opened as one.
280 *
281 * @param {string | undefined} depth LICH_SUBAGENT_DEPTH
282 */
283function isWorkerDepth(depth) {
284 const levels = Number(depth)
285 return Number.isInteger(levels) && levels > 0
286}
287
288/**
289 * @param {Engine} $
290 * @param {AgentCall} e
291 * @param {(e: AgentCall) => Promise<unknown>} next
292 * @param {string} reason
293 */
294function runNatively($, e, next, reason) {
295 $.ui.toast(`lich: ran "${e.description}" as a Claude Code subagent: ${reason}`)
296 return next(e)
297}
298
299/**
300 * @param {Engine} $
301 * @param {State} state
302 * @param {AgentCall} e
303 * @param {any} next
304 */
305async function runAsSession($, state, e, next) {
306 if (!isDelegable(e, next, state)) return next(e)
307 const [lich, session, cards, depth] = await Promise.all([
308 $.env.get("LICH_BIN"),
309 $.env.get("LICH_SESSION_ID"),
310 $.env.get("LICH_SUBAGENT_CARDS"),
311 $.env.get("LICH_SUBAGENT_DEPTH"),
312 ])
313 if (!lich || !session) return next(e)
314 if (cards === CARDS_OFF) {
315 if (!isWorkerDepth(depth)) return next(e)
316 return runNatively($, e, next, "this session is itself a lich subagent card, and lich keeps its subagents inside it")
317 }
318 const bytes = new TextEncoder().encode(e.prompt).length
319 if (bytes > PROMPT_LIMIT_BYTES) {
320 return runNatively($, e, next, `the task is ${bytes} bytes, over lich's ${PROMPT_LIMIT_BYTES}`)
321 }
322
323 const isolated = e.isolation === "worktree"
324 const base = isolated ? await currentBranch($) : ""
325 if (depth === undefined && base.startsWith(WORKER_BRANCH_PREFIX)) return next(e)
326 const startedMs = await $.clock.now()
327 const branch = isolated ? branchFor(e) : ""
328 /** @type {Opened & { delivery: Report }} */
329 let opened
330 try {
331 opened = await openWorker($, lich, e, branch, base)
332 } catch (error) {
333 if (next.signal.aborted) {
334 return {
335 deny: `interrupted; a lich session${branch ? ` on branch ${branch}` : ""} may already have the task, and a report it sends arrives here as a [lich] note.`,
336 }
337 }
338 return runNatively($, e, next, messageOf(error))
339 }
340 const answer = answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs)
341 if (opened.delivery.status === "pending") trackWorker($, state, lich, opened, e, branch)
342 return answer
343}
344
345/**
346 * Shows how many workers this session waits on in the footer's lich line, as
347 * Claude Code's own hint does for its background agents, and clears it at none.
348 *
349 * @param {Engine} $
350 * @param {State} state
351 */
352function showWorkers($, state) {
353 const count = state.workers.size
354 setStatusPart("workers", count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`)
355 $.ui.invalidate("ui.render")
356}
357
358/**
359 * Keeps one watch running while any worker does.
360 *
361 * @param {Engine} $
362 * @param {State} state
363 */
364function watchWorkers($, state) {
365 if (state.watching || state.workers.size === 0) return
366 state.watching = true
367 $.clock.after(WORKER_POLL_MS, () => refreshWorkers($, state))
368}
369
370/**
371 * A worker in this checkout still runs while lich lists it: lich closes one
372 * once it reported, and a turn it ended with a command left in the background
373 * is done without being finished. An isolated worker keeps its card after it
374 * reported, so for it a done turn is the end.
375 *
376 * @param {Worker} worker
377 * @param {Peer[]} peers
378 */
379function stillRuns(worker, peers) {
380 const listed = peers.find((p) => p.name === worker.name)
381 return listed !== undefined && (worker.shared || listed.state !== "done")
382}
383
384/**
385 * Drops the workers lich lists as finished. A list lich could not give leaves
386 * the count as it is until the next period.
387 *
388 * @param {Engine} $
389 * @param {State} state
390 */
391async function refreshWorkers($, state) {
392 state.watching = false
393 try {
394 const { exitCode, stdout } = await $.process.run([state.lich, "sessions", "--json"])
395 /** @type {Peer[] | undefined} */
396 const peers = exitCode === 0 ? parsedOrUndefined(stdout) : undefined
397 const before = state.workers.size
398 if (Array.isArray(peers)) {
399 for (const [id, worker] of state.workers) {
400 if (!stillRuns(worker, peers)) state.workers.delete(id)
401 }
402 }
403 if (state.workers.size !== before) showWorkers($, state)
404 } catch {
405 // A rejection here reaches no hook; the next period asks again.
406 }
407 watchWorkers($, state)
408}
409
410/**
411 * @param {Engine} $
412 * @param {State} state
413 * @param {string} lich
414 * @param {Opened} opened
415 * @param {AgentCall} e
416 * @param {string} branch
417 */
418function trackWorker($, state, lich, opened, e, branch) {
419 state.lich = lich
420 state.workers.set(opened.name, { label: opened.label, name: opened.name, description: e.description, shared: !branch })
421 showWorkers($, state)
422 watchWorkers($, state)
423}
424
425/**
426 * Claude Code's TaskStop for a worker this mod opened: Esc in this session
427 * leaves a background agent running, and TaskStop is how the model stops one
428 * (both measured on 2.1.289). A worker in this checkout is closed, having
429 * nothing of its own to keep; an isolated one has its turn stopped, and its
430 * card and worktree stay for the user. Any other task goes to Claude Code.
431 *
432 * @param {Engine} $
433 * @param {State} state
434 * @param {{ tool: "TaskStop", task_id?: string, shell_id?: string }} e
435 * @param {any} next
436 */
437async function stopWorker($, state, e, next) {
438 const id = e.task_id ?? e.shell_id ?? ""
439 const worker = state.workers.get(id)
440 if (worker === undefined) return next(e)
441 const argv = worker.shared
442 ? [state.lich, "close", worker.name]
443 : [state.lich, "control", worker.name, "abort"]
444 let outcome
445 try {
446 outcome = await $.process.run(argv)
447 } catch (error) {
448 return { deny: `lich could not stop "${worker.label}": ${messageOf(error)}` }
449 }
450 if (outcome.exitCode !== 0) {
451 return { deny: `lich could not stop "${worker.label}": ${outcome.stderr.trim() || `lich exited ${outcome.exitCode}`}` }
452 }
453 state.workers.delete(id)
454 showWorkers($, state)
455 return {
456 result: {
457 message: `Successfully stopped task: ${id} (${worker.description})`,
458 task_id: id,
459 task_type: "local_agent",
460 command: worker.description,
461 },
462 }
463}
464
465/** @param {import('claude-code').On} on */
466export function register(on) {
467 /** @type {State} */
468 const state = { interactive: false, workers: new Map(), watching: false, lich: "" }
469
470 // A `claude -p` started from a tool inside a lich session inherits its
471 // variables; its subagents are its own and stay inside it. The matcher is
472 // also what lets this module hook `session.start` beside mod-control.js:
473 // Claude Code refuses one plugin's second hook on an event with no matcher.
474 on("session.start", { isInteractive: true }, ($, e, next) => {
475 state.interactive = true
476 return next(e)
477 })
478
479 on("tool.call", { tool: "Agent" }, ($, e, next) => runAsSession($, state, e, next))
480 on("tool.call", { tool: "TaskStop" }, ($, e, next) => stopWorker($, state, e, next))
481}
482hooks/mod-compacting.js 113 lines1// Reports a Claude Code conversation being compacted to lich, as the
2// session-state contract's `compacting`. Contract: ../docs/session-state.md,
3// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/session-state.md
4//
5// A Claude Code mod, registered by hooks/lich.js. It brackets `session.compact`:
6// `compacting` before the compaction runs, and the state the session goes back
7// to once `next(e)` settles, whether the compaction stood or failed. A manual
8// /compact runs at an idle prompt, so it closes with `done`; an automatic one
9// runs inside a turn, ahead of the model request that follows, so it closes
10// with `busy` and the turn's own Stop reports `done` later.
11//
12// A settings hook could not do this: `PostCompact` never fires for a
13// compaction that fails, and two measured ones do (Claude Code 2.1.295). Esc
14// on a manual /compact rejects `next(e)` with "Request was aborted", and an
15// automatic one the engine gives up on rejects it within ~40ms with "reactive
16// compaction did not settle ok" while the turn goes on.
17//
18// Every function that takes `$` is declared here at the top level: the loader
19// refuses a module that hands `$` to a nested function.
20
21// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
22const PLUGIN_VERSION = "0.19.0"
23
24/**
25 * @typedef {import('claude-code').EngineInterface} Engine
26 * @typedef {import('claude-code').SessionCompactInput} Compaction
27 * @typedef {import('claude-code').SessionCompactResult} Compacted
28 * @typedef {{ base: string, token: string, session: string }} Link
29 * @typedef {'done' | 'busy'} Closing
30 * @typedef {{ link?: Link, sending: Promise<void> }} State
31 */
32
33/**
34 * @param {Engine} $
35 * @returns {Promise<Link | undefined>}
36 */
37async function linkFromEnv($) {
38 const [port, token, session] = await Promise.all([
39 $.env.get("LICH_PORT"),
40 $.env.get("LICH_TOKEN"),
41 $.env.get("LICH_SESSION_ID"),
42 ])
43 if (!port || !token || !session) return undefined
44 return { base: `http://127.0.0.1:${port}`, token, session }
45}
46
47/**
48 * @param {Engine} $
49 * @param {Link} link
50 * @param {'compacting' | Closing} sessionState
51 */
52async function report($, link, sessionState) {
53 try {
54 await $.http.fetch(`${link.base}/hook?token=${link.token}`, {
55 method: "POST",
56 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
57 body: JSON.stringify({ session_id: link.session, state: sessionState }),
58 })
59 } catch {
60 // The contract drops a report that cannot be sent; it never retries one.
61 }
62}
63
64/**
65 * Reports beside the chain, one at a time: lich keeps the latest state, so a
66 * closing report landing before its `compacting` would leave the card stuck.
67 *
68 * @param {Engine} $
69 * @param {State} state
70 * @param {Link} link
71 * @param {'compacting' | Closing} sessionState
72 */
73function queue($, state, link, sessionState) {
74 state.sending = state.sending.then(() => report($, link, sessionState))
75}
76
77/**
78 * @param {Engine} $
79 * @param {State} state
80 * @param {Compaction} e
81 * @param {(e: Compaction) => Promise<Compacted>} next
82 * @param {Closing} closing
83 */
84async function bracket($, state, e, next, closing) {
85 const link = state.link
86 // A subagent compacting its own transcript leaves the session's alone.
87 if (!link || e.agentId !== undefined) return next(e)
88 queue($, state, link, "compacting")
89 try {
90 return await next(e)
91 } finally {
92 queue($, state, link, closing)
93 }
94}
95
96/** @param {import('claude-code').On} on */
97export function register(on) {
98 /** @type {State} */
99 const state = { sending: Promise.resolve() }
100
101 // Interactive sessions only: a `claude -p` started from a tool inside a lich
102 // session inherits its variables, and its compactions are not the card's.
103 on("session.start", { isInteractive: true }, async ($, e, next) => {
104 state.link = await linkFromEnv($)
105 return next(e)
106 })
107
108 // Only the two triggers measured. `precompute` runs ahead of time, out of
109 // sight, and a plugin's own `$.session.compact` was never observed.
110 on("session.compact", { trigger: "manual" }, ($, e, next) => bracket($, state, e, next, "done"))
111 on("session.compact", { trigger: "auto" }, ($, e, next) => bracket($, state, e, next, "busy"))
112}
113hooks/edit-guard.js 215 lines1// Tells a Claude Code session inside lich when another lich session edited the
2// file it is editing. Docs: ../docs/edit-guard.md. No lich contract behind it:
3// the sessions meet in the checkout's git dir, not over HTTP.
4//
5// A subagent lich opens shares its caller's checkout, so two sessions can edit
6// one file. Claude Code already refuses an edit that would overwrite a change
7// it has not read (measured on 2.1.289, in the doc), so this is coordination,
8// not protection: every edit leaves a marker naming the session, and an edit
9// to a file another session marked recently carries a note naming it.
10//
11// Every function that takes `$` is declared here at the top level: the loader
12// refuses a module that hands `$` to a nested function.
13
14import { setStatusPart } from "./status-line.js"
15
16// Long enough to span the other session's turn that made the edit, short
17// enough that a marker from work long finished stops raising notes.
18const RECENT_MS = 10 * 60_000
19const MARKER_DIR = "lich-edits"
20const GIT_TIMEOUT_MS = 5_000
21// The edit's result waits on it, so it is cut well short of the git timeout.
22const LICH_TIMEOUT_MS = 2_000
23
24/**
25 * @typedef {import('claude-code').EngineInterface} Engine
26 * @typedef {{ path: string, session: string, at: number }} Marker
27 * @typedef {{ label: string, id?: string }} Peer
28 */
29
30/** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */
31function hashOf(text) {
32 let hash = 0x811c9dc5
33 for (let i = 0; i < text.length; i++) {
34 hash ^= text.charCodeAt(i)
35 hash = Math.imul(hash, 0x01000193)
36 }
37 return (hash >>> 0).toString(16).padStart(8, "0")
38}
39
40/** @param {string} path */
41function folderOf(path) {
42 const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
43 return cut <= 0 ? path.slice(0, cut + 1) || "." : path.slice(0, cut)
44}
45
46/**
47 * Where the marker for `path` lives, or undefined outside a git repository.
48 *
49 * @param {Engine} $
50 * @param {string} path
51 */
52async function markerPathOf($, path) {
53 const git = await $.process.run(["git", "-C", folderOf(path), "rev-parse", "--absolute-git-dir"], {
54 timeoutMs: GIT_TIMEOUT_MS,
55 })
56 if (git.exitCode !== 0) return undefined
57 return `${git.stdout.trim()}/${MARKER_DIR}/${hashOf(path)}`
58}
59
60/**
61 * The marker another session left on `path`, if any.
62 *
63 * @param {Engine} $
64 * @param {string} markerPath
65 * @param {string} path
66 * @returns {Promise<Marker | undefined>}
67 */
68async function readMarker($, markerPath, path) {
69 if (!(await $.fs.exists(markerPath))) return undefined
70 /** @type {Marker} */
71 const marker = JSON.parse(await $.fs.read(markerPath))
72 return marker.path === path ? marker : undefined
73}
74
75/**
76 * @param {Marker | undefined} marker
77 * @param {string} session
78 * @param {number} now
79 * @returns {marker is Marker}
80 */
81function isNews(marker, session, now) {
82 return !!marker && marker.session !== session && now - marker.at <= RECENT_MS
83}
84
85/**
86 * The label on the card of the session `id`, or undefined when lich cannot say.
87 *
88 * A lich older than the `id` field in `lich sessions --json` lists none, and
89 * the session goes unnamed there.
90 *
91 * @param {Engine} $
92 * @param {string} id
93 */
94async function labelOf($, id) {
95 const lich = await $.env.get("LICH_BIN")
96 if (!lich) return undefined
97 const { exitCode, stdout } = await $.process.run([lich, "sessions", "--json"], { timeoutMs: LICH_TIMEOUT_MS })
98 if (exitCode !== 0) return undefined
99 /** @type {Peer[]} */
100 const peers = JSON.parse(stdout)
101 return peers.find((peer) => peer.id === id)?.label
102}
103
104/**
105 * @param {Marker} marker
106 * @param {string} who
107 * @param {number} now
108 */
109function noteFor(marker, who, now) {
110 const minutes = Math.round((now - marker.at) / 60_000)
111 const ago = minutes === 0 ? "under a minute ago" : `${minutes} min ago`
112 return (
113 `${marker.path} was also edited by the lich session ${who} at ` +
114 `${new Date(marker.at).toISOString()} (${ago}), which shares this checkout and may still be ` +
115 `working on it. Re-read the file before building on it, and coordinate with that session through ` +
116 `send_to_session or lich send rather than undoing its change.`
117 )
118}
119
120/**
121 * The session that left `marker`, by label when lich can say which.
122 *
123 * @param {Engine} $
124 * @param {Marker} marker
125 */
126async function whoMarked($, marker) {
127 let label
128 try {
129 label = await labelOf($, marker.session)
130 } catch (error) {
131 logFailure($, error)
132 }
133 return label ? `"${label}"` : marker.session
134}
135
136/** The marker the status line shows, so an older one's timer does not clear a newer one. @type {Marker | undefined} */
137let shownMarker
138
139/**
140 * Tells the user too, in the status line, until the marker stops being news.
141 * The note only reaches the model.
142 *
143 * @param {Engine} $
144 * @param {Marker} marker
145 * @param {string} who
146 */
147function showEditBy($, marker, who) {
148 shownMarker = marker
149 const name = marker.path.slice(Math.max(marker.path.lastIndexOf("/"), marker.path.lastIndexOf("\\")) + 1)
150 setStatusPart("edits", `${name} also edited by ${who}`)
151 $.ui.invalidate("ui.render")
152}
153
154/**
155 * @param {Engine} $
156 * @param {Marker} marker
157 */
158function clearEditBy($, marker) {
159 if (shownMarker !== marker) return
160 shownMarker = undefined
161 setStatusPart("edits", undefined)
162 $.ui.invalidate("ui.render")
163}
164
165/** @param {Engine} $ @param {unknown} error */
166function logFailure($, error) {
167 $.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`)
168}
169
170/**
171 * @param {Engine} $
172 * @param {{ file_path?: string, notebook_path?: string }} e
173 * @param {(e: any) => Promise<any>} next
174 */
175async function guard($, e, next) {
176 const session = await $.env.get("LICH_SESSION_ID")
177 const path = e.file_path ?? e.notebook_path
178 if (!session || !path) return next(e)
179
180 let markerPath
181 let previous
182 try {
183 markerPath = await markerPathOf($, path)
184 previous = markerPath && (await readMarker($, markerPath, path))
185 } catch (error) {
186 logFailure($, error)
187 return next(e)
188 }
189
190 const result = await next(e)
191 if (!markerPath || result.deny !== undefined) return result
192
193 let note
194 try {
195 const now = await $.clock.now()
196 if (isNews(previous, session, now)) {
197 const who = await whoMarked($, previous)
198 note = noteFor(previous, who, now)
199 showEditBy($, previous, who)
200 $.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous))
201 }
202 if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now }))
203 } catch (error) {
204 logFailure($, error)
205 }
206 return note ? { ...result, context: [...(result.context ?? []), note] } : result
207}
208
209/** @param {import('claude-code').On} on */
210export function register(on) {
211 on("tool.call", { tool: "Edit" }, guard)
212 on("tool.call", { tool: "Write" }, guard)
213 on("tool.call", { tool: "NotebookEdit" }, guard)
214}
215hooks/mod-control.js 261 lines1// Lets lich drive a Claude Code session (`lich control`, the `control_session`
2// MCP tool). Contract: ../docs/mod-control.md,
3// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/mod-control.md
4//
5// A Claude Code mod, not a hook script: hooks/hooks.json names this file under
6// `modules`, and Claude Code runs it inside its own process. It reports nothing.
7// It holds a long poll open on lich, applies the commands that come back and
8// acks each one. A Claude Code that does not run mods ignores the `modules`
9// key, and the scripts beside this file keep reporting as before.
10//
11// Everything outside the module is reached through `$`, which is why every
12// function that takes it is declared here at the top level: the loader refuses
13// a module that hands `$` to a nested function.
14
15// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
16const PLUGIN_VERSION = "0.19.0"
17
18const SETTLE_WHEN_IDLE = new Set(["prompt", "command"])
19
20// The contract's client rule: after a network error or a 5xx, wait 1 second,
21// doubling up to 10, and start over after a 200.
22const FIRST_BACKOFF_MS = 1000
23const MAX_BACKOFF_MS = 10000
24
25// The levels `turn.step` takes. The engine checks an effort only when the next
26// model request goes out, long after the command was acked `ok`, and then skips
27// the hook, so a level outside these is refused here, where lich hears of it.
28const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"])
29
30// Put before an `ask`'s question. Without it, a fork made while a turn runs
31// reaches for a tool, is refused (a fork has none) and answers in a second
32// request, at twice the latency and the uncached tokens: measured on Claude
33// Code 2.1.289.
34const ASK_PREAMBLE =
35 "This is a side question asked from outside your turn, while you work. It does not " +
36 "interrupt your turn and your answer is not added to the conversation. Tools are " +
37 "unavailable: do not call any tool. Answer from what the conversation already holds, " +
38 "in plain text, briefly. Question: "
39
40// The contract cuts an answer at this many UTF-16 units, which keeps an ack
41// under lich's 64 KiB body limit; a fork has no length bound of its own.
42const ANSWER_LIMIT = 16000
43
44/**
45 * @typedef {import('claude-code').EngineInterface} Engine
46 * @typedef {{ status: string, summary: string }} Notification
47 * @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort
48 * @typedef {{ id: string, kind: string, text?: string, notification?: Notification, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command
49 * @typedef {{
50 * base: string,
51 * token: string,
52 * session: string,
53 * backoffMs: number,
54 * applying: Promise<void>,
55 * }} Link
56 * @typedef {{
57 * link?: Link,
58 * turnId?: string,
59 * model?: string,
60 * effort?: Effort,
61 * }} State
62 */
63
64/**
65 * @param {Engine} $
66 * @returns {Promise<Link | undefined>}
67 */
68async function linkFromEnv($) {
69 const [port, token, session] = await Promise.all([
70 $.env.get("LICH_PORT"),
71 $.env.get("LICH_TOKEN"),
72 $.env.get("LICH_SESSION_ID"),
73 ])
74 if (!port || !token || !session) return undefined
75 return { base: `http://127.0.0.1:${port}`, token, session, backoffMs: 0, applying: Promise.resolve() }
76}
77
78/**
79 * One poll, then the next one scheduled: at once after a 200, later after a
80 * failure, never after a 404 (a lich older than the contract).
81 *
82 * @param {Engine} $
83 * @param {State} state
84 * @param {Link} link
85 */
86async function poll($, state, link) {
87 const url = `${link.base}/mod/commands?token=${link.token}&session_id=${link.session}`
88 /** @type {Command[]} */
89 let commands
90 try {
91 const response = await $.http.fetch(url, { headers: { "X-Lich-Plugin": PLUGIN_VERSION } })
92 if (response.status === 404) return
93 if (!response.ok) throw new Error(`lich answered ${response.status}`)
94 commands = JSON.parse(response.text)
95 } catch {
96 link.backoffMs = Math.min(link.backoffMs * 2 || FIRST_BACKOFF_MS, MAX_BACKOFF_MS)
97 $.clock.after(link.backoffMs, () => void poll($, state, link))
98 return
99 }
100 link.backoffMs = 0
101 for (const command of commands) {
102 // An answer can take a minute and changes nothing the other commands
103 // depend on, so it is not queued behind them, nor they behind it.
104 if (command.kind === "ask") {
105 void applyAndAck($, state, link, command)
106 continue
107 }
108 link.applying = link.applying.then(() => {
109 const acked = applyAndAck($, state, link, command)
110 // A prompt or a slash command settles only once the session is idle and
111 // it ran. Waiting on one would hold an abort meant for the running turn
112 // until that turn ended, and a prompt's abort would then cancel the turn
113 // the prompt started instead.
114 if (!SETTLE_WHEN_IDLE.has(command.kind)) return acked
115 })
116 }
117 $.clock.after(0, () => void poll($, state, link))
118}
119
120/**
121 * Applies one command and acks it. Every command but a `prompt` or a `command`
122 * holds the next one until its ack is answered: an `abort` ack makes lich end
123 * the turn it has open, so one landing after the next `prompt` opened a turn
124 * would end that turn instead.
125 *
126 * @param {Engine} $
127 * @param {State} state
128 * @param {Link} link
129 * @param {Command} command
130 */
131async function applyAndAck($, state, link, command) {
132 /** @type {{ ok: boolean, error?: string, answer?: string }} */
133 let outcome
134 try {
135 outcome = { ok: true, ...(await apply($, state, command)) }
136 } catch (error) {
137 outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
138 }
139 try {
140 await $.http.fetch(`${link.base}/mod/acks?token=${link.token}`, {
141 method: "POST",
142 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
143 body: JSON.stringify({ session_id: link.session, id: command.id, kind: command.kind, ...outcome }),
144 })
145 } catch {
146 // The contract drops an ack that cannot be sent: lich never resends the command.
147 }
148}
149
150/**
151 * @param {Engine} $
152 * @param {State} state
153 * @param {Command} command
154 * @returns {Promise<{ answer: string } | undefined>} what the ack carries beyond `ok`
155 */
156async function apply($, state, command) {
157 switch (command.kind) {
158 case "prompt": {
159 const text = command.text ?? ""
160 const submitted = await $.prompt.submit(
161 command.notification ? { text: notificationText(command.notification, text), asUser: true } : { text },
162 )
163 if (submitted.drop !== undefined) throw new Error(submitted.drop)
164 return
165 }
166 case "abort":
167 if (state.turnId === undefined) throw new Error("no turn is running")
168 await $.turn.abort({ turnId: state.turnId })
169 return
170 case "model":
171 state.model = command.model
172 return
173 case "effort":
174 if (command.effort !== undefined && !EFFORTS.has(command.effort)) throw new Error("unknown effort")
175 state.effort = /** @type {Effort | undefined} */ (command.effort)
176 return
177 case "command":
178 await $.command.run({ command: command.name ?? "", args: command.args })
179 return
180 case "ask":
181 return { answer: await answer($, command.question ?? "") }
182 default:
183 throw new Error("unknown kind")
184 }
185}
186
187/**
188 * A prompt lich marks as news from outside the session, in the shape Claude
189 * Code gives its own background agent's completion: it shows `summary` as one
190 * line and the model reads the whole text. `text` goes in unescaped because the
191 * model reads it byte for byte, entities included; Claude Code decodes them in
192 * the line it shows (measured on 2.1.289).
193 *
194 * @param {Notification} notification
195 * @param {string} text
196 */
197function notificationText(notification, text) {
198 return (
199 `<task-notification>\n<status>${escapeXml(notification.status)}</status>\n` +
200 `<summary>${escapeXml(notification.summary)}</summary>\n<result>${text}</result>\n</task-notification>`
201 )
202}
203
204/** @param {string} value */
205function escapeXml(value) {
206 return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
207}
208
209/**
210 * The session's answer to a side question: a fork of its own conversation, cut
211 * at ANSWER_LIMIT. A fork with no answer throws its reason, which the ack
212 * carries as its error.
213 *
214 * @param {Engine} $
215 * @param {string} question
216 */
217async function answer($, question) {
218 const reply = await $.model.fork({ prompt: ASK_PREAMBLE + question })
219 if (!reply.isAnswered) {
220 throw new Error(reply.reason === "api-error" ? `api-error ${reply.status} ${reply.error}` : reply.reason)
221 }
222 if (reply.text.length <= ANSWER_LIMIT) return reply.text
223 return `${reply.text.slice(0, ANSWER_LIMIT)}\n[truncated]`
224}
225
226/** @param {import('claude-code').On} on */
227export function register(on) {
228 /** @type {State} */
229 const state = {}
230
231 on("session.start", async ($, e, next) => {
232 // A `claude -p` holds its run open until a parked poll returns, up to 25
233 // seconds, and one started from a tool inside a lich session inherits that
234 // session's variables, so it would also take the parent's commands.
235 if (e.isInteractive && state.link === undefined) {
236 state.link = await linkFromEnv($)
237 const link = state.link
238 if (link) $.clock.after(0, () => void poll($, state, link))
239 }
240 return next(e)
241 })
242
243 on("turn.start", ($, e, next) => {
244 state.turnId = e.turnId
245 return next(e)
246 })
247
248 on("turn.complete", ($, e, next) => {
249 if (e.turnId === state.turnId) state.turnId = undefined
250 return next(e)
251 })
252
253 on("turn.step", async function* ($, e, next) {
254 return yield* next({
255 ...e,
256 ...(state.model !== undefined && { model: state.model }),
257 ...(state.effort !== undefined && { effort: state.effort }),
258 })
259 })
260}
261hooks/mod-status.js 108 lines1// Shows the relay errands a Claude Code session is part of in its status line:
2// who it owes an answer, the tasks it handed out that are still running, and
3// the answers waiting to be collected. Contract: ../docs/mod-status.md,
4// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/mod-status.md
5//
6// The one contract that reads rather than reports, and reading never collects:
7// an answer listed as ready stays in the inbox for the agent.
8//
9// Every function that takes `$` is declared here at the top level: the loader
10// refuses a module that hands `$` to a nested function.
11
12import { setStatusPart } from "./status-line.js"
13
14// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
15const PLUGIN_VERSION = "0.19.0"
16
17// The contract asks for a read at most every few seconds; the worker count in
18// agent-cards.js polls lich on the same period.
19const READ_EVERY_MS = 5000
20
21/**
22 * @typedef {import('claude-code').EngineInterface} Engine
23 * @typedef {{ ticket: string, from: string, asked: string }} Owed
24 * @typedef {{ ticket: string, target: string, state: string }} Open
25 * @typedef {{ ticket: string, target: string, status: string }} Ready
26 * @typedef {{ owed: Owed[], open: Open[], ready: Ready[] }} Status
27 * @typedef {{ url: string, timer?: import('claude-code').Timer, reading: boolean }} State
28 */
29
30/** @param {number} count @param {string} noun */
31function counted(count, noun) {
32 return `${count} ${noun}${count === 1 ? "" : "s"}`
33}
34
35/**
36 * The status line's text for `status`, or undefined when there is nothing to
37 * show. A task out on a session waiting for a human is called out: nobody but
38 * the user can move it.
39 *
40 * @param {Status} status
41 */
42function errandsLine({ owed, open, ready }) {
43 const pieces = []
44 if (owed.length === 1 && owed[0].from) pieces.push(`owes "${owed[0].from}" an answer`)
45 else if (owed.length > 0) pieces.push(`owes ${counted(owed.length, "answer")}`)
46 if (open.length > 0) {
47 const waiting = open.filter((errand) => errand.state === "waiting").length
48 pieces.push(`${counted(open.length, "task")} out${waiting > 0 ? ` (${waiting} waiting)` : ""}`)
49 }
50 if (ready.length > 0) pieces.push(`${counted(ready.length, "answer")} to collect`)
51 return pieces.length === 0 ? undefined : pieces.join(", ")
52}
53
54/**
55 * @param {Engine} $
56 * @returns {Promise<string | undefined>}
57 */
58async function statusUrlFromEnv($) {
59 const [port, token, session] = await Promise.all([
60 $.env.get("LICH_PORT"),
61 $.env.get("LICH_TOKEN"),
62 $.env.get("LICH_SESSION_ID"),
63 ])
64 if (!port || !token || !session) return undefined
65 return `http://127.0.0.1:${port}/mod/status?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(session)}`
66}
67
68/**
69 * One read. A failed one draws nothing from lich until a read works again,
70 * and is never retried; a 404 is a lich older than the contract, which stops
71 * the reads for the session.
72 *
73 * @param {Engine} $
74 * @param {State} state
75 */
76async function read($, state) {
77 if (state.reading) return
78 state.reading = true
79 /** @type {string | undefined} */
80 let line
81 try {
82 const response = await $.http.fetch(state.url, { headers: { "X-Lich-Plugin": PLUGIN_VERSION } })
83 if (response.status === 404) state.timer?.cancel()
84 if (response.ok) line = errandsLine(JSON.parse(response.text))
85 } catch {
86 // Unreachable or unreadable: the line goes until the next read works.
87 }
88 state.reading = false
89 setStatusPart("errands", line)
90 $.ui.invalidate("ui.render")
91}
92
93/** @param {import('claude-code').On} on */
94export function register(on) {
95 // Interactive sessions only: a `claude -p` started from a tool inside a lich
96 // session inherits its variables, and would read the parent's errands.
97 on("session.start", { isInteractive: true }, async ($, e, next) => {
98 const url = await statusUrlFromEnv($)
99 if (url) {
100 /** @type {State} */
101 const state = { url, reading: false }
102 state.timer = $.clock.every(READ_EVERY_MS, () => read($, state))
103 read($, state)
104 }
105 return next(e)
106 })
107}
108hooks/mod-usage.js 120 lines1// Reports what a Claude Code session measured about itself to lich. Contract:
2// ../docs/mod-usage.md, canonical in
3// https://github.com/omartelo/lich/blob/main/docs/hooks/mod-usage.md
4//
5// A Claude Code mod, not a hook script: hooks/hooks.json names this file under
6// `modules`, beside mod-control.js. It hooks `session.measure`, which fires at
7// start, at the end of every main-thread turn and when a rate-limit window
8// moves, and posts the context window, the rate limits and the cost to lich,
9// which shows those instead of the figures it derives.
10//
11// Every function that takes `$` is declared here at the top level: the loader
12// refuses a module that hands `$` to a nested function.
13
14// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
15const PLUGIN_VERSION = "0.19.0"
16
17/**
18 * @typedef {import('claude-code').EngineInterface} Engine
19 * @typedef {import('claude-code').SessionMeasureInput} Measure
20 * @typedef {{ base: string, token: string, session: string }} Link
21 * @typedef {{
22 * started: Promise<Link | undefined>,
23 * sending: Promise<void>,
24 * gone: boolean,
25 * }} State
26 */
27
28/**
29 * @param {Engine} $
30 * @returns {Promise<Link | undefined>}
31 */
32async function linkFromEnv($) {
33 const [port, token, session] = await Promise.all([
34 $.env.get("LICH_PORT"),
35 $.env.get("LICH_TOKEN"),
36 $.env.get("LICH_SESSION_ID"),
37 ])
38 if (!port || !token || !session) return undefined
39 return { base: `http://127.0.0.1:${port}`, token, session }
40}
41
42/**
43 * The body lich takes: Claude Code's figures under the contract's names, an
44 * absent figure left out rather than zeroed.
45 *
46 * @param {string} session
47 * @param {string} conversation
48 * @param {Measure} e
49 */
50function usageBody(session, conversation, e) {
51 return {
52 session_id: session,
53 conversation_id: conversation,
54 context: {
55 window: e.context.window,
56 ...(e.context.tokens !== undefined && { tokens: e.context.tokens }),
57 ...(e.context.percent !== undefined && { percent: e.context.percent }),
58 },
59 rate_limits: e.rateLimits.map((limit) => ({
60 kind: limit.kind,
61 percent_used: limit.percentUsed,
62 ...(limit.resetsAt !== undefined && { resets_at: limit.resetsAt }),
63 })),
64 ...(e.cost !== undefined && { cost_usd: e.cost.usd }),
65 }
66}
67
68/**
69 * Posts one measurement, once the session's link is known. A report that
70 * fails is dropped: the next measurement carries the whole figures again.
71 *
72 * @param {Engine} $
73 * @param {State} state
74 * @param {Measure} e
75 */
76async function report($, state, e) {
77 const link = await state.started
78 if (!link || state.gone) return
79 try {
80 const response = await $.http.fetch(`${link.base}/mod/usage?token=${link.token}`, {
81 method: "POST",
82 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
83 body: JSON.stringify(usageBody(link.session, await $.session.id(), e)),
84 })
85 // A lich older than the contract: nothing it would take is coming.
86 if (response.status === 404) state.gone = true
87 } catch {
88 // The contract drops a report that cannot be sent; it never retries one.
89 }
90}
91
92/** @param {import('claude-code').On} on */
93export function register(on) {
94 /** @type {(link: Link | undefined) => void} */
95 let start = () => {}
96 /** @type {State} */
97 const state = {
98 started: new Promise((resolve) => {
99 start = resolve
100 }),
101 sending: Promise.resolve(),
102 gone: false,
103 }
104
105 // Interactive sessions only: a `claude -p` started from a tool inside a lich
106 // session inherits that session's variables, and its figures would land on
107 // the parent's card. Its measurements wait on a link that never comes.
108 on("session.start", { isInteractive: true }, async ($, e, next) => {
109 start(await linkFromEnv($))
110 return next(e)
111 })
112
113 on("session.measure", ($, e, next) => {
114 // One at a time, in order: lich keeps the latest report, so an older one
115 // landing last would put stale figures back on the card.
116 state.sending = state.sending.then(() => report($, state, e))
117 return next(e)
118 })
119}
120hooks/self-command.js 100 lines1// Lets a Claude Code session run one of its own built-in slash commands when the
2// person asks for one ("run /compact"). Plugin side only: lich's `control`
3// refuses a session controlling itself, so nothing here goes through lich.
4// Described in ../docs/self-command.md.
5//
6// The model asks through the Skill tool, the one it already reaches for: Claude
7// Code refuses a built-in there ("compact is a built-in CLI command, not a
8// skill"), and this mod answers that refusal by queueing the command. A tool of
9// the mod's own is not an option: Claude Code lists it as `mcp__lich__<name>`,
10// and `$.tool.register` refuses that in a session whose MCP config already has
11// lich's own server under that name, which is every lich session (measured on
12// 2.1.295).
13//
14// Every function that takes `$` is declared here at the top level: the loader
15// refuses a module that hands `$` to a nested function.
16
17// Run through a mod, these save what they set as the default for every new
18// session (measured on Claude Code 2.1.288 and 2.1.289).
19const SAVES_A_DEFAULT = new Set(["model", "effort"])
20
21const SKILL_NOTE =
22 "\n\nIn this session a built-in slash command (compact, clear, and the like) can be named here too, " +
23 "when the user asks you to run it: it is queued and runs once your turn ends, so end your turn right after."
24
25/**
26 * @typedef {import('claude-code').EngineInterface} Engine
27 * @typedef {{ isEnabled: boolean }} State
28 */
29
30/**
31 * Queues `/name args` for when the session is idle. `$.command.run` rejects
32 * inside a hook the turn is waiting on, so it is called from a timer; a
33 * command that then fails has no turn left to answer, so its error goes to a
34 * toast.
35 *
36 * @param {Engine} $
37 * @param {string} name
38 * @param {string} args
39 */
40async function runCommand($, name, args) {
41 try {
42 await $.command.run({ command: name, args })
43 } catch (error) {
44 $.ui.toast(`/${name} did not run: ${error instanceof Error ? error.message : String(error)}`)
45 }
46}
47
48/**
49 * @param {Engine} $
50 * @param {string} name
51 */
52async function isBuiltin($, name) {
53 const commands = await $.command.list()
54 return commands.some((command) => command.name === name && command.source === "builtin")
55}
56
57/** @param {import('claude-code').On} on */
58export function register(on) {
59 /** @type {State} */
60 const state = { isEnabled: false }
61
62 // Interactive lich sessions only: outside lich the plugin adds nothing, and a
63 // `claude -p` started from a tool inside a lich session inherits its variables.
64 on("session.start", { isInteractive: true }, async ($, e, next) => {
65 if (await $.env.get("LICH_SESSION_ID")) {
66 state.isEnabled = true
67 $.ui.invalidate("tool.describe")
68 }
69 return next(e)
70 })
71
72 on("tool.describe", { tool: "Skill" }, async ($, e, next) => {
73 const described = await next(e)
74 if (!state.isEnabled) return described
75 return { ...described, description: described.description + SKILL_NOTE }
76 })
77
78 // The Skill tool runs first: a skill, or a built-in it serves as a prompt
79 // (/init, /review), stays its own. Only what it refused is taken.
80 on("tool.call", { tool: "Skill" }, async ($, e, next) => {
81 const native = await next(e)
82 if (!state.isEnabled || !("isError" in native && native.isError)) return native
83 const name = e.skill.trim().replace(/^\//, "")
84 if (SAVES_A_DEFAULT.has(name)) {
85 return {
86 deny:
87 `/${name} run from inside the session would be saved as the default for every new Claude Code ` +
88 `session. Ask the user to set it from lich, whose ${name} override applies to this session only.`,
89 }
90 }
91 if (!(await isBuiltin($, name))) return native
92 const args = e.args?.trim() ?? ""
93 $.clock.after(0, () => runCommand($, name, args))
94 return {
95 result: { success: true, commandName: name },
96 context: [`/${[name, args].filter(Boolean).join(" ")} is queued and runs once this turn ends. End the turn now.`],
97 }
98 })
99}
100hooks/status-line.js 47 lines1// The lich line in Claude Code's prompt footer, shared by the mods. It is drawn
2// dim among the mode labels at the footer's right (`SessionMode`), not through
3// `$.ui.status`, which Claude Code draws as a yellow warning. Each mod owns one
4// part of the line, and the line is the parts joined in a fixed order.
5//
6// The errands lich lists (mod-status.js) include every worker agent-cards.js
7// opened, so while that part shows, the worker count is left out of the line.
8//
9// The parts live in this module, not in `$.state`: every part is redrawn by
10// its own mod within seconds, except an edit note, which a hot reload of the
11// plugin drops early.
12
13/** @typedef {"errands" | "workers" | "edits"} Part */
14
15/** @type {Part[]} */
16const ORDER = ["errands", "workers", "edits"]
17const SEPARATOR = " · "
18
19/** @type {Map<Part, string>} */
20const parts = new Map()
21
22/**
23 * Sets this mod's part of the line; undefined removes it. The caller then
24 * redraws the footer with `$.ui.invalidate("ui.render")`, since the loader
25 * refuses `$` handed across an import.
26 *
27 * @param {Part} part
28 * @param {string | undefined} text
29 */
30export function setStatusPart(part, text) {
31 if (text === undefined) parts.delete(part)
32 else parts.set(part, text)
33}
34
35function statusLine() {
36 const shown = ORDER.filter((p) => parts.has(p) && !(p === "workers" && parts.has("errands")))
37 return shown.length === 0 ? undefined : `lich: ${shown.map((p) => parts.get(p)).join(SEPARATOR)}`
38}
39
40/** @param {import('claude-code').On} on */
41export function register(on) {
42 on("ui.render", { component: "SessionMode" }, ($, e, next) => {
43 const line = statusLine()
44 return line === undefined ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, line] } })
45 })
46}
47hooks/worker-answer.js 129 lines1// Answers the task a lich subagent worker was handed with the worker's own last
2// message, the way a native subagent's final message is its result. Contract:
3// ../docs/mod-answer.md, canonical in
4// https://github.com/omartelo/lich/blob/main/docs/hooks/mod-answer.md
5//
6// A Claude Code mod, registered by hooks/lich.js beside mod-control.js. Only a
7// worker reports: lich sets LICH_SUBAGENT_DEPTH on every Claude Code session it
8// starts, 0 at the top and n for a worker n `lich open --subagent` levels down.
9// A lich older than that variable spawns every worker with
10// LICH_SUBAGENT_CARDS=off instead, which a session the user turned subagent
11// cards off for also carries; lich ignores the report from a session with no
12// subagent errand open, so that one costs a request per turn and nothing else.
13//
14// A turn is the worker's answer when its main-loop `classic.Stop` lists no
15// background task (a shell, subagent, monitor or workflow still running means
16// the turn handed work to the background, and the turn Claude Code resumes in
17// once it finishes is the one that answers) and its `turn.complete` ended with
18// an answer that is not blank. Measured on Claude Code 2.1.289: `classic.Stop`
19// fires on the main loop only, before `turn.complete`, and its
20// `last_assistant_message` equals the completion's `answer`; an aborted turn
21// fires no Stop.
22//
23// Every function that takes `$` is declared here at the top level: the loader
24// refuses a module that hands `$` to a nested function.
25
26// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
27const PLUGIN_VERSION = "0.19.0"
28
29// The value a lich older than LICH_SUBAGENT_DEPTH spawns every `--subagent`
30// session with.
31const CARDS_OFF = "off"
32
33// The contract cuts an answer at this many UTF-16 units, as mod-control.js
34// cuts an ask's, which keeps the body under lich's 64 KiB limit.
35const ANSWER_LIMIT = 16000
36
37/**
38 * @typedef {import('claude-code').EngineInterface} Engine
39 * @typedef {{ base: string, token: string, session: string }} Link
40 * @typedef {{ link?: Link, idleText?: string }} State
41 */
42
43/**
44 * @param {string | undefined} depth LICH_SUBAGENT_DEPTH
45 * @param {string | undefined} cards LICH_SUBAGENT_CARDS
46 */
47function isWorker(depth, cards) {
48 if (depth === undefined) return cards === CARDS_OFF
49 const levels = Number(depth)
50 return Number.isInteger(levels) && levels > 0
51}
52
53/**
54 * The link to lich, for a worker only.
55 *
56 * @param {Engine} $
57 * @returns {Promise<Link | undefined>}
58 */
59async function workerLink($) {
60 const [port, token, session, depth, cards] = await Promise.all([
61 $.env.get("LICH_PORT"),
62 $.env.get("LICH_TOKEN"),
63 $.env.get("LICH_SESSION_ID"),
64 $.env.get("LICH_SUBAGENT_DEPTH"),
65 $.env.get("LICH_SUBAGENT_CARDS"),
66 ])
67 if (!port || !token || !session || !isWorker(depth, cards)) return undefined
68 return { base: `http://127.0.0.1:${port}`, token, session }
69}
70
71/** @param {string} text */
72function cut(text) {
73 if (text.length <= ANSWER_LIMIT) return text
74 return `${text.slice(0, ANSWER_LIMIT)}\n[truncated]`
75}
76
77/**
78 * Posts one answer beside the chain. One that cannot be sent is dropped: the
79 * worker's card still holds it, and a second copy of a report is worse than
80 * none.
81 *
82 * @param {Engine} $
83 * @param {Link} link
84 * @param {string} text
85 */
86async function report($, link, text) {
87 try {
88 await $.http.fetch(`${link.base}/mod/answer?token=${link.token}`, {
89 method: "POST",
90 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
91 body: JSON.stringify({ session_id: link.session, text: cut(text) }),
92 })
93 } catch {
94 // The contract's client rule: a report that cannot be sent is dropped.
95 }
96}
97
98/** @param {import('claude-code').On} on */
99export function register(on) {
100 /** @type {State} */
101 const state = {}
102
103 // Interactive sessions only: a `claude -p` started from a tool inside a
104 // worker inherits its variables, and its answers are not the worker's.
105 on("session.start", { isInteractive: true }, async ($, e, next) => {
106 state.link = await workerLink($)
107 return next(e)
108 })
109
110 on("classic.Stop", ($, e, next) => {
111 const idle = (e.background_tasks ?? []).length === 0
112 const text = e.last_assistant_message ?? ""
113 state.idleText = idle && text.trim() !== "" ? text : undefined
114 return next(e)
115 })
116
117 // The matcher keeps this beside mod-control.js's own turn.complete hook, which
118 // has none: Claude Code refuses one plugin's second hook on an event with no
119 // matcher. An aborted, failed or refused turn never matches.
120 on("turn.complete", { reason: "answer" }, ($, e, next) => {
121 const text = state.idleText
122 state.idleText = undefined
123 const link = state.link
124 const isWorkersAnswer = link !== undefined && e.agentId === undefined && !e.isAborted && text === e.answer
125 if (isWorkersAnswer && text !== undefined) void report($, link, text)
126 return next(e)
127 })
128}
129