SLOPSHOPPER

lich

Claude Code plugin for the lich harness

newspinnerguardtoastmodelprocess
★ 1v0.19.0no licenseupdated 2026-10-09omartelo/lich-plugin
A shopper browsing a rack in a slop shop
README

lich-plugin

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:

  • session state — busy/done on the session card (UserPromptSubmit/PreInvocation/Stop)
  • session start — persists the agent's session id on the lich session (SessionStart/PreInvocation)
  • session title — names the card after the session's own title (Stop)
  • session touched — refreshes the card's git status right after file-mutating tools (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:

  • theme (/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

Structure

.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.

Installation

Claude Code

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

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.

Antigravity CLI

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

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 (oh-my-pi)

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

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.

Manual (from a clone)

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.

Local testing

claude --plugin-dir .

Tests

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.

Source 10 files
hooks/lich.js 27 lines
1// 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}
27
hooks/agent-cards.js 482 lines
1// 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}
482
hooks/mod-compacting.js 113 lines
1// 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}
113
hooks/edit-guard.js 215 lines
1// 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}
215
hooks/mod-control.js 261 lines
1// 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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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}
261
hooks/mod-status.js 108 lines
1// 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}
108
hooks/mod-usage.js 120 lines
1// 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}
120
hooks/self-command.js 100 lines
1// 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}
100
hooks/status-line.js 47 lines
1// 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}
47
hooks/worker-answer.js 129 lines
1// 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