SLOPSHOPPER

lich

Claude Code plugin for the lich harness

newspinnerguardtoastmodelprocess
★ 1v0.20.3no licenseupdated 2026-10-15lichdotdev/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 11 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 526 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// 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}
526
hooks/mod-compacting.js 130 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: `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}
130
hooks/edit-guard.js 212 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 { 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}
212
hooks/mod-control.js 260 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
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
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}
260
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.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}
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.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}
120
hooks/self-command.js 159 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 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}
159
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 167 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 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}
167
hooks/prompt-text.js 217 lines
1// 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