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
18 changes: 11 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
lich that sends `command` (lich-plugin 0.15 contract).
- **A general-purpose subagent runs as a lich session you can watch and steer.**
Inside lich, the Claude Code mod opens each general-purpose subagent (or one
started with no type) as a Claude Code session on a branch of its own, off the
asking session's branch. The Agent call returns at once as a background
subagent, so the asking session keeps working, and the report arrives at its
prompt as a [lich] note. Explore, Plan and other types stay inside Claude
Code. When lich cannot take the task, the subagent runs inside Claude Code as
before, with a toast saying why. Turning off "Subagents as lich sessions" in
started with no type) as a Claude Code session filed under the asking
session. Like a native subagent it works in the asking session's checkout,
or on a branch and worktree of its own, off the asking session's branch, when
the subagent asks for worktree isolation. The Agent call returns at once as a
background subagent, so the asking session keeps working, and the worker's
full report arrives at its prompt on its own as a [lich] note, as does word
that the worker is waiting on a permission. Explore, Plan and other types stay
inside Claude Code. When lich cannot take the task, the subagent runs inside
Claude Code as before, with a toast saying why. Turning off "Subagents as lich sessions" in
lich's Settings › Providers › Claude Code keeps every subagent inside Claude
Code. Needs Claude Code 2.1.280 or later with mods on (measured on 2.1.289).
Code. Needs Claude Code 2.1.280 or later with mods on (measured on 2.1.289)
and a lich that takes `lich open --subagent`.
- **lich can ask a Claude Code session a side question without stopping it.**
The mod answers an `ask` with a fork of the session's own conversation, which
runs beside the turn, stays out of the conversation and is mostly served from
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ refuses its session, naming what to fix.
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: its own worktree, branched off the asking session's branch, and
a card you can watch and steer. The Agent call returns at once as a background
subagent, and the session's report arrives at the asking session's prompt as a
`[lich]` note; Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the
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
rules and what happens when lich cannot be reached.

It also ships skills for the parts of lich you configure from inside a session:
Expand Down
68 changes: 41 additions & 27 deletions docs/agent-cards.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Mod: agent cards

Runs a subagent Claude Code starts as a lich session: its own worktree and a
card the user can watch and steer, where Claude Code would otherwise run the
agent hidden inside the asking session.
Runs a subagent Claude Code starts as a lich session: a card the user can watch
and steer, where Claude Code would otherwise run the agent hidden inside the
asking session. It follows the native agent's semantics: it works in the asking
session's checkout, or in a worktree of its own when the call asks for
`isolation: "worktree"`.

There is no HTTP contract behind it. It drives the lich CLI the asking session
already has, `$LICH_BIN open`, so what it reads is what that command prints
Expand All @@ -23,32 +25,37 @@ of these hold, and every other one goes on to Claude Code untouched:
Claude Code's own error;
- it names no `team_name` and no `isolation: remote`, so a teammate and a
remote agent keep Claude Code's own route;
- the session is not itself a worker: a branch under `subagent/` is one this
mod opened, and a worker's own subagents stay native instead of opening cards
from cards;
- the session is not itself a worker, so a worker's own subagents stay native
instead of opening cards from cards. lich starts every `--subagent` session
with `LICH_SUBAGENT_CARDS=off` (below), which covers a worker in the asking
session's checkout; an isolated call also stays native when the session is on
a branch under `subagent/`, which only this mod opens;
- the session is interactive (`session.start` saw `isInteractive`), since a
`claude -p` started from a tool inside a lich session inherits its variables;
- `LICH_BIN` and `LICH_SESSION_ID` are set, which is to say the session runs
inside lich;
- `LICH_SUBAGENT_CARDS` is not `off`. lich sets it to `off` in a Claude Code
session it starts while "Subagents as lich sessions" is off in Settings ›
Providers › Claude Code, and leaves it out otherwise, so the default is on.
Providers › Claude Code, and in every session opened with `--subagent`, and
leaves it out otherwise, so the default is on.
A session's environment is fixed when it starts, so turning the setting over
reaches the sessions started after that.

A taken call runs:

1. `git branch --show-current` in the session's directory, for the base.
2. `$LICH_BIN open --kind claude --worktree <branch> [--base <current>] [--model <model>] --prompt <prompt> --json`,
1. With `isolation: "worktree"` only, `git branch --show-current` in the
session's directory, for the base.
2. `$LICH_BIN open --kind claude --subagent [--worktree <branch> [--base <current>]] [--model <model>] --prompt <prompt> --json`,
given two minutes, the sum of the waits lich's own client allows an open
with a task.
with a task. `--worktree` and `--base` go only with `isolation: "worktree"`;
without them lich opens the worker in the asking session's directory.

Nothing runs after that: the delivery `lich open` printed decides the answer.

| delivery | the Agent call answers |
|-------------------------------|---------------------------------------------------------------------|
| `pending` | `async_launched` at once, plus a note on how the report comes back |
| `answered` | `completed`: the report, then the branch and checkout it is on |
| `answered` | `completed`: the report, then the checkout (and branch) it is on |
| `unanswered` | `completed`: the worker ended its turn without reporting, and where to look |
| `unread`, `undelivered` | denied: the task never reached the card |
| a status it does not know | denied, naming the card and its branch |
Expand All @@ -62,21 +69,26 @@ that have no output file: there is nothing to read, and without
`canReadOutputFile` Claude Code never shows the model the field. `completed`
carries the worker's checkout in `worktreePath` and `worktreeBranch`, and token
and tool counts of zero because they are the worker's session's, not this one's.
Like a native agent's, its `worktreePath` and `worktreeBranch` are there only
for an isolated call.

## How it behaves

- **The call runs in the background**, as a background subagent does: once lich
has the task it answers `async_launched` and the asking turn goes on. Parallel
Agent calls run their hooks concurrently, so each opens its own card at once.
- **The report comes back through lich, not Claude Code.** The task is an
ordinary lich errand (not `--private`), so when the worker replies lich types
a `[lich]` note at the asking session's prompt (through mod-control's `prompt`
when the session runs the mod), and the model collects it with
`wait_for_answer` or `lich wait`. Claude Code's own text for a background
agent promises a task notification and names `SendMessage`, so the result
carries a `context` line the model reads after it: where the worker is, how
its report arrives, its ticket, and that `send_to_session` or `lich send`
reaches it where `SendMessage` cannot.
- **The report comes back through lich, not Claude Code.** `--subagent` makes
lich type the worker's whole report at the asking session's prompt as a
`[lich]` note (through mod-control's `prompt` when the session runs the mod),
so the model has nothing to collect; `wait_for_answer` and `lich wait` still
work. Claude Code's own text for a background agent promises a task
notification and names `SendMessage`, so the result carries a `context` line
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
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
Code runs the native agent in place of any hook that throws, overruns its
budget or lets a `$.process.run` reject, so the module catches every failure
Expand All @@ -85,17 +97,21 @@ and tool counts of zero because they are the worker's session's, not this one's.
while the open is still running denies the call instead, because lich may
already have handed the task over and a native agent beside the worker would
do the work twice; a report that worker sends still arrives as a `[lich]` note.
- **The branch is named from the call.** The description is slugged the way
- **An isolated worker's branch is named from the call.** The description is slugged the way
lich's own worktree dialog slugs a typed name (2 to 5 words, 10 to 40
characters, letters and digits in any script), `agent` when nothing is left,
under `subagent/`, plus `-` and the last four letters or digits of the call's id. lich checks out
a branch that already exists as it stands, and the suffix keeps a call from
landing on one.
- **The base is the asking session's branch, as last committed.** Uncommitted
- **An isolated worker's base is the asking session's branch, as last
committed.** Uncommitted
changes are not in the worker's checkout. On a detached HEAD, or when git
cannot answer, the base is left to lich, which uses the project's current branch.
- **The worker runs with the permissions lich opens any Claude Code session
with**, not the asking session's mode, so a permission prompt waits on its card.
with**, not the asking session's mode, so a permission prompt waits on its
card, and lich tells the asking session when the worker is waiting on one.
- **lich files the worker in a folder named after the asking session**, and its
errand never expires.
- **A model the call names is the worker's model**, passed as `--model`. With
none, the worker starts on the model lich opens Claude Code with, not the
asking session's, which a native agent would inherit.
Expand All @@ -111,10 +127,8 @@ and tool counts of zero because they are the worker's session's, not this one's.
- **Claude Code does not track the worker as a task.** It is not in the
background-task list, Claude Code sends no completion notification for it,
and stopping it is done on its card, not from Claude Code.
- **A report nobody collects within an hour is lost.** lich drops an
unanswered ticket an hour after anyone last waited on it, and a result left
in the inbox on the same clock; the work itself stays on the worker's card
and branch.
- **Needs a lich that takes `lich open --subagent`.** An older one refuses the
flag, and every subagent runs inside Claude Code with a toast saying why.
- **A reload of the module mid-session forgets that the session is
interactive** until its next `session.start`, and Agent calls run natively
until then.
63 changes: 36 additions & 27 deletions hooks/agent-cards.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Runs a general-purpose subagent Claude Code starts as a lich session: its own
// worktree and a card the user can watch and steer, instead of an agent hidden
// inside this session. Docs: ../docs/agent-cards.md. There is no HTTP contract
// behind it: it drives the lich CLI (`$LICH_BIN open`), whose output and exit
// codes are docs/cli.md in the lich repository.
// Runs a general-purpose subagent Claude Code starts as a lich session: a card
// the user can watch and steer, instead of an agent hidden inside this session.
// It works in this session's checkout, as a native subagent does, or in a
// worktree of its own when the call asks for `isolation: "worktree"`.
// Docs: ../docs/agent-cards.md. There is no HTTP contract behind it: it drives
// the lich CLI (`$LICH_BIN open`), whose output and exit codes are docs/cli.md
// in the lich repository.
//
// Once lich has the task the Agent call answers at once, as a background
// subagent does, and nothing here waits on the worker: the errand is an
// ordinary one, so lich announces its result at this session's prompt as a
// [lich] note and the model collects it through lich.
// subagent does, and nothing here waits on the worker: `--subagent` makes lich
// type the worker's whole report at this session's prompt as a [lich] note.
//
// Claude Code fails a tool.call hook open: one that throws, overruns its budget
// or lets a `$.process.run` reject is skipped and the native agent runs in its
Expand Down Expand Up @@ -41,7 +42,9 @@ const MIN_CHARS = 10
const MAX_CHARS = 40
const FALLBACK_SLUG = "agent"
// Marks a worker's branch, so the mod inside that worker leaves its own
// subagents native instead of opening cards from cards.
// subagents native instead of opening cards from cards. A worker in this
// session's checkout has no branch of its own: lich starts every `--subagent`
// session with LICH_SUBAGENT_CARDS=off instead.
const WORKER_BRANCH_PREFIX = "subagent/"

// The end of the Agent call's id makes each branch new: lich checks out an
Expand Down Expand Up @@ -138,13 +141,14 @@ function parsedOrUndefined(stdout) {
* @param {Engine} $
* @param {string} lich
* @param {AgentCall} e
* @param {string} branch
* @param {string} branch "" opens the worker in this session's checkout
* @param {string} base
* @returns {Promise<Opened & { delivery: Report }>}
*/
async function openWorker($, lich, e, branch, base) {
const argv = [
lich, "open", "--kind", "claude", "--worktree", branch,
lich, "open", "--kind", "claude", "--subagent",
...(branch ? ["--worktree", branch] : []),
...(base ? ["--base", base] : []),
...(e.model ? ["--model", e.model] : []),
"--prompt", e.prompt, "--json",
Expand All @@ -169,9 +173,11 @@ async function openWorker($, lich, e, branch, base) {
* @param {AgentCall} e
* @param {Opened} opened
* @param {string} branch
* @param {Report} delivery
*/
function backgrounded(e, opened, branch, delivery) {
function backgrounded(e, opened, branch) {
const where = branch
? `on branch ${branch} in ${opened.path}, not in this checkout`
: `in this same checkout, ${opened.path}, and edits its files as you do`
return {
result: {
status: /** @type {const} */ ("async_launched"),
Expand All @@ -181,9 +187,9 @@ function backgrounded(e, opened, branch, delivery) {
outputFile: "",
},
context: [
`The agent runs as the lich session "${opened.label}", on branch ${branch} in ${opened.path}, not in this checkout. ` +
`Its report arrives at this prompt as a [lich] note, not as a task notification: collect it with the lich ` +
`tool wait_for_answer, or "$LICH_BIN" wait ${delivery.ticket}. SendMessage cannot reach that session; ` +
`The agent runs as the lich session "${opened.label}", ${where}. ` +
`Its full report arrives at this prompt on its own as a [lich] note, not as a task notification, so there ` +
`is no need to call wait_for_answer (it still works). SendMessage cannot reach that session; ` +
`send_to_session or lich send can.`,
],
}
Expand All @@ -192,7 +198,8 @@ function backgrounded(e, opened, branch, delivery) {
/**
* The Agent tool's `completed` arm, which Claude Code checks a hook's result
* against. The worker's tokens and tools are its own session's, so none are
* counted here.
* counted here; a worker in this checkout names no worktree, as a native agent
* without isolation does.
*
* @param {AgentCall} e
* @param {Opened} opened
Expand All @@ -218,8 +225,7 @@ function completed(e, opened, branch, text, durationMs) {
cache_creation: null,
},
prompt: e.prompt,
worktreePath: opened.path,
worktreeBranch: branch,
...(branch ? { worktreePath: opened.path, worktreeBranch: branch } : {}),
}
}

Expand All @@ -233,24 +239,26 @@ function completed(e, opened, branch, text, durationMs) {
function answerFor(e, opened, branch, report, durationMs) {
switch (report.status) {
case "pending":
return backgrounded(e, opened, branch, report)
return backgrounded(e, opened, branch)
case "answered": {
const where =
`The work is on branch ${branch} in ${opened.path} (lich session "${opened.label}"), not in this checkout. ` +
(branch
? `The work is on branch ${branch} in ${opened.path} (lich session "${opened.label}"), not in this checkout. `
: `The work is in this same checkout, ${opened.path} (lich session "${opened.label}"). `) +
`Reach that session with send_to_session or lich send, not SendMessage.`
return { result: completed(e, opened, branch, `${report.answer}\n\n${where}`, durationMs) }
}
case "unanswered": {
const text =
`"${opened.label}" ended its turn without reporting back through lich. What it did is on its card and on ` +
`branch ${branch}; a report it sends later arrives here as a [lich] note.`
`"${opened.label}" ended its turn without reporting back through lich. What it did is on its card and ` +
`${branch ? `on branch ${branch}` : "in this checkout"}; a report it sends later arrives here as a [lich] note.`
return { result: completed(e, opened, branch, text, durationMs) }
}
case "unread":
case "undelivered":
return { deny: `the task never reached "${opened.label}" (${report.status}): open its card.` }
default:
return { deny: `lich answered "${report.status}" about "${opened.label}", on branch ${branch}: open its card.` }
return { deny: `lich answered "${report.status}" about "${opened.label}"${branch ? `, on branch ${branch}` : ""}: open its card.` }
}
}

Expand Down Expand Up @@ -285,18 +293,19 @@ async function runAsSession($, state, e, next) {
return runNatively($, e, next, `the task is ${bytes} bytes, over lich's ${PROMPT_LIMIT_BYTES}`)
}

const base = await currentBranch($)
const isolated = e.isolation === "worktree"
const base = isolated ? await currentBranch($) : ""
if (base.startsWith(WORKER_BRANCH_PREFIX)) return next(e)
const startedMs = await $.clock.now()
const branch = branchFor(e)
const branch = isolated ? branchFor(e) : ""
/** @type {Opened & { delivery: Report }} */
let opened
try {
opened = await openWorker($, lich, e, branch, base)
} catch (error) {
if (next.signal.aborted) {
return {
deny: `interrupted; a lich session on branch ${branch} may already have the task, and a report it sends arrives here as a [lich] note.`,
deny: `interrupted; a lich session${branch ? ` on branch ${branch}` : ""} may already have the task, and a report it sends arrives here as a [lich] note.`,
}
}
return runNatively($, e, next, messageOf(error))
Expand Down
Loading
Loading