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 526 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// The call goes through Claude Code's own permission check first (its rules,
10// mode, dialog and PreToolUse hooks, all beneath `next` in tool.call), and lich
11// opens the worker only at `agent.spawn`, which fires once the call was allowed
12// and before the native agent or its worktree exist; the spawn is then denied
13// and the Agent call answered with lich's outcome. A call refused there never
14// reaches `agent.spawn`, so it opens nothing (measured on 2.1.296).
15//
16// Once lich has the task the Agent call answers at once, as a background
17// subagent does, and nothing here waits on the worker: `--subagent` makes lich
18// type the worker's whole report at this session's prompt as a [lich] note.
19// While workers run, the status line counts them from `$LICH_BIN sessions
20// --json`, and Claude Code's TaskStop on one is taken here and done by lich.
21//
22// Claude Code fails a tool.call hook open: one that throws, overruns its budget
23// or lets a `$.process.run` reject is skipped and the native agent runs in its
24// place (measured on 2.1.289). Every failure is therefore caught here and
25// decided: before lich has the task, the native agent runs with a toast saying
26// why; once lich may have it, the call is denied, because a native agent beside
27// the worker would do the work twice.
28//
29// Every function that takes `$` is declared at the top level: the loader
30// refuses a module that hands `$` to a nested function or keeps it in a variable.
31
32import { promptLang, say } from "./prompt-text.js"
33import { setStatusPart } from "./status-line.js"
34
35// lich's `promptLimit` (internal/relay). lich checks it only after the session
36// was opened, so a task over it would leave an empty card behind.
37const PROMPT_LIMIT_BYTES = 8192
38
39// `lich open --prompt` waits up to `openCall` (60s) for the session and then
40// delivers like a send: `deliverWait` (20s) plus `callSlack` (30s), all in
41// internal/cli/cli.go. This bound only has to outlast lich's own.
42const OPEN_TIMEOUT_MS = 120000
43
44// The value lich writes into a session's environment to keep its subagents
45// native: when "Subagents as lich sessions" is off in Settings › Providers ›
46// Claude Code, and in a worker at lich's own depth limit for cards opened from
47// cards. A lich older than LICH_SUBAGENT_DEPTH writes it into every worker.
48const CARDS_OFF = "off"
49
50// The bounds lich's own worktree dialog slugs a typed name with
51// (`toBranchName`, frontend/src/lib/git/branch-name.ts there), so a branch
52// opened here reads like one a person named.
53const MIN_WORDS = 2
54const MAX_WORDS = 5
55const MIN_CHARS = 10
56const MAX_CHARS = 40
57const FALLBACK_SLUG = "agent"
58// Marks a worker's branch. Under a lich older than LICH_SUBAGENT_DEPTH, the mod
59// inside an isolated worker reads it to leave its own subagents native instead
60// of opening cards from cards; a lich that sets the depth decides that alone,
61// through LICH_SUBAGENT_CARDS.
62const WORKER_BRANCH_PREFIX = "subagent/"
63
64// How often the status line asks lich which workers still run. Claude Code's
65// own "N agents" hint moves as each agent ends; a worker's end reaches lich, not
66// this session, so it is read from `lich sessions --json`, one short run of the
67// CLI per period while any worker runs.
68const WORKER_POLL_MS = 5000
69
70// The end of the Agent call's id makes each branch new: lich checks out an
71// existing branch as it stands, so a bare slug could land on someone's work.
72const SUFFIX_CHARS = 4
73
74/**
75 * @typedef {import('claude-code').EngineInterface} Engine
76 * @typedef {{ ticket: string, target: string, status: string, answer: string }} Report
77 * @typedef {{ label: string, name: string, path: string, delivery?: Report }} Opened
78 * @typedef {{ label: string, name: string, description: string, shared: boolean }} Worker
79 * @typedef {{ call: AgentCall, lich: string, base: string, lang: string, answer?: object }} Claim
80 * @typedef {{ interactive: boolean, workers: Map<string, Worker>, watching: boolean, lich: string,
81 * claims: Map<string, Claim> }} State
82 * @typedef {{ label: string, name: string, state: string }} Peer
83 * @typedef {{ tool: "Agent", tool_use_id: string, agentId?: string, description: string, prompt: string,
84 * subagent_type?: string, model?: string, team_name?: string, isolation?: string }} AgentCall
85 */
86
87/** @param {unknown} error */
88function messageOf(error) {
89 return error instanceof Error ? error.message : String(error)
90}
91
92/**
93 * A call the model made on the main loop for a general-purpose agent, in an
94 * interactive session. Another plugin's `$.agent.spawn`, a subagent's own
95 * call, a typed agent and a teammate keep their native behaviour.
96 *
97 * @param {AgentCall} e
98 * @param {{ origin: { plugin: string } }} next
99 * @param {State} state
100 */
101function isDelegable(e, next, state) {
102 const isModelOnMainLoop = next.origin.plugin === "engine" && e.agentId === undefined
103 const isGeneralPurpose = e.subagent_type === undefined || e.subagent_type === "general-purpose"
104 const isLocal = e.team_name === undefined && e.isolation !== "remote"
105 return state.interactive && isModelOnMainLoop && isGeneralPurpose && isLocal
106}
107
108/** @param {string} text */
109function slugOf(text) {
110 const words = text
111 .toLowerCase()
112 .replace(/[^\p{L}\p{N}]+/gu, " ")
113 .trim()
114 .split(/\s+/)
115 .filter(Boolean)
116 /** @type {string[]} */
117 const picked = []
118 let length = 0
119 for (const word of words) {
120 const grown = length === 0 ? word.length : length + 1 + word.length
121 const hasMinimum = picked.length >= MIN_WORDS && length >= MIN_CHARS
122 if (picked.length >= MAX_WORDS || (grown > MAX_CHARS && hasMinimum)) break
123 picked.push(word)
124 length = grown
125 }
126 return picked.join("-").slice(0, MAX_CHARS).replace(/-+$/, "")
127}
128
129/** @param {AgentCall} e */
130function branchFor(e) {
131 const suffix = e.tool_use_id.replace(/[^A-Za-z0-9]/g, "").slice(-SUFFIX_CHARS).toLowerCase()
132 return `${WORKER_BRANCH_PREFIX}${slugOf(e.description ?? "") || FALLBACK_SLUG}-${suffix}`
133}
134
135/**
136 * The branch the asking session is on, so the worker starts from its work and
137 * not from the main checkout's branch; "" on a detached HEAD or when git
138 * cannot answer, which leaves the base to lich.
139 *
140 * @param {Engine} $
141 */
142async function currentBranch($) {
143 try {
144 const { exitCode, stdout } = await $.process.run(["git", "branch", "--show-current"])
145 return exitCode === 0 ? stdout.trim() : ""
146 } catch {
147 // A rejection here would fail the whole hook open; lich's default base is the answer instead.
148 return ""
149 }
150}
151
152/** @param {string} stdout */
153function parsedOrUndefined(stdout) {
154 try {
155 return JSON.parse(stdout)
156 } catch {
157 return undefined
158 }
159}
160
161/**
162 * Opens the worker and hands it the task. Throws when the task did not reach
163 * it, with lich's own reason.
164 *
165 * @param {Engine} $
166 * @param {string} lich
167 * @param {AgentCall} e
168 * @param {string} branch "" opens the worker in this session's checkout
169 * @param {string} base
170 * @returns {Promise<Opened & { delivery: Report }>}
171 */
172async function openWorker($, lich, e, branch, base) {
173 const argv = [
174 lich, "open", "--kind", "claude", "--subagent",
175 ...(branch ? ["--worktree", branch] : []),
176 ...(base ? ["--base", base] : []),
177 ...(e.model ? ["--model", e.model] : []),
178 "--prompt", e.prompt, "--json",
179 ]
180 const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: OPEN_TIMEOUT_MS })
181 /** @type {Opened | undefined} */
182 const opened = parsedOrUndefined(stdout)
183 const reason = stderr.trim() || `lich open exited ${exitCode}`
184 if (opened === undefined) throw new Error(reason)
185 if (opened.delivery === undefined) throw new Error(`the task did not reach "${opened.label}": ${reason}`)
186 return /** @type {Opened & { delivery: Report }} */ (opened)
187}
188
189/**
190 * The Agent tool's `async_launched` arm, plus what the model reads after it.
191 * Claude Code's own text for this arm promises a task notification and points
192 * at SendMessage, and neither reaches a lich session, so `context` says how the
193 * report really comes back. `outputFile` is "", Claude Code's own value for an
194 * agent with no output file: without `canReadOutputFile` the model is never
195 * shown it.
196 *
197 * @param {AgentCall} e
198 * @param {Opened} opened
199 * @param {string} branch
200 * @param {string} lang
201 */
202function backgrounded(e, opened, branch, lang) {
203 const where = branch
204 ? say(lang, "whereBranch", { branch, path: opened.path })
205 : say(lang, "whereShared", { path: opened.path })
206 return {
207 result: {
208 status: /** @type {const} */ ("async_launched"),
209 agentId: opened.name,
210 description: e.description,
211 prompt: e.prompt,
212 outputFile: "",
213 },
214 context: [say(lang, "backgrounded", { label: opened.label, where })],
215 }
216}
217
218/**
219 * The Agent tool's `completed` arm, which Claude Code checks a hook's result
220 * against. The worker's tokens and tools are its own session's, so none are
221 * counted here; a worker in this checkout names no worktree, as a native agent
222 * without isolation does.
223 *
224 * @param {AgentCall} e
225 * @param {Opened} opened
226 * @param {string} branch
227 * @param {string} text
228 * @param {number} durationMs
229 */
230function completed(e, opened, branch, text, durationMs) {
231 return {
232 status: /** @type {const} */ ("completed"),
233 agentId: opened.name,
234 content: [{ type: /** @type {const} */ ("text"), text }],
235 totalToolUseCount: 0,
236 totalDurationMs: durationMs,
237 totalTokens: 0,
238 usage: {
239 input_tokens: 0,
240 output_tokens: 0,
241 cache_creation_input_tokens: null,
242 cache_read_input_tokens: null,
243 server_tool_use: null,
244 service_tier: null,
245 cache_creation: null,
246 },
247 prompt: e.prompt,
248 ...(branch ? { worktreePath: opened.path, worktreeBranch: branch } : {}),
249 }
250}
251
252/**
253 * @param {AgentCall} e
254 * @param {Opened} opened
255 * @param {string} branch
256 * @param {Report} report
257 * @param {number} durationMs
258 * @param {string} lang
259 */
260function answerFor(e, opened, branch, report, durationMs, lang) {
261 switch (report.status) {
262 // A worker whose turn ended unanswered is usually still at it: it left a
263 // command running in the background and resumes when it finishes, and its
264 // report still arrives as a [lich] note.
265 case "pending":
266 case "unanswered":
267 return backgrounded(e, opened, branch, lang)
268 case "answered": {
269 const where = say(lang, branch ? "completedBranch" : "completedShared", {
270 branch,
271 path: opened.path,
272 label: opened.label,
273 })
274 return { result: completed(e, opened, branch, `${report.answer}\n\n${where}`, durationMs) }
275 }
276 case "unread":
277 case "undelivered":
278 return { deny: say(lang, "denyNeverReached", { label: opened.label, status: report.status }) }
279 default:
280 return {
281 deny: say(lang, "denyAnswered", {
282 status: report.status,
283 label: opened.label,
284 branchPart: branch ? `, ${say(lang, "onBranch", { branch })}` : "",
285 }),
286 }
287 }
288}
289
290/**
291 * A worker lich opened with `--subagent`, `depth` levels below a session nobody
292 * opened as one.
293 *
294 * @param {string | undefined} depth LICH_SUBAGENT_DEPTH
295 */
296function isWorkerDepth(depth) {
297 const levels = Number(depth)
298 return Number.isInteger(levels) && levels > 0
299}
300
301/**
302 * @param {Engine} $
303 * @param {AgentCall} e
304 * @param {(e: AgentCall) => Promise<unknown>} next
305 * @param {string} reason
306 */
307function runNatively($, e, next, reason) {
308 $.ui.toast(`lich: ran "${e.description}" as a Claude Code subagent: ${reason}`)
309 return next(e)
310}
311
312/**
313 * @param {Engine} $
314 * @param {State} state
315 * @param {AgentCall} e
316 * @param {any} next
317 */
318async function runAsSession($, state, e, next) {
319 if (!isDelegable(e, next, state)) return next(e)
320 const [lich, session, cards, depth, langTag] = await Promise.all([
321 $.env.get("LICH_BIN"),
322 $.env.get("LICH_SESSION_ID"),
323 $.env.get("LICH_SUBAGENT_CARDS"),
324 $.env.get("LICH_SUBAGENT_DEPTH"),
325 $.env.get("LICH_PROMPT_LANG"),
326 ])
327 const lang = promptLang(langTag)
328 if (!lich || !session) return next(e)
329 if (cards === CARDS_OFF) {
330 if (!isWorkerDepth(depth)) return next(e)
331 return runNatively($, e, next, "this session is itself a lich subagent card, and lich keeps its subagents inside it")
332 }
333 const bytes = new TextEncoder().encode(e.prompt).length
334 if (bytes > PROMPT_LIMIT_BYTES) {
335 return runNatively($, e, next, `the task is ${bytes} bytes, over lich's ${PROMPT_LIMIT_BYTES}`)
336 }
337
338 const isolated = e.isolation === "worktree"
339 const base = isolated ? await currentBranch($) : ""
340 if (depth === undefined && base.startsWith(WORKER_BRANCH_PREFIX)) return next(e)
341 /** @type {Claim} */
342 const claim = { call: e, lich, base, lang }
343 state.claims.set(e.tool_use_id, claim)
344 try {
345 const native = await next(e)
346 return claim.answer ?? native
347 } finally {
348 state.claims.delete(e.tool_use_id)
349 }
350}
351
352/**
353 * The spawn of an Agent call `runAsSession` claimed, reached only once Claude
354 * Code allowed the call: opens the worker in its place and keeps the answer for
355 * the call. Before lich has the task the native agent starts instead.
356 *
357 * @param {Engine} $
358 * @param {State} state
359 * @param {{ tool_use_id: string }} e
360 * @param {any} next
361 */
362async function openInPlaceOfSpawn($, state, e, next) {
363 const claim = state.claims.get(e.tool_use_id)
364 if (claim === undefined) return next(e)
365 const { call, lich, base, lang } = claim
366 const startedMs = await $.clock.now()
367 const branch = call.isolation === "worktree" ? branchFor(call) : ""
368 /** @type {Opened & { delivery: Report }} */
369 let opened
370 try {
371 opened = await openWorker($, lich, call, branch, base)
372 } catch (error) {
373 if (!next.signal.aborted) return runNatively($, call, () => next(e), messageOf(error))
374 claim.answer = {
375 deny: say(lang, "denyInterrupted", {
376 branchPart: branch ? ` ${say(lang, "onBranch", { branch })}` : "",
377 }),
378 }
379 return claim.answer
380 }
381 claim.answer = answerFor(call, opened, branch, opened.delivery, (await $.clock.now()) - startedMs, lang)
382 if (opened.delivery.status === "pending") trackWorker($, state, lich, opened, call, branch)
383 return { deny: `lich runs this agent as the session "${opened.label}"` }
384}
385
386/**
387 * Shows how many workers this session waits on in the footer's lich line, as
388 * Claude Code's own hint does for its background agents, and clears it at none.
389 *
390 * @param {Engine} $
391 * @param {State} state
392 */
393function showWorkers($, state) {
394 const count = state.workers.size
395 setStatusPart("workers", count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`)
396 $.ui.invalidate("ui.render")
397}
398
399/**
400 * Keeps one watch running while any worker does.
401 *
402 * @param {Engine} $
403 * @param {State} state
404 */
405function watchWorkers($, state) {
406 if (state.watching || state.workers.size === 0) return
407 state.watching = true
408 $.clock.after(WORKER_POLL_MS, () => refreshWorkers($, state))
409}
410
411/**
412 * A worker in this checkout still runs while lich lists it: lich closes one
413 * once it reported, and a turn it ended with a command left in the background
414 * is done without being finished. An isolated worker keeps its card after it
415 * reported, so for it a done turn is the end.
416 *
417 * @param {Worker} worker
418 * @param {Peer[]} peers
419 */
420function stillRuns(worker, peers) {
421 const listed = peers.find((p) => p.name === worker.name)
422 return listed !== undefined && (worker.shared || listed.state !== "done")
423}
424
425/**
426 * Drops the workers lich lists as finished. A list lich could not give leaves
427 * the count as it is until the next period.
428 *
429 * @param {Engine} $
430 * @param {State} state
431 */
432async function refreshWorkers($, state) {
433 state.watching = false
434 try {
435 const { exitCode, stdout } = await $.process.run([state.lich, "sessions", "--json"])
436 /** @type {Peer[] | undefined} */
437 const peers = exitCode === 0 ? parsedOrUndefined(stdout) : undefined
438 const before = state.workers.size
439 if (Array.isArray(peers)) {
440 for (const [id, worker] of state.workers) {
441 if (!stillRuns(worker, peers)) state.workers.delete(id)
442 }
443 }
444 if (state.workers.size !== before) showWorkers($, state)
445 } catch {
446 // A rejection here reaches no hook; the next period asks again.
447 }
448 watchWorkers($, state)
449}
450
451/**
452 * @param {Engine} $
453 * @param {State} state
454 * @param {string} lich
455 * @param {Opened} opened
456 * @param {AgentCall} e
457 * @param {string} branch
458 */
459function trackWorker($, state, lich, opened, e, branch) {
460 state.lich = lich
461 state.workers.set(opened.name, { label: opened.label, name: opened.name, description: e.description, shared: !branch })
462 showWorkers($, state)
463 watchWorkers($, state)
464}
465
466/**
467 * Claude Code's TaskStop for a worker this mod opened: Esc in this session
468 * leaves a background agent running, and TaskStop is how the model stops one
469 * (both measured on 2.1.289). A worker in this checkout is closed, having
470 * nothing of its own to keep; an isolated one has its turn stopped, and its
471 * card and worktree stay for the user. Any other task goes to Claude Code.
472 *
473 * @param {Engine} $
474 * @param {State} state
475 * @param {{ tool: "TaskStop", task_id?: string, shell_id?: string }} e
476 * @param {any} next
477 */
478async function stopWorker($, state, e, next) {
479 const id = e.task_id ?? e.shell_id ?? ""
480 const worker = state.workers.get(id)
481 if (worker === undefined) return next(e)
482 const lang = promptLang(await $.env.get("LICH_PROMPT_LANG"))
483 const argv = worker.shared
484 ? [state.lich, "close", worker.name]
485 : [state.lich, "control", worker.name, "abort"]
486 let outcome
487 try {
488 outcome = await $.process.run(argv)
489 } catch (error) {
490 return { deny: say(lang, "denyStopFailed", { label: worker.label, reason: messageOf(error) }) }
491 }
492 if (outcome.exitCode !== 0) {
493 const reason = outcome.stderr.trim() || say(lang, "lichExited", { code: outcome.exitCode })
494 return { deny: say(lang, "denyStopFailed", { label: worker.label, reason }) }
495 }
496 state.workers.delete(id)
497 showWorkers($, state)
498 return {
499 result: {
500 message: `Successfully stopped task: ${id} (${worker.description})`,
501 task_id: id,
502 task_type: "local_agent",
503 command: worker.description,
504 },
505 }
506}
507
508/** @param {import('claude-code').On} on */
509export function register(on) {
510 /** @type {State} */
511 const state = { interactive: false, workers: new Map(), watching: false, lich: "", claims: new Map() }
512
513 // A `claude -p` started from a tool inside a lich session inherits its
514 // variables; its subagents are its own and stay inside it. The matcher is
515 // also what lets this module hook `session.start` beside mod-control.js:
516 // Claude Code refuses one plugin's second hook on an event with no matcher.
517 on("session.start", { isInteractive: true }, ($, e, next) => {
518 state.interactive = true
519 return next(e)
520 })
521
522 on("tool.call", { tool: "Agent" }, ($, e, next) => runAsSession($, state, e, next))
523 on("agent.spawn", ($, e, next) => openInPlaceOfSpawn($, state, e, next))
524 on("tool.call", { tool: "TaskStop" }, ($, e, next) => stopWorker($, state, e, next))
525}
526hooks/mod-compacting.js 130 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: `busy`
8// while a turn is open, whose own Stop reports `done` later, and `done` when
9// none is. The trigger cannot tell them apart: Claude Code 2.1.300 applies the
10// compaction it runs at an idle prompt, before the prompt cache expires, as
11// `auto`, the same trigger as one inside a turn.
12//
13// A settings hook could not do this: `PostCompact` never fires for a
14// compaction that fails, and two measured ones do (Claude Code 2.1.295). Esc
15// on a manual /compact rejects `next(e)` with "Request was aborted", and an
16// automatic one the engine gives up on rejects it within ~40ms with "reactive
17// compaction did not settle ok" while the turn goes on.
18//
19// Every function that takes `$` is declared here at the top level: the loader
20// refuses a module that hands `$` to a nested function.
21
22// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
23const PLUGIN_VERSION = "0.20.3"
24
25/**
26 * @typedef {import('claude-code').EngineInterface} Engine
27 * @typedef {import('claude-code').SessionCompactInput} Compaction
28 * @typedef {import('claude-code').SessionCompactResult} Compacted
29 * @typedef {{ base: string, token: string, session: string }} Link
30 * @typedef {'done' | 'busy'} Closing
31 * @typedef {{ link?: Link, turnId?: string, sending: Promise<void> }} State
32 */
33
34/**
35 * @param {Engine} $
36 * @returns {Promise<Link | undefined>}
37 */
38async function linkFromEnv($) {
39 const [port, token, session] = await Promise.all([
40 $.env.get("LICH_PORT"),
41 $.env.get("LICH_TOKEN"),
42 $.env.get("LICH_SESSION_ID"),
43 ])
44 if (!port || !token || !session) return undefined
45 return { base: `http://127.0.0.1:${port}`, token, session }
46}
47
48/**
49 * Posts one state, naming the conversation it comes from (`$.session.id()`, the
50 * id SessionStart hands lich) so lich can tell it from an agent CLI nested in
51 * the session (docs/session-state.md, Nested agent CLIs).
52 *
53 * @param {Engine} $
54 * @param {Link} link
55 * @param {'compacting' | Closing} sessionState
56 */
57async function report($, link, sessionState) {
58 try {
59 const conversation = await $.session.id()
60 await $.http.fetch(`${link.base}/hook?token=${link.token}`, {
61 method: "POST",
62 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
63 body: JSON.stringify({ session_id: link.session, state: sessionState, provider_session_id: conversation }),
64 })
65 } catch {
66 // The contract drops a report that cannot be sent; it never retries one.
67 }
68}
69
70/**
71 * Reports beside the chain, one at a time: lich keeps the latest state, so a
72 * closing report landing before its `compacting` would leave the card stuck.
73 *
74 * @param {Engine} $
75 * @param {State} state
76 * @param {Link} link
77 * @param {'compacting' | Closing} sessionState
78 */
79function queue($, state, link, sessionState) {
80 state.sending = state.sending.then(() => report($, link, sessionState))
81}
82
83/**
84 * @param {Engine} $
85 * @param {State} state
86 * @param {Compaction} e
87 * @param {(e: Compaction) => Promise<Compacted>} next
88 */
89async function bracket($, state, e, next) {
90 const link = state.link
91 // A subagent compacting its own transcript leaves the session's alone.
92 if (!link || e.agentId !== undefined) return next(e)
93 queue($, state, link, "compacting")
94 try {
95 return await next(e)
96 } finally {
97 queue($, state, link, state.turnId === undefined ? "done" : "busy")
98 }
99}
100
101/** @param {import('claude-code').On} on */
102export function register(on) {
103 /** @type {State} */
104 const state = { sending: Promise.resolve() }
105
106 // Interactive sessions only: a `claude -p` started from a tool inside a lich
107 // session inherits its variables, and its compactions are not the card's.
108 on("session.start", { isInteractive: true }, async ($, e, next) => {
109 state.link = await linkFromEnv($)
110 return next(e)
111 })
112
113 // `/^/` matches every turn: Claude Code refuses one plugin's second hook on
114 // an event with no matcher, and mod-control.js already holds those two.
115 on("turn.start", { turnId: /^/ }, ($, e, next) => {
116 state.turnId = e.turnId
117 return next(e)
118 })
119
120 on("turn.complete", { turnId: /^/ }, ($, e, next) => {
121 if (e.turnId === state.turnId) state.turnId = undefined
122 return next(e)
123 })
124
125 // Only the two triggers measured. `precompute` runs ahead of time, out of
126 // sight, and a plugin's own `$.session.compact` was never observed.
127 on("session.compact", { trigger: "manual" }, ($, e, next) => bracket($, state, e, next))
128 on("session.compact", { trigger: "auto" }, ($, e, next) => bracket($, state, e, next))
129}
130hooks/edit-guard.js 212 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 { promptLang, say } from "./prompt-text.js"
15import { setStatusPart } from "./status-line.js"
16
17// Long enough to span the other session's turn that made the edit, short
18// enough that a marker from work long finished stops raising notes.
19const RECENT_MS = 10 * 60_000
20const MARKER_DIR = "lich-edits"
21const GIT_TIMEOUT_MS = 5_000
22// The edit's result waits on it, so it is cut well short of the git timeout.
23const LICH_TIMEOUT_MS = 2_000
24
25/**
26 * @typedef {import('claude-code').EngineInterface} Engine
27 * @typedef {{ path: string, session: string, at: number }} Marker
28 * @typedef {{ label: string, id?: string }} Peer
29 */
30
31/** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */
32function hashOf(text) {
33 let hash = 0x811c9dc5
34 for (let i = 0; i < text.length; i++) {
35 hash ^= text.charCodeAt(i)
36 hash = Math.imul(hash, 0x01000193)
37 }
38 return (hash >>> 0).toString(16).padStart(8, "0")
39}
40
41/** @param {string} path */
42function folderOf(path) {
43 const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
44 return cut <= 0 ? path.slice(0, cut + 1) || "." : path.slice(0, cut)
45}
46
47/**
48 * Where the marker for `path` lives, or undefined outside a git repository.
49 *
50 * @param {Engine} $
51 * @param {string} path
52 */
53async function markerPathOf($, path) {
54 const git = await $.process.run(["git", "-C", folderOf(path), "rev-parse", "--absolute-git-dir"], {
55 timeoutMs: GIT_TIMEOUT_MS,
56 })
57 if (git.exitCode !== 0) return undefined
58 return `${git.stdout.trim()}/${MARKER_DIR}/${hashOf(path)}`
59}
60
61/**
62 * The marker another session left on `path`, if any.
63 *
64 * @param {Engine} $
65 * @param {string} markerPath
66 * @param {string} path
67 * @returns {Promise<Marker | undefined>}
68 */
69async function readMarker($, markerPath, path) {
70 if (!(await $.fs.exists(markerPath))) return undefined
71 /** @type {Marker} */
72 const marker = JSON.parse(await $.fs.read(markerPath))
73 return marker.path === path ? marker : undefined
74}
75
76/**
77 * @param {Marker | undefined} marker
78 * @param {string} session
79 * @param {number} now
80 * @returns {marker is Marker}
81 */
82function isNews(marker, session, now) {
83 return !!marker && marker.session !== session && now - marker.at <= RECENT_MS
84}
85
86/**
87 * The label on the card of the session `id`, or undefined when lich cannot say.
88 *
89 * A lich older than the `id` field in `lich sessions --json` lists none, and
90 * the session goes unnamed there.
91 *
92 * @param {Engine} $
93 * @param {string} id
94 */
95async function labelOf($, id) {
96 const lich = await $.env.get("LICH_BIN")
97 if (!lich) return undefined
98 const { exitCode, stdout } = await $.process.run([lich, "sessions", "--json"], { timeoutMs: LICH_TIMEOUT_MS })
99 if (exitCode !== 0) return undefined
100 /** @type {Peer[]} */
101 const peers = JSON.parse(stdout)
102 return peers.find((peer) => peer.id === id)?.label
103}
104
105/**
106 * @param {Marker} marker
107 * @param {string} who
108 * @param {number} now
109 * @param {string} lang
110 */
111function noteFor(marker, who, now, lang) {
112 const minutes = Math.round((now - marker.at) / 60_000)
113 const ago = minutes === 0 ? say(lang, "editNoteJustNow") : say(lang, "editNoteAgo", { minutes })
114 return say(lang, "editNote", { path: marker.path, who, at: new Date(marker.at).toISOString(), ago })
115}
116
117/**
118 * The session that left `marker`, by label when lich can say which.
119 *
120 * @param {Engine} $
121 * @param {Marker} marker
122 */
123async function whoMarked($, marker) {
124 let label
125 try {
126 label = await labelOf($, marker.session)
127 } catch (error) {
128 logFailure($, error)
129 }
130 return label ? `"${label}"` : marker.session
131}
132
133/** The marker the status line shows, so an older one's timer does not clear a newer one. @type {Marker | undefined} */
134let shownMarker
135
136/**
137 * Tells the user too, in the status line, until the marker stops being news.
138 * The note only reaches the model.
139 *
140 * @param {Engine} $
141 * @param {Marker} marker
142 * @param {string} who
143 */
144function showEditBy($, marker, who) {
145 shownMarker = marker
146 const name = marker.path.slice(Math.max(marker.path.lastIndexOf("/"), marker.path.lastIndexOf("\\")) + 1)
147 setStatusPart("edits", `${name} also edited by ${who}`)
148 $.ui.invalidate("ui.render")
149}
150
151/**
152 * @param {Engine} $
153 * @param {Marker} marker
154 */
155function clearEditBy($, marker) {
156 if (shownMarker !== marker) return
157 shownMarker = undefined
158 setStatusPart("edits", undefined)
159 $.ui.invalidate("ui.render")
160}
161
162/** @param {Engine} $ @param {unknown} error */
163function logFailure($, error) {
164 $.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`)
165}
166
167/**
168 * @param {Engine} $
169 * @param {{ file_path?: string, notebook_path?: string }} e
170 * @param {(e: any) => Promise<any>} next
171 */
172async function guard($, e, next) {
173 const session = await $.env.get("LICH_SESSION_ID")
174 const path = e.file_path ?? e.notebook_path
175 if (!session || !path) return next(e)
176
177 let markerPath
178 let previous
179 try {
180 markerPath = await markerPathOf($, path)
181 previous = markerPath && (await readMarker($, markerPath, path))
182 } catch (error) {
183 logFailure($, error)
184 return next(e)
185 }
186
187 const result = await next(e)
188 if (!markerPath || result.deny !== undefined) return result
189
190 let note
191 try {
192 const now = await $.clock.now()
193 if (isNews(previous, session, now)) {
194 const who = await whoMarked($, previous)
195 note = noteFor(previous, who, now, promptLang(await $.env.get("LICH_PROMPT_LANG")))
196 showEditBy($, previous, who)
197 $.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous))
198 }
199 if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now }))
200 } catch (error) {
201 logFailure($, error)
202 }
203 return note ? { ...result, context: [...(result.context ?? []), note] } : result
204}
205
206/** @param {import('claude-code').On} on */
207export function register(on) {
208 on("tool.call", { tool: "Edit" }, guard)
209 on("tool.call", { tool: "Write" }, guard)
210 on("tool.call", { tool: "NotebookEdit" }, guard)
211}
212hooks/mod-control.js 260 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
15import { promptLang, say } from "./prompt-text.js"
16
17// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
18const PLUGIN_VERSION = "0.20.3"
19
20const SETTLE_WHEN_IDLE = new Set(["prompt", "command"])
21
22// The contract's client rule: after a network error or a 5xx, wait 1 second,
23// doubling up to 10, and start over after a 200.
24const FIRST_BACKOFF_MS = 1000
25const MAX_BACKOFF_MS = 10000
26
27// The levels `turn.step` takes. The engine checks an effort only when the next
28// model request goes out, long after the command was acked `ok`, and then skips
29// the hook, so a level outside these is refused here, where lich hears of it.
30const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"])
31
32// The contract cuts an answer at this many UTF-16 units, which keeps an ack
33// under lich's 64 KiB body limit; a fork has no length bound of its own.
34const ANSWER_LIMIT = 16000
35
36/**
37 * @typedef {import('claude-code').EngineInterface} Engine
38 * @typedef {{ status: string, summary: string }} Notification
39 * @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort
40 * @typedef {{ id: string, kind: string, text?: string, notification?: Notification, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command
41 * @typedef {{
42 * base: string,
43 * token: string,
44 * session: string,
45 * backoffMs: number,
46 * applying: Promise<void>,
47 * }} Link
48 * @typedef {{
49 * link?: Link,
50 * turnId?: string,
51 * model?: string,
52 * effort?: Effort,
53 * lang?: string,
54 * }} State
55 */
56
57/**
58 * @param {Engine} $
59 * @returns {Promise<Link | undefined>}
60 */
61async function linkFromEnv($) {
62 const [port, token, session] = await Promise.all([
63 $.env.get("LICH_PORT"),
64 $.env.get("LICH_TOKEN"),
65 $.env.get("LICH_SESSION_ID"),
66 ])
67 if (!port || !token || !session) return undefined
68 return { base: `http://127.0.0.1:${port}`, token, session, backoffMs: 0, applying: Promise.resolve() }
69}
70
71/**
72 * One poll, then the next one scheduled: at once after a 200, later after a
73 * failure, never after a 404 (a lich older than the contract).
74 *
75 * @param {Engine} $
76 * @param {State} state
77 * @param {Link} link
78 */
79async function poll($, state, link) {
80 const url = `${link.base}/mod/commands?token=${link.token}&session_id=${link.session}`
81 /** @type {Command[]} */
82 let commands
83 try {
84 const response = await $.http.fetch(url, { headers: { "X-Lich-Plugin": PLUGIN_VERSION } })
85 if (response.status === 404) return
86 if (!response.ok) throw new Error(`lich answered ${response.status}`)
87 commands = JSON.parse(response.text)
88 } catch {
89 link.backoffMs = Math.min(link.backoffMs * 2 || FIRST_BACKOFF_MS, MAX_BACKOFF_MS)
90 $.clock.after(link.backoffMs, () => void poll($, state, link))
91 return
92 }
93 link.backoffMs = 0
94 for (const command of commands) {
95 // An answer can take a minute and changes nothing the other commands
96 // depend on, so it is not queued behind them, nor they behind it.
97 if (command.kind === "ask") {
98 void applyAndAck($, state, link, command)
99 continue
100 }
101 link.applying = link.applying.then(() => {
102 const acked = applyAndAck($, state, link, command)
103 // A prompt or a slash command settles only once the session is idle and
104 // it ran. Waiting on one would hold an abort meant for the running turn
105 // until that turn ended, and a prompt's abort would then cancel the turn
106 // the prompt started instead.
107 if (!SETTLE_WHEN_IDLE.has(command.kind)) return acked
108 })
109 }
110 $.clock.after(0, () => void poll($, state, link))
111}
112
113/**
114 * Applies one command and acks it. Every command but a `prompt` or a `command`
115 * holds the next one until its ack is answered: an `abort` ack makes lich end
116 * the turn it has open, so one landing after the next `prompt` opened a turn
117 * would end that turn instead.
118 *
119 * @param {Engine} $
120 * @param {State} state
121 * @param {Link} link
122 * @param {Command} command
123 */
124async function applyAndAck($, state, link, command) {
125 /** @type {{ ok: boolean, error?: string, answer?: string }} */
126 let outcome
127 try {
128 outcome = { ok: true, ...(await apply($, state, command)) }
129 } catch (error) {
130 outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
131 }
132 try {
133 await $.http.fetch(`${link.base}/mod/acks?token=${link.token}`, {
134 method: "POST",
135 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
136 body: JSON.stringify({ session_id: link.session, id: command.id, kind: command.kind, ...outcome }),
137 })
138 } catch {
139 // The contract drops an ack that cannot be sent: lich never resends the command.
140 }
141}
142
143/**
144 * @param {Engine} $
145 * @param {State} state
146 * @param {Command} command
147 * @returns {Promise<{ answer: string } | undefined>} what the ack carries beyond `ok`
148 */
149async function apply($, state, command) {
150 switch (command.kind) {
151 case "prompt": {
152 const text = command.text ?? ""
153 const submitted = await $.prompt.submit(
154 command.notification ? { text: notificationText(command.notification, text), asUser: true } : { text },
155 )
156 if (submitted.drop !== undefined) throw new Error(submitted.drop)
157 return
158 }
159 case "abort":
160 if (state.turnId === undefined) throw new Error("no turn is running")
161 await $.turn.abort({ turnId: state.turnId })
162 return
163 case "model":
164 state.model = command.model
165 return
166 case "effort":
167 if (command.effort !== undefined && !EFFORTS.has(command.effort)) throw new Error("unknown effort")
168 state.effort = /** @type {Effort | undefined} */ (command.effort)
169 return
170 case "command":
171 await $.command.run({ command: command.name ?? "", args: command.args })
172 return
173 case "ask":
174 return { answer: await answer($, command.question ?? "", state.lang) }
175 default:
176 throw new Error("unknown kind")
177 }
178}
179
180/**
181 * A prompt lich marks as news from outside the session, in the shape Claude
182 * Code gives its own background agent's completion: it shows `summary` as one
183 * line and the model reads the whole text. `text` goes in unescaped because the
184 * model reads it byte for byte, entities included; Claude Code decodes them in
185 * the line it shows (measured on 2.1.289).
186 *
187 * @param {Notification} notification
188 * @param {string} text
189 */
190function notificationText(notification, text) {
191 return (
192 `<task-notification>\n<status>${escapeXml(notification.status)}</status>\n` +
193 `<summary>${escapeXml(notification.summary)}</summary>\n<result>${text}</result>\n</task-notification>`
194 )
195}
196
197/** @param {string} value */
198function escapeXml(value) {
199 return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
200}
201
202/**
203 * The session's answer to a side question: a fork of its own conversation, cut
204 * at ANSWER_LIMIT. A fork with no answer throws its reason, which the ack
205 * carries as its error.
206 *
207 * @param {Engine} $
208 * @param {string} question
209 * @param {string} lang
210 */
211async function answer($, question, lang) {
212 // The preamble goes before the question: without it, a fork made while a turn
213 // runs reaches for a tool, is refused (a fork has none) and answers in a second
214 // request, at twice the latency and the uncached tokens: measured on Claude
215 // Code 2.1.289.
216 const reply = await $.model.fork({ prompt: say(lang, "askPreamble") + question })
217 if (!reply.isAnswered) {
218 throw new Error(reply.reason === "api-error" ? `api-error ${reply.status} ${reply.error}` : reply.reason)
219 }
220 if (reply.text.length <= ANSWER_LIMIT) return reply.text
221 return `${reply.text.slice(0, ANSWER_LIMIT)}\n[truncated]`
222}
223
224/** @param {import('claude-code').On} on */
225export function register(on) {
226 /** @type {State} */
227 const state = {}
228
229 on("session.start", async ($, e, next) => {
230 // A `claude -p` holds its run open until a parked poll returns, up to 25
231 // seconds, and one started from a tool inside a lich session inherits that
232 // session's variables, so it would also take the parent's commands.
233 if (e.isInteractive && state.link === undefined) {
234 state.link = await linkFromEnv($)
235 state.lang = promptLang(await $.env.get("LICH_PROMPT_LANG"))
236 const link = state.link
237 if (link) $.clock.after(0, () => void poll($, state, link))
238 }
239 return next(e)
240 })
241
242 on("turn.start", ($, e, next) => {
243 state.turnId = e.turnId
244 return next(e)
245 })
246
247 on("turn.complete", ($, e, next) => {
248 if (e.turnId === state.turnId) state.turnId = undefined
249 return next(e)
250 })
251
252 on("turn.step", async function* ($, e, next) {
253 return yield* next({
254 ...e,
255 ...(state.model !== undefined && { model: state.model }),
256 ...(state.effort !== undefined && { effort: state.effort }),
257 })
258 })
259}
260hooks/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.20.3"
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.20.3"
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 159 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 a tool it already reaches for, and the mod answers
7// that tool's refusal by queueing the command: the Skill tool, which refuses a
8// built-in ("compact is a built-in CLI command, not a skill"), and lich's
9// control_session, which refuses the session's own ("... is this session, and
10// a session cannot control itself"), told apart by `lich whoami`. A tool of the mod's own is not an option:
11// Claude Code lists it as `mcp__lich__<name>`, and `$.tool.register` refuses
12// that in a session whose MCP config already has lich's own server under that
13// name, which is every lich session (measured on 2.1.295).
14//
15// Every function that takes `$` is declared here at the top level: the loader
16// refuses a module that hands `$` to a nested function.
17
18import { promptLang, say } from "./prompt-text.js"
19
20// Run through a mod, these save what they set as the default for every new
21// session (measured on Claude Code 2.1.288 and 2.1.289).
22const SAVES_A_DEFAULT = new Set(["model", "effort"])
23
24// The name Claude Code gives lich's tool (measured on 2.1.295 with lich's MCP
25// server).
26const CONTROL_TOOL = "mcp__lich__control_session"
27
28const WHOAMI_TIMEOUT_MS = 2_000
29
30// `list_sessions` leaves this session out, so the model does not know its own
31// label; the note names it by `LICH_SESSION_ID`, which lich takes as a target too.
32/**
33 * @typedef {import('claude-code').EngineInterface} Engine
34 * @typedef {{ sessionId: string | undefined }} State
35 */
36
37/**
38 * Queues `/name args` for when the session is idle. `$.command.run` rejects
39 * inside a hook the turn is waiting on, so it is called from a timer; a
40 * command that then fails has no turn left to answer, so its error goes to a
41 * toast.
42 *
43 * @param {Engine} $
44 * @param {string} name
45 * @param {string} args
46 */
47async function runCommand($, name, args) {
48 try {
49 await $.command.run({ command: name, args })
50 } catch (error) {
51 $.ui.toast(`/${name} did not run: ${error instanceof Error ? error.message : String(error)}`)
52 }
53}
54
55/**
56 * @param {Engine} $
57 * @param {string} name
58 */
59async function isBuiltin($, name) {
60 const commands = await $.command.list()
61 return commands.some((command) => command.name === name && command.source === "builtin")
62}
63
64/**
65 * Queues the built-in `name` the model asked for, or refuses it. Undefined
66 * when `name` is no built-in, so the caller answers with the tool's own reply.
67 *
68 * @param {Engine} $
69 * @param {string} name
70 * @param {string} args
71 */
72async function queueBuiltin($, name, args) {
73 if (SAVES_A_DEFAULT.has(name)) {
74 return {
75 deny:
76 say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "denyDefaultSaved", { name }),
77 }
78 }
79 if (!(await isBuiltin($, name))) return undefined
80 $.clock.after(0, () => runCommand($, name, args))
81 return `/${[name, args].filter(Boolean).join(" ")}`
82}
83
84/**
85 * Whether `target` names this session: its id, or the label or name
86 * `lich whoami` prints for it, which lich matches without regard to case.
87 *
88 * @param {Engine} $
89 * @param {string} target
90 * @param {string} sessionId
91 */
92async function isThisSession($, target, sessionId) {
93 if (target === sessionId) return true
94 const lich = await $.env.get("LICH_BIN")
95 if (!lich) return false
96 const { exitCode, stdout } = await $.process.run([lich, "whoami", "--json"], { timeoutMs: WHOAMI_TIMEOUT_MS })
97 if (exitCode !== 0) return false
98 const self = JSON.parse(stdout)
99 const wanted = target.toLowerCase()
100 return [self.label, self.name].some((name) => typeof name === "string" && name.toLowerCase() === wanted)
101}
102
103/** @param {string} name */
104const bareName = (name) => name.trim().replace(/^\//, "")
105
106/** @param {import('claude-code').On} on */
107export function register(on) {
108 /** @type {State} */
109 const state = { sessionId: undefined }
110
111 // Interactive lich sessions only: outside lich the plugin adds nothing, and a
112 // `claude -p` started from a tool inside a lich session inherits its variables.
113 on("session.start", { isInteractive: true }, async ($, e, next) => {
114 state.sessionId = await $.env.get("LICH_SESSION_ID")
115 if (state.sessionId) $.ui.invalidate("tool.describe")
116 return next(e)
117 })
118
119 on("tool.describe", { tool: "Skill" }, async ($, e, next) => {
120 const described = await next(e)
121 if (!state.sessionId) return described
122 return { ...described, description: described.description + say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "skillNote") }
123 })
124
125 // The Skill tool runs first: a skill, or a built-in it serves as a prompt
126 // (/init, /review), stays its own. Only what it refused is taken.
127 on("tool.call", { tool: "Skill" }, async ($, e, next) => {
128 const native = await next(e)
129 if (!state.sessionId || !("isError" in native && native.isError)) return native
130 const name = bareName(e.skill)
131 const queued = await queueBuiltin($, name, e.args?.trim() ?? "")
132 if (queued === undefined) return native
133 if (typeof queued !== "string") return queued
134 return {
135 result: { success: true, commandName: name },
136 context: [say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "skillQueued", { queued })],
137 }
138 })
139
140 on("tool.describe", { tool: CONTROL_TOOL }, async ($, e, next) => {
141 const described = await next(e)
142 if (!state.sessionId) return described
143 return { ...described, description: described.description + say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "controlNote", { sessionId: state.sessionId }) }
144 })
145
146 // lich answers first: a command for another session is its own. Only its
147 // refusal of this session as the target is taken.
148 on("tool.call", { tool: CONTROL_TOOL }, async ($, e, next) => {
149 const native = await next(e)
150 if (!state.sessionId || e.action !== "command" || typeof e.value !== "string") return native
151 if (!("isError" in native && native.isError)) return native
152 if (!(await isThisSession($, e.session, state.sessionId))) return native
153 const queued = await queueBuiltin($, bareName(e.value), e.args?.trim() ?? "")
154 if (queued === undefined) return native
155 if (typeof queued !== "string") return queued
156 return { result: say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "controlQueued", { queued }) }
157 })
158}
159hooks/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 167 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 main-loop turn reports only when its `classic.Stop` lists no background
15// task (a shell, subagent, monitor or workflow still running means the turn
16// handed work to the background, and the turn Claude Code resumes in once it
17// finishes is the one that reports). Its `turn.complete` then says what: an
18// answer that is not blank is the worker's answer, a blank one is
19// `unanswered: "blank"`. Measured on Claude Code 2.1.289: `classic.Stop` fires
20// on the main loop only, before `turn.complete`, and its
21// `last_assistant_message` equals the completion's `answer`; an aborted turn
22// fires no Stop.
23//
24// A refused turn and one an API error ended report nothing, though the
25// contract has `unanswered: "refusal"` and `"error"` for them. Measured on
26// Claude Code 2.1.296, both end through `StopFailure` (a refusal with error
27// `invalid_request`) and then `turn.complete`, with no `Stop`, and neither
28// carries `background_tasks`: the mod cannot tell a failed turn with nothing
29// running from one whose background work resumes it later and answers then,
30// so reporting would answer the errand twice.
31//
32// Every function that takes `$` is declared here at the top level: the loader
33// refuses a module that hands `$` to a nested function.
34
35// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
36const PLUGIN_VERSION = "0.20.3"
37
38// The value a lich older than LICH_SUBAGENT_DEPTH spawns every `--subagent`
39// session with.
40const CARDS_OFF = "off"
41
42// The contract cuts an answer at this many UTF-16 units, as mod-control.js
43// cuts an ask's, which keeps the body under lich's 64 KiB limit.
44const ANSWER_LIMIT = 16000
45
46/**
47 * @typedef {import('claude-code').EngineInterface} Engine
48 * @typedef {{ base: string, token: string, session: string }} Link
49 * @typedef {{ idle: boolean, text: string }} Stop
50 * @typedef {{ link?: Link, stop?: Stop }} State
51 * @typedef {{ text: string } | { unanswered: "blank" }} Report
52 */
53
54/**
55 * @param {string | undefined} depth LICH_SUBAGENT_DEPTH
56 * @param {string | undefined} cards LICH_SUBAGENT_CARDS
57 */
58function isWorker(depth, cards) {
59 if (depth === undefined) return cards === CARDS_OFF
60 const levels = Number(depth)
61 return Number.isInteger(levels) && levels > 0
62}
63
64/**
65 * The link to lich, for a worker only.
66 *
67 * @param {Engine} $
68 * @returns {Promise<Link | undefined>}
69 */
70async function workerLink($) {
71 const [port, token, session, depth, cards] = await Promise.all([
72 $.env.get("LICH_PORT"),
73 $.env.get("LICH_TOKEN"),
74 $.env.get("LICH_SESSION_ID"),
75 $.env.get("LICH_SUBAGENT_DEPTH"),
76 $.env.get("LICH_SUBAGENT_CARDS"),
77 ])
78 if (!port || !token || !session || !isWorker(depth, cards)) return undefined
79 return { base: `http://127.0.0.1:${port}`, token, session }
80}
81
82/** @param {string} text */
83function cut(text) {
84 if (text.length <= ANSWER_LIMIT) return text
85 return `${text.slice(0, ANSWER_LIMIT)}\n[truncated]`
86}
87
88/**
89 * What a finished main-loop turn reports, if anything. The Stop must be the
90 * turn's own: an answer's text equals the completion's, a blank Stop comes
91 * with a blank completion.
92 *
93 * @param {Stop | undefined} stop the turn's main-loop `classic.Stop`
94 * @param {import('claude-code').TurnCompleteInput} e
95 * @returns {Report | undefined}
96 */
97function reportOf(stop, e) {
98 if (stop === undefined || !stop.idle) return undefined
99 if (e.reason !== "answer") return undefined
100 if (stop.text.trim() === "") return e.answer.trim() === "" ? { unanswered: "blank" } : undefined
101 return stop.text === e.answer ? { text: cut(stop.text) } : undefined
102}
103
104/**
105 * Posts one report beside the chain. One that cannot be sent is dropped: the
106 * worker's card still holds it, and a second copy of a report is worse than
107 * none.
108 *
109 * @param {Engine} $
110 * @param {Link} link
111 * @param {Report} body
112 */
113async function report($, link, body) {
114 try {
115 await $.http.fetch(`${link.base}/mod/answer?token=${link.token}`, {
116 method: "POST",
117 headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION },
118 body: JSON.stringify({ session_id: link.session, ...body }),
119 })
120 } catch {
121 // The contract's client rule: a report that cannot be sent is dropped.
122 }
123}
124
125/**
126 * @param {Engine} $
127 * @param {State} state
128 * @param {import('claude-code').TurnCompleteInput} e
129 * @param {(e: import('claude-code').TurnCompleteInput) => unknown} next
130 */
131function settleTurn($, state, e, next) {
132 const stop = state.stop
133 state.stop = undefined
134 const link = state.link
135 const body = link !== undefined && e.agentId === undefined ? reportOf(stop, e) : undefined
136 if (link !== undefined && body !== undefined) void report($, link, body)
137 return next(e)
138}
139
140/** @param {import('claude-code').On} on */
141export function register(on) {
142 /** @type {State} */
143 const state = {}
144
145 // Interactive sessions only: a `claude -p` started from a tool inside a
146 // worker inherits its variables, and its answers are not the worker's.
147 on("session.start", { isInteractive: true }, async ($, e, next) => {
148 state.link = await workerLink($)
149 return next(e)
150 })
151
152 on("classic.Stop", ($, e, next) => {
153 state.stop = { idle: (e.background_tasks ?? []).length === 0, text: e.last_assistant_message ?? "" }
154 return next(e)
155 })
156
157 // One hook per reason a turn ends with: the matchers keep them beside
158 // mod-control.js's own turn.complete hook, which has none, and Claude Code
159 // refuses one plugin's second hook on an event with no matcher. The refused,
160 // aborted and failed ones report nothing; they are here so every turn drops
161 // the Stop it read, and a later turn never inherits it.
162 on("turn.complete", { reason: "answer" }, ($, e, next) => settleTurn($, state, e, next))
163 on("turn.complete", { reason: "refusal" }, ($, e, next) => settleTurn($, state, e, next))
164 on("turn.complete", { reason: "error" }, ($, e, next) => settleTurn($, state, e, next))
165 on("turn.complete", { reason: "aborted" }, ($, e, next) => settleTurn($, state, e, next))
166}
167hooks/prompt-text.js 217 lines1// The agent-facing text the hooks add to a session, in every language lich can
2// talk to its agents in. lich exports the user's prompt language into every
3// session as LICH_PROMPT_LANG (`internal/prompt` there); an empty or unknown
4// value is English, as it is for lich itself.
5//
6// A text is a template with `{name}` slots. Tool and command names, the `[lich]`
7// marker and the status enums are literal in every locale: the model matches on
8// them, and so does the contract.
9
10/** @type {Record<string, string>} */
11export const en = {
12 askPreamble:
13 "This is a side question asked from outside your turn, while you work. It does not " +
14 "interrupt your turn and your answer is not added to the conversation. Tools are " +
15 "unavailable: do not call any tool. Answer from what the conversation already holds, " +
16 "in plain text, briefly. Question: ",
17 onBranch: "on branch {branch}",
18 whereBranch: "on branch {branch} in {path}, not in this checkout",
19 whereShared: "in this same checkout, {path}, and edits its files as you do",
20 backgrounded:
21 'The agent runs as the lich session "{label}", {where}. ' +
22 "Its full report arrives at this prompt on its own as a [lich] note, not as a task notification, so there " +
23 "is no need to call wait_for_answer (it still works). SendMessage cannot reach that session; " +
24 "send_to_session or lich send can.",
25 completedBranch:
26 'The work is on branch {branch} in {path} (lich session "{label}"), not in this checkout. ' +
27 "Reach that session with send_to_session or lich send, not SendMessage.",
28 completedShared:
29 'The work is in this same checkout, {path} (lich session "{label}"). ' +
30 "Reach that session with send_to_session or lich send, not SendMessage.",
31 denyNeverReached: 'the task never reached "{label}" ({status}): open its card.',
32 denyAnswered: 'lich answered "{status}" about "{label}"{branchPart}: open its card.',
33 denyInterrupted:
34 "interrupted; a lich session{branchPart} may already have the task, and a report it sends arrives here as a [lich] note.",
35 denyStopFailed: 'lich could not stop "{label}": {reason}',
36 lichExited: "lich exited {code}",
37 editNoteJustNow: "under a minute ago",
38 editNoteAgo: "{minutes} min ago",
39 editNote:
40 "{path} was also edited by the lich session {who} at {at} ({ago}), which shares this checkout and may " +
41 "still be working on it. Re-read the file before building on it, and coordinate with that session through " +
42 "send_to_session or lich send rather than undoing its change.",
43 skillNote:
44 "\n\nIn this session a built-in slash command (compact, clear, and the like) can be named here too, " +
45 "when the user asks you to run it: it is queued and runs once your turn ends, so end your turn right after.",
46 controlNote:
47 "\n\nIn this session, action command is accepted with this session itself as the target: pass session " +
48 '"{sessionId}". The command is queued and runs once your turn ends, so end your turn right after.',
49 denyDefaultSaved:
50 "/{name} run from inside the session would be saved as the default for every new Claude Code " +
51 "session. Ask the user to set it from lich, whose {name} override applies to this session only.",
52 skillQueued: "{queued} is queued and runs once this turn ends. End the turn now.",
53 controlQueued: "{queued} is queued on this session and runs once this turn ends. End the turn now.",
54}
55
56/** @type {Record<string, string>} */
57export const ptBR = {
58 askPreamble:
59 "Esta é uma pergunta lateral feita de fora do seu turno, enquanto você trabalha. Ela não " +
60 "interrompe o seu turno e a sua resposta não entra na conversa. As ferramentas estão " +
61 "indisponíveis: não chame nenhuma. Responda com o que a conversa já contém, " +
62 "em texto simples e de forma breve. Pergunta: ",
63 onBranch: "na branch {branch}",
64 whereBranch: "na branch {branch} em {path}, não neste checkout",
65 whereShared: "neste mesmo checkout, {path}, e edita os arquivos dele como você",
66 backgrounded:
67 'O agente roda como a sessão lich "{label}", {where}. ' +
68 "O relatório completo chega neste prompt por conta própria como uma nota [lich], não como task notification, " +
69 "então não é preciso chamar wait_for_answer (ele ainda funciona). SendMessage não alcança essa sessão; " +
70 "send_to_session ou lich send alcançam.",
71 completedBranch:
72 'O trabalho está na branch {branch} em {path} (sessão lich "{label}"), não neste checkout. ' +
73 "Fale com essa sessão por send_to_session ou lich send, não por SendMessage.",
74 completedShared:
75 'O trabalho está neste mesmo checkout, {path} (sessão lich "{label}"). ' +
76 "Fale com essa sessão por send_to_session ou lich send, não por SendMessage.",
77 denyNeverReached: 'a tarefa nunca chegou a "{label}" ({status}): abra o card dela.',
78 denyAnswered: 'o lich respondeu "{status}" sobre "{label}"{branchPart}: abra o card dela.',
79 denyInterrupted:
80 "interrompido; uma sessão lich{branchPart} pode já ter a tarefa, e o relatório que ela enviar chega aqui como nota [lich].",
81 denyStopFailed: 'o lich não conseguiu parar "{label}": {reason}',
82 lichExited: "o lich saiu com {code}",
83 editNoteJustNow: "há menos de um minuto",
84 editNoteAgo: "há {minutes} min",
85 editNote:
86 "{path} também foi editado pela sessão lich {who} em {at} ({ago}), que divide este checkout e pode " +
87 "ainda estar trabalhando nele. Releia o arquivo antes de continuar a partir dele e combine com essa sessão por " +
88 "send_to_session ou lich send em vez de desfazer a mudança dela.",
89 skillNote:
90 "\n\nNesta sessão um slash command embutido (compact, clear e afins) também pode ser nomeado aqui, " +
91 "quando o usuário pedir para você executá-lo: ele entra na fila e roda quando o seu turno terminar, então encerre o turno logo em seguida.",
92 controlNote:
93 "\n\nNesta sessão, a ação command aceita a própria sessão como alvo: passe session " +
94 '"{sessionId}". O comando entra na fila e roda quando o seu turno terminar, então encerre o turno logo em seguida.',
95 denyDefaultSaved:
96 "/{name} executado de dentro da sessão seria salvo como padrão de toda nova sessão do Claude Code. " +
97 "Peça ao usuário para definir pelo lich, cujo override de {name} vale só para esta sessão.",
98 skillQueued: "{queued} está na fila e roda quando este turno terminar. Encerre o turno agora.",
99 controlQueued: "{queued} está na fila nesta sessão e roda quando este turno terminar. Encerre o turno agora.",
100}
101
102/** @type {Record<string, string>} */
103export const zhCN = {
104 askPreamble:
105 "这是在你的回合之外、你工作期间提出的旁路问题。它不会打断你的回合,你的回答也不会加入对话。" +
106 "工具不可用:不要调用任何工具。请仅根据对话中已有的内容,用纯文本简要回答。问题:",
107 onBranch: "在分支 {branch} 上",
108 whereBranch: "在分支 {branch} 上的 {path} 中,不在当前 checkout 里",
109 whereShared: "在同一个 checkout {path} 中,并且和你一样编辑其中的文件",
110 backgrounded:
111 'agent 作为 lich 会话 "{label}" 运行,{where}。' +
112 "它的完整报告会作为 [lich] 备注自动出现在此提示中,而不是 task notification,因此无需调用 " +
113 "wait_for_answer(调用仍然有效)。SendMessage 无法联系该会话;send_to_session 或 lich send 可以。",
114 completedBranch:
115 '工作位于分支 {branch} 上的 {path}(lich 会话 "{label}"),不在当前 checkout 里。' +
116 "请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。",
117 completedShared:
118 '工作位于同一个 checkout {path}(lich 会话 "{label}")中。' +
119 "请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。",
120 denyNeverReached: '任务从未送达 "{label}"({status}):请打开它的卡片。',
121 denyAnswered: 'lich 针对 "{label}"{branchPart} 返回了 "{status}":请打开它的卡片。',
122 denyInterrupted:
123 "已中断;某个 lich 会话{branchPart}可能已经拿到任务,它发送的报告会作为 [lich] 备注出现在这里。",
124 denyStopFailed: 'lich 无法停止 "{label}":{reason}',
125 lichExited: "lich 退出,退出码 {code}",
126 editNoteJustNow: "不到一分钟前",
127 editNoteAgo: "{minutes} 分钟前",
128 editNote:
129 "{path} 也被 lich 会话 {who} 在 {at}({ago})编辑过,该会话与你共用此 checkout,可能仍在处理它。" +
130 "在基于该文件继续之前请重新读取,并通过 send_to_session 或 lich send 与该会话协调,不要撤销它的改动。",
131 skillNote:
132 "\n\n在此会话中,内置斜杠命令(compact、clear 等)也可以在这里指定,只要用户要求你运行它:" +
133 "它会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。",
134 controlNote:
135 "\n\n在此会话中,action 为 command 时可以把会话自身作为目标:传入 session " +
136 '"{sessionId}"。该命令会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。',
137 denyDefaultSaved:
138 "在会话内部运行 /{name} 会把它保存为每个新 Claude Code 会话的默认值。" +
139 "请让用户通过 lich 来设置,lich 的 {name} 覆盖只对此会话生效。",
140 skillQueued: "{queued} 已进入队列,将在本回合结束后运行。现在请结束本回合。",
141 controlQueued: "{queued} 已在此会话中排队,将在本回合结束后运行。现在请结束本回合。",
142}
143
144/** @type {Record<string, string>} */
145export const es = {
146 askPreamble:
147 "Esta es una pregunta lateral hecha desde fuera de tu turno, mientras trabajas. No " +
148 "interrumpe tu turno y tu respuesta no se añade a la conversación. Las herramientas no están " +
149 "disponibles: no llames a ninguna. Responde con lo que la conversación ya contiene, " +
150 "en texto plano y de forma breve. Pregunta: ",
151 onBranch: "en la rama {branch}",
152 whereBranch: "en la rama {branch} en {path}, no en este checkout",
153 whereShared: "en este mismo checkout, {path}, y edita sus archivos igual que tú",
154 backgrounded:
155 'El agente se ejecuta como la sesión lich "{label}", {where}. ' +
156 "Su informe completo llega a este prompt por sí solo como una nota [lich], no como task notification, así que no " +
157 "hace falta llamar a wait_for_answer (sigue funcionando). SendMessage no puede llegar a esa sesión; " +
158 "send_to_session o lich send sí.",
159 completedBranch:
160 'El trabajo está en la rama {branch} en {path} (sesión lich "{label}"), no en este checkout. ' +
161 "Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.",
162 completedShared:
163 'El trabajo está en este mismo checkout, {path} (sesión lich "{label}"). ' +
164 "Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.",
165 denyNeverReached: 'la tarea nunca llegó a "{label}" ({status}): abre su tarjeta.',
166 denyAnswered: 'lich respondió "{status}" sobre "{label}"{branchPart}: abre su tarjeta.',
167 denyInterrupted:
168 "interrumpido; puede que una sesión lich{branchPart} ya tenga la tarea, y el informe que envíe llega aquí como una nota [lich].",
169 denyStopFailed: 'lich no pudo detener "{label}": {reason}',
170 lichExited: "lich terminó con {code}",
171 editNoteJustNow: "hace menos de un minuto",
172 editNoteAgo: "hace {minutes} min",
173 editNote:
174 "{path} también fue editado por la sesión lich {who} en {at} ({ago}), que comparte este checkout y puede " +
175 "seguir trabajando en él. Vuelve a leer el archivo antes de continuar a partir de él y coordínate con esa sesión " +
176 "mediante send_to_session o lich send en lugar de deshacer su cambio.",
177 skillNote:
178 "\n\nEn esta sesión, un slash command integrado (compact, clear y similares) también puede nombrarse aquí, " +
179 "cuando el usuario te pida ejecutarlo: se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.",
180 controlNote:
181 "\n\nEn esta sesión, la acción command acepta la propia sesión como destino: pasa session " +
182 '"{sessionId}". El comando se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.',
183 denyDefaultSaved:
184 "/{name} ejecutado desde dentro de la sesión se guardaría como valor predeterminado de toda nueva sesión de Claude Code. " +
185 "Pide al usuario que lo defina desde lich, cuyo override de {name} vale solo para esta sesión.",
186 skillQueued: "{queued} está en cola y se ejecuta cuando termine este turno. Termina el turno ahora.",
187 controlQueued: "{queued} está en cola en esta sesión y se ejecuta cuando termine este turno. Termina el turno ahora.",
188}
189
190/** @type {Record<string, Record<string, string>>} */
191const catalogs = { en, "pt-BR": ptBR, "zh-CN": zhCN, es }
192
193/**
194 * The text `key` names in `lang`, its slots filled from `vars`. English stands
195 * in for an unknown language; a key missing from English throws, which the
196 * parity test turns into a failure long before a session sees it.
197 *
198 * @param {string | undefined} lang
199 * @param {string} key
200 * @param {Record<string, string | number>} [vars]
201 */
202export function say(lang, key, vars = {}) {
203 const template = (catalogs[lang ?? "en"] ?? en)[key] ?? en[key]
204 if (template === undefined) throw new Error(`no prompt text named "${key}"`)
205 return template.replace(/\{(\w+)\}/g, (slot, name) => String(vars[name] ?? slot))
206}
207
208/**
209 * The catalog language for a LICH_PROMPT_LANG value. The hook reads the env
210 * itself: the engine never follows $ across an import.
211 *
212 * @param {string | undefined} tag
213 */
214export function promptLang(tag) {
215 return tag && tag in catalogs ? tag : "en"
216}
217