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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the card on `done` and an automatic one on a bare spinner. A lich older than
the contract answers `compacting` with a 400 and still takes the closing state.

### Changed

- **A lich worker can open cards of its own.** With a lich that sets
`LICH_SUBAGENT_DEPTH`, a general-purpose subagent started inside a worker runs
as a lich session too, until the depth lich allows; the `subagent/` branch an
isolated worker sits on no longer keeps its subagents native. A worker at that
limit runs them natively, with a toast saying why. The worker's answer is told
by the same variable, so a top-level session with subagent cards turned off
no longer posts one. An older lich keeps today's behaviour.

## [0.18.4] - 2026-10-08

### Added
Expand Down
31 changes: 23 additions & 8 deletions docs/agent-cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,36 @@ 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, 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;
- under a lich older than `LICH_SUBAGENT_DEPTH` (below), the session is not
itself a worker, so a worker's own subagents stay native instead of opening
cards from cards. That lich starts every `--subagent` session with
`LICH_SUBAGENT_CARDS=off`, 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. A lich that sets the depth
decides how deep cards open from cards through `LICH_SUBAGENT_CARDS` alone,
so a worker below its limit opens cards of its own, from a `subagent/` branch
too;
- 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 in every session opened with `--subagent`, and
leaves it out otherwise, so the default is on.
Providers › Claude Code, and in a worker at lich's own depth limit for cards
opened from cards, and leaves it out otherwise, so the default is on. A lich
older than `LICH_SUBAGENT_DEPTH` sets it in every session opened with
`--subagent` instead.
A session's environment is fixed when it starts, so turning the setting over
reaches the sessions started after that.
reaches the sessions started after that. When it is `off` in a worker, which
`LICH_SUBAGENT_DEPTH` above `0` marks, the native agent runs with a toast
saying lich keeps that session's subagents native; in any other session it
runs silently.

`LICH_SUBAGENT_DEPTH` is set by lich on every Claude Code session it starts:
`0` for one nobody opened with `--subagent`, `n` for a worker opened `n`
`--subagent` levels down. The depth limit is lich's; this mod reads the
variable only to tell a worker from a top-level session and an older lich from
a current one.

A taken call runs:

Expand Down
12 changes: 8 additions & 4 deletions docs/mod-answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ reply instructions, and answers the errand with what this mod posts.

## 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.
- **Only a worker answers.** It reads `LICH_SUBAGENT_DEPTH` in `session.start`,
which lich sets on every Claude Code session it starts, and reports only when
it is an integer above `0`: a session opened `n` `--subagent` levels down
carries `n`, any other `0`. Under a lich older than that variable it reads
`LICH_SUBAGENT_CARDS` instead and reports only when it is `off`, which that
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
Expand Down
9 changes: 6 additions & 3 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ lich drives an agent CLI inside a PTY and injects the same three variables
[hook contracts](https://github.com/omartelo/lich/blob/main/docs/hooks/README.md)
are therefore provider-agnostic: what changes per provider is only *how the
harness registers hooks* and *what its lifecycle events are called*. A Claude
Code session also carries `LICH_BIN`, and `LICH_SUBAGENT_CARDS=off` when
"Subagents as lich sessions" is off in lich's Settings › Providers › Claude
Code; both are read only by the agent-cards mod ([agent-cards.md](agent-cards.md)).
Code session also carries `LICH_BIN`; `LICH_SUBAGENT_DEPTH`, how many
`--subagent` levels down the session was opened (`0` for one nobody opened as a
worker); and `LICH_SUBAGENT_CARDS=off` when "Subagents as lich sessions" is off
in lich's Settings › Providers › Claude Code, or in a worker at lich's depth
limit. The agent-cards mod ([agent-cards.md](agent-cards.md)) reads all three,
and the worker-answer mod ([mod-answer.md](mod-answer.md)) the last two.

This repository is one plugin, packaged for each harness it supports:

Expand Down
35 changes: 26 additions & 9 deletions hooks/agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ const PROMPT_LIMIT_BYTES = 8192
// internal/cli/cli.go. This bound only has to outlast lich's own.
const OPEN_TIMEOUT_MS = 120000

// The value lich's Settings › Providers › Claude Code writes into a session's
// environment when "Subagents as lich sessions" is off.
// The value lich writes into a session's environment to keep its subagents
// native: when "Subagents as lich sessions" is off in Settings › Providers ›
// Claude Code, and in a worker at lich's own depth limit for cards opened from
// cards. A lich older than LICH_SUBAGENT_DEPTH writes it into every worker.
const CARDS_OFF = "off"

// The bounds lich's own worktree dialog slugs a typed name with
Expand All @@ -45,10 +47,10 @@ const MAX_WORDS = 5
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. A worker in this
// session's checkout has no branch of its own: lich starts every `--subagent`
// session with LICH_SUBAGENT_CARDS=off instead.
// Marks a worker's branch. Under a lich older than LICH_SUBAGENT_DEPTH, the mod
// inside an isolated worker reads it to leave its own subagents native instead
// of opening cards from cards; a lich that sets the depth decides that alone,
// through LICH_SUBAGENT_CARDS.
const WORKER_BRANCH_PREFIX = "subagent/"

// How often the status line asks lich which workers still run. Claude Code's
Expand Down Expand Up @@ -272,6 +274,17 @@ function answerFor(e, opened, branch, report, durationMs) {
}
}

/**
* A worker lich opened with `--subagent`, `depth` levels below a session nobody
* opened as one.
*
* @param {string | undefined} depth LICH_SUBAGENT_DEPTH
*/
function isWorkerDepth(depth) {
const levels = Number(depth)
return Number.isInteger(levels) && levels > 0
}

/**
* @param {Engine} $
* @param {AgentCall} e
Expand All @@ -291,21 +304,25 @@ function runNatively($, e, next, reason) {
*/
async function runAsSession($, state, e, next) {
if (!isDelegable(e, next, state)) return next(e)
const [lich, session, cards] = await Promise.all([
const [lich, session, cards, depth] = await Promise.all([
$.env.get("LICH_BIN"),
$.env.get("LICH_SESSION_ID"),
$.env.get("LICH_SUBAGENT_CARDS"),
$.env.get("LICH_SUBAGENT_DEPTH"),
])
if (!lich || !session) return next(e)
if (cards === CARDS_OFF) return next(e)
if (cards === CARDS_OFF) {
if (!isWorkerDepth(depth)) return next(e)
return runNatively($, e, next, "this session is itself a lich subagent card, and lich keeps its subagents inside it")
}
const bytes = new TextEncoder().encode(e.prompt).length
if (bytes > PROMPT_LIMIT_BYTES) {
return runNatively($, e, next, `the task is ${bytes} bytes, over lich's ${PROMPT_LIMIT_BYTES}`)
}

const isolated = e.isolation === "worktree"
const base = isolated ? await currentBranch($) : ""
if (base.startsWith(WORKER_BRANCH_PREFIX)) return next(e)
if (depth === undefined && base.startsWith(WORKER_BRANCH_PREFIX)) return next(e)
const startedMs = await $.clock.now()
const branch = isolated ? branchFor(e) : ""
/** @type {Opened & { delivery: Report }} */
Expand Down
28 changes: 21 additions & 7 deletions hooks/worker-answer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
// https://github.com/omartelo/lich/blob/main/docs/hooks/mod-answer.md
//
// A Claude Code mod, registered by hooks/lich.js beside mod-control.js. Only a
// worker reports: lich spawns every `lich open --subagent` session with
// LICH_SUBAGENT_CARDS=off, which a session the user turned subagent cards off
// for also carries; lich ignores the report from a session with no subagent
// errand open, so that one costs a request per turn and nothing else.
// worker reports: lich sets LICH_SUBAGENT_DEPTH on every Claude Code session it
// starts, 0 at the top and n for a worker n `lich open --subagent` levels down.
// A lich older than that variable spawns every worker with
// LICH_SUBAGENT_CARDS=off instead, which a session the user turned subagent
// cards off for also carries; lich ignores the report from a session with no
// subagent errand open, so that one costs a request per turn and nothing else.
//
// A turn is the worker's answer when its main-loop `classic.Stop` lists no
// background task (a shell, subagent, monitor or workflow still running means
Expand All @@ -24,7 +26,8 @@
// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release).
const PLUGIN_VERSION = "0.18.4"

// The value lich spawns a `--subagent` session with.
// The value a lich older than LICH_SUBAGENT_DEPTH spawns every `--subagent`
// session with.
const CARDS_OFF = "off"

// The contract cuts an answer at this many UTF-16 units, as mod-control.js
Expand All @@ -37,20 +40,31 @@ const ANSWER_LIMIT = 16000
* @typedef {{ link?: Link, idleText?: string }} State
*/

/**
* @param {string | undefined} depth LICH_SUBAGENT_DEPTH
* @param {string | undefined} cards LICH_SUBAGENT_CARDS
*/
function isWorker(depth, cards) {
if (depth === undefined) return cards === CARDS_OFF
const levels = Number(depth)
return Number.isInteger(levels) && levels > 0
}

/**
* The link to lich, for a worker only.
*
* @param {Engine} $
* @returns {Promise<Link | undefined>}
*/
async function workerLink($) {
const [port, token, session, cards] = await Promise.all([
const [port, token, session, depth, cards] = await Promise.all([
$.env.get("LICH_PORT"),
$.env.get("LICH_TOKEN"),
$.env.get("LICH_SESSION_ID"),
$.env.get("LICH_SUBAGENT_DEPTH"),
$.env.get("LICH_SUBAGENT_CARDS"),
])
if (!port || !token || !session || cards !== CARDS_OFF) return undefined
if (!port || !token || !session || !isWorker(depth, cards)) return undefined
return { base: `http://127.0.0.1:${port}`, token, session }
}

Expand Down
9 changes: 9 additions & 0 deletions tests/agent-cards.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ test('turned off in lich, every subagent stays native and lich is never run', as
expect(w.argvs).toEqual([])
})

test('a worker at lich\'s depth limit keeps its subagents native, with a toast saying why', async ($, on) => {
const w = worldWith(on, { ...ENV, LICH_SUBAGENT_CARDS: 'off', LICH_SUBAGENT_DEPTH: '2' })
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
await $.tool.call(CALL)
expect(w.native).toEqual(['Fix the auth flow'])
expect(w.argvs).toEqual([])
expect(w.toasts).toEqual([expect.stringContaining('this session is itself a lich subagent')])
})

test('a running worker shows in the footer until lich closes it, and TaskStop closes one', async ($, on) => {
const pending = { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } }
const peer = (name: string, state: string) => ({ label: name, name, project: 'lich', kind: 'claude', state })
Expand Down
32 changes: 32 additions & 0 deletions tests/agent-cards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,38 @@ test('subagents as lich sessions turned off in lich keeps every call native', as
assertNativeUntouched({ mod, ...(await mod.call()) })
})

// lich sets the depth on every Claude Code session it starts; at 0 the session
// is nobody's worker and cards off is only the user's setting.
test('a top-level session with subagent cards turned off stays native without a toast', async () => {
const mod = load({ env: { ...ENV, LICH_SUBAGENT_CARDS: 'off', LICH_SUBAGENT_DEPTH: '0' } })
await mod.start()
assertNativeUntouched({ mod, ...(await mod.call()) })
assert.deepEqual(mod.toasts, [])
})

test('a worker lich keeps native at its depth limit runs natively, with a toast saying why', async () => {
const mod = load({ env: { ...ENV, LICH_SUBAGENT_CARDS: 'off', LICH_SUBAGENT_DEPTH: '2' } })
await mod.start()
assertNativeUntouched({ mod, ...(await mod.call()) })
assert.deepEqual(mod.toasts, [
'lich: ran "Fix the auth flow" as a Claude Code subagent: this session is itself a lich subagent card, and lich keeps its subagents inside it',
])
})

// With the depth set, lich's LICH_SUBAGENT_CARDS is the only gate: the
// subagent/ branch an isolated worker sits on no longer keeps it native.
test('a worker below lich\'s depth limit opens cards of its own, even from a subagent/ branch', async () => {
const base = 'subagent/fix-the-auth-flow-ab12'
const { mod, answer, passed } = await delegate(
[onBranch(base), exits(0, opened('pending'))],
ISOLATED,
{ env: { ...ENV, LICH_SUBAGENT_DEPTH: '1' } },
)
assert.deepEqual(passed, [])
assert.deepEqual(answer, backgrounded())
assert.deepEqual(mod.ran[1].argv.slice(5, 9), ['--worktree', BRANCH, '--base', base])
})

test('outside lich every call stays native', async () => {
for (const missing of ['LICH_BIN', 'LICH_SESSION_ID']) {
const env = { ...ENV }
Expand Down
11 changes: 11 additions & 0 deletions tests/worker-answer.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,14 @@ test('a turn with work in the background answers nothing', async ($, on) => {
await clock.settle()
expect(world.answers).toHaveLength(0)
})

test('a worker lich names by its depth answers with subagent cards left on', async ($, on) => {
mock.env(on, { LICH_PORT: '47999', LICH_TOKEN: 'tok', LICH_SESSION_ID: 'lich-1', LICH_SUBAGENT_DEPTH: '1' })
const clock = mock.clock(on)
const world = lich(on)
await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true })
await $.classic.Stop({ stop_hook_active: false, last_assistant_message: 'Rewrote docs/cli.md.', background_tasks: [] })
await $.turn.complete(ANSWERED)
await clock.settle()
expect(world.answers).toEqual([{ session_id: 'lich-1', text: 'Rewrote docs/cli.md.' }])
})
38 changes: 38 additions & 0 deletions tests/worker-answer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,44 @@ test('an ordinary session never answers', async () => {
assert.equal(mod.requests.length, 0)
})

test('a worker lich names by its depth answers, with subagent cards left on', async () => {
const mod = load({ env: { ...lichEnv(PORT), LICH_SUBAGENT_DEPTH: '1' } })
await mod.start()

await mod.turn(stop('Done.'), complete('Done.'))

assert.deepEqual(mod.bodies(), [{ session_id: LICH_SESSION_ID, text: 'Done.' }])
})

test('a worker at lich\'s depth limit answers, though its subagent cards are off', async () => {
const mod = load({ env: { ...WORKER_ENV, LICH_SUBAGENT_DEPTH: '2' } })
await mod.start()

await mod.turn(stop('Done.'), complete('Done.'))

assert.equal(mod.requests.length, 1)
})

// The depth decides once lich sets it: cards off at depth 0 is the user's
// setting, not a worker.
test('a top-level session with subagent cards turned off never answers', async () => {
const mod = load({ env: { ...WORKER_ENV, LICH_SUBAGENT_DEPTH: '0' } })
await mod.start()

await mod.turn(stop('Done.'), complete('Done.'))

assert.equal(mod.requests.length, 0)
})

test('a lich older than the depth variable marks a worker by cards off alone', async () => {
const mod = load({ env: WORKER_ENV })
await mod.start()

await mod.turn(stop('Done.'), complete('Done.'))

assert.equal(mod.requests.length, 1)
})

test('outside lich nothing is sent', async () => {
const mod = load({ env: { LICH_SUBAGENT_CARDS: 'off' } })
await mod.start()
Expand Down
Loading