Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **A lich worker answers the way a native subagent does.** The worker's last
message is its report, so it is handed the task alone, with no ticket or reply
instructions; a worker that handed work to the background reports once that
work is done. Needs a lich that takes the answer (the mod-answer contract).

### Fixed

- **The edit guard names the other session by its card's label.** A note that a
Expand Down
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c
hooks/ # hook scripts (all four run them; only how they are addressed differs)
hooks/detail.jq # what a tool call acts on, shared by two of those scripts
hooks/win-run.cmd # runs one of those scripts on Windows (Codex)
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the four mods below
hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the five mods below
hooks/mod-control.js # Claude Code mod: lich drives the session
hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session
hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost
hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file
hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task
opencode/lich.js # opencode client: a module, not a command
omp/lich.js # omp client: a module, not a command
docs/ # lich ⇄ plugin communication contracts
Expand Down Expand Up @@ -60,6 +61,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p
- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system
- [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only
- [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only
- [docs/mod-answer.md](docs/mod-answer.md): a lich subagent worker answers the task it was handed with its turn's final message, through a mod reading the main loop's `classic.Stop` and `turn.complete`; Claude Code only
- [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only
- [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report

Expand Down Expand Up @@ -94,7 +96,9 @@ lich it subscribes to nothing at all.
Claude Code mod, handing it a fake `$` and asserting its acks against the
mod-control fixtures and its polls against the commands lich sends;
[tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its
usage reports against the mod-usage fixtures.
usage reports against the mod-usage fixtures, and
[tests/worker-answer.test.mjs](tests/worker-answer.test.mjs) for a worker's
answer against the mod-answer fixtures.
[tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a
fake `$` with an in-memory git dir and asserts the markers it leaves and the
note another session's marker adds.
Expand All @@ -108,6 +112,7 @@ dependencies.
[tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) and
[tests/agent-cards.engine.test.ts](tests/agent-cards.engine.test.ts) and
[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and
[tests/worker-answer.engine.test.ts](tests/worker-answer.engine.test.ts) and
[tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the
mods under Claude Code's own engine (`claude plugin test .`); they need a
`claude` binary, so CI does not run them. The kit does not check a `tool.call`
Expand Down Expand Up @@ -160,6 +165,6 @@ Same standard as the `lich` repository (Keep a Changelog + SemVer). Releases are

1. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version heading (with date) and refresh the compare links at the bottom.
2. Pick the number by what changed: a release that sends anything lich's contract did not already accept (a new endpoint, header, field or value) bumps the **minor** (the major, from 1.0); anything else bumps the **patch**. lich accepts every release below the next minor of the contract it implements, so a contract change released as a patch reaches a lich that refuses it. `docs/hooks/README.md` in lich, Versioning, is the rule.
3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js` and `hooks/mod-control.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release.
4. Annotated tag `vX.Y.Z` + push with the tag.
5. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ 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; Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the
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](docs/mod-answer.md)). Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](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
Expand All @@ -61,11 +62,12 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni
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 four below
hooks/lich.js # the Claude Code mods' entry, which registers the five 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
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
Expand Down
19 changes: 12 additions & 7 deletions docs/agent-cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ for an isolated call.
the model reads after it: where the worker is (and, without isolation, that
it edits this same checkout), that its report arrives on its own, and that
`send_to_session` or `lich send` reaches it where `SendMessage` cannot.
- **Without isolation the worker shares the checkout**, as a native subagent
- **The worker answers by itself.** Its own copy of this plugin posts the final
message of a turn that ends with nothing left running in the background as
its answer ([mod-answer](mod-answer.md)), so lich hands it the task with no
ticket and no reply instructions, as a native subagent gets its prompt.- **Without isolation the worker shares the checkout**, as a native subagent
does: both edit the same files, and nothing keeps their changes apart. With
`isolation: "worktree"` it gets a branch and worktree of its own.
- **It falls back to the native agent only before lich has the task.** Claude
Expand All @@ -111,11 +114,12 @@ for an isolated call.
"N agents" hint counts background agents: `N workers` under the prompt
(`$.ui.status`), which Claude Code shows after the plugin's name as
`lich: N workers`, cleared at none. Every 5 seconds while any runs, the mod reads
`$LICH_BIN sessions --json`; a worker lich lists with a turn `done`, or no
longer lists at all, is finished. lich closes a worker in this checkout once
it reported and its turn ended (`lich open --subagent` in lich's docs/cli.md),
which is what takes it off the list. A list lich could not give leaves the
count as it was until the next read.
`$LICH_BIN sessions --json`. A worker in this checkout is finished once lich
no longer lists it: lich closes it after it reported and its turn ended
(`lich open --subagent` in lich's docs/cli.md), while a turn it ended with a
command left in the background is done without being finished. An isolated
worker keeps its card after it reported, so a turn lich lists `done` ends it.
A list lich could not give leaves the count as it was until the next read.
- **Esc does not stop a worker, and TaskStop does**, as with a native background
agent. Measured on Claude Code 2.1.289: Esc in the asking session interrupts
its own turn and leaves its background agents running (a second Esc at the
Expand All @@ -127,7 +131,8 @@ for an isolated call.
and is still counting: a worker in this checkout is closed with
`$LICH_BIN close <name>`, having nothing of its own to keep, and an isolated
one has its turn stopped with `$LICH_BIN control <name> abort`, its card and
worktree left for the user. Either answers TaskStop's own result shape; a
worktree left for the user. A closed worker's task just ends: lich sends
the asking session no note about it. Either answers TaskStop's own result shape; a
command lich refused denies the call with lich's reason. Any other task id
goes to Claude Code.
- **The worker runs with the permissions lich opens any Claude Code session
Expand Down
56 changes: 56 additions & 0 deletions docs/mod-answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Mod: worker answer

Client side of the [mod-answer contract](https://github.com/omartelo/lich/blob/main/docs/hooks/mod-answer.md)
(the endpoint and the payload are defined in the lich repository; this plugin
only implements them).

Makes a lich subagent worker (a session opened with `lich open --subagent`,
which is what [agent-cards](agent-cards.md) opens for an Agent call) answer the
task it was handed the way a native subagent does: the final message of its
turn is its result. lich hands such a worker its task with no ticket and no
reply instructions, and answers the errand with what this mod posts.

| client | Claude Code | Codex | Antigravity | opencode | omp | Crush |
|--------------------------|----------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------|
| `hooks/worker-answer.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system |

| event | sent |
|-------------------------------------------|-------------------------------------------------------------|
| `classic.Stop`, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "text"}` |

## How it behaves

- **Only a worker answers.** It reads `LICH_SUBAGENT_CARDS` in `session.start`
and reports only when it is `off`, which lich sets on every `--subagent`
session. A session the user turned subagent cards off for carries it too;
lich ignores a report from a session with no subagent errand open.
- **A turn that handed work to the background does not answer.** Its main-loop
`classic.Stop` lists what is still running in `background_tasks` (a shell, a
subagent, a monitor, a workflow); Claude Code resumes in a new turn when that
work finishes, and that turn's `Stop`, with nothing listed, is the one that
answers.
- **An aborted, failed or refused turn does not answer**, and neither does one
whose final message is blank. `turn.complete` is matched on
`reason: "answer"`, which also keeps the hook beside mod-control's own
`turn.complete` hook: Claude Code refuses one plugin's second hook on an event
with no matcher.
- **A subagent's turn inside the worker does not answer.** `classic.Stop` fires
on the main loop only, and a `turn.complete` that carries an `agentId` is
skipped.
- **A Stop answers only for its own turn.** The text is posted when the
completion's `answer` equals the Stop's `last_assistant_message`, and the
Stop's verdict is dropped once a completion read it: an aborted turn fires no
Stop, and must not inherit the last one.
- **The text is cut at 16,000 UTF-16 units** and marked `[truncated]`, as an
`ask`'s answer is.
- **The turn is never held up.** The answer is posted beside the chain; one
that fails is dropped, never retried.
- **Outside lich, and in a non-interactive run, it never reports.**

Measured on Claude Code 2.1.289: `classic.Stop` fires on the main loop before
`turn.complete`, its `last_assistant_message` equals the completion's `answer`,
and its `background_tasks` is `[]` when nothing runs. `classic.SubagentStop`
fires for engine forks too, and a background subagent lists itself as still
running there, which is why only the main loop's `Stop` is read. A worker that
asks with `AskUserQuestion` blocks with no `Stop` until someone answers in its
card.
3 changes: 2 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ This repository is one plugin, packaged for each harness it supports:
| `hooks/crush-hooks.json` | Crush | hook registration, merged by hand |
| `hooks/*.sh` | the four | the reports themselves |
| `hooks/win-run.cmd` | Codex | runs a script on Windows |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the four below |
| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the five below |
| `hooks/mod-control.js` | Claude Code | the mod-control client, a mod |
| `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod |
| `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod |
| `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod |
| `hooks/worker-answer.js` | Claude Code | the mod-answer client: a worker's last message answers its task, a mod |
| `opencode/lich.js` | opencode | the whole client, as a module |
| `omp/lich.js` | omp | the whole client, as a module |
| `skills/` | all | skills, same layout |
Expand Down
11 changes: 6 additions & 5 deletions hooks/agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,16 +350,17 @@ function watchWorkers($, state) {
}

/**
* A worker still runs while lich lists it with a turn that is not done: busy,
* waiting on a permission, or not reported yet, which is a worker whose first
* turn has not started. One lich no longer lists was closed, which lich does to
* a worker in this checkout once it reported.
* A worker in this checkout still runs while lich lists it: lich closes one
* once it reported, and a turn it ended with a command left in the background
* is done without being finished. An isolated worker keeps its card after it
* reported, so for it a done turn is the end.
*
* @param {Worker} worker
* @param {Peer[]} peers
*/
function stillRuns(worker, peers) {
return peers.some((p) => p.name === worker.name && p.state !== "done")
const listed = peers.find((p) => p.name === worker.name)
return listed !== undefined && (worker.shared || listed.state !== "done")
}

/**
Expand Down
2 changes: 2 additions & 0 deletions hooks/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { register as registerAgentCards } from "./agent-cards.js"
import { register as registerEditGuard } from "./edit-guard.js"
import { register as registerModControl } from "./mod-control.js"
import { register as registerModUsage } from "./mod-usage.js"
import { register as registerWorkerAnswer } from "./worker-answer.js"

/** @param {import('claude-code').On} on */
export function register(on) {
registerModControl(on)
registerAgentCards(on)
registerModUsage(on)
registerEditGuard(on)
registerWorkerAnswer(on)
}
Loading
Loading