From 77b6003ef2a8af7019a3fd7fd8f25a589d1a55a8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 22:19:54 -0300 Subject: [PATCH 1/2] feat(agent-cards): let a lich worker open cards of its own lich now sets LICH_SUBAGENT_DEPTH on every Claude Code session it starts (0 at the top, n for a worker n --subagent levels down) and keeps LICH_SUBAGENT_CARDS=off only for the user's setting and for a worker at its own depth limit. - worker-answer: a session is a worker when LICH_SUBAGENT_DEPTH is an integer above 0; without the variable, LICH_SUBAGENT_CARDS=off still marks one, for an older lich. - agent-cards: cards off in a worker (depth above 0) runs the native agent with a toast saying why; a top-level session stays silent. The subagent/ branch gate applies only without the depth variable, so a worker below lich's limit opens cards from an isolated worktree too. No existing test changed; new ones cover the depth rules and the legacy fallbacks. --- CHANGELOG.md | 10 ++++++++ docs/agent-cards.md | 31 +++++++++++++++++------- docs/mod-answer.md | 12 ++++++---- docs/providers.md | 9 ++++--- hooks/agent-cards.js | 35 ++++++++++++++++++++------- hooks/worker-answer.js | 28 ++++++++++++++++------ tests/agent-cards.engine.test.ts | 9 +++++++ tests/agent-cards.test.mjs | 32 +++++++++++++++++++++++++ tests/worker-answer.engine.test.ts | 11 +++++++++ tests/worker-answer.test.mjs | 38 ++++++++++++++++++++++++++++++ 10 files changed, 184 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b25b0..40b9acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/agent-cards.md b/docs/agent-cards.md index e002770..29c175c 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -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: diff --git a/docs/mod-answer.md b/docs/mod-answer.md index cf2c009..228628c 100644 --- a/docs/mod-answer.md +++ b/docs/mod-answer.md @@ -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 diff --git a/docs/providers.md b/docs/providers.md index 148c865..1c44561 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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: diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index bc7a785..3e1326f 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -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 @@ -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 @@ -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 @@ -291,13 +304,17 @@ 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, and lich keeps its own subagents native at this depth") + } 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}`) @@ -305,7 +322,7 @@ async function runAsSession($, state, e, next) { 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 }} */ diff --git a/hooks/worker-answer.js b/hooks/worker-answer.js index 5380d2b..74bbc17 100644 --- a/hooks/worker-answer.js +++ b/hooks/worker-answer.js @@ -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 @@ -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 @@ -37,6 +40,16 @@ 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. * @@ -44,13 +57,14 @@ const ANSWER_LIMIT = 16000 * @returns {Promise} */ 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 } } diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index be88631..ea41725 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -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 }) diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index e674618..52e379e 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -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, and lich keeps its own subagents native at this depth', + ]) +}) + +// 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 } diff --git a/tests/worker-answer.engine.test.ts b/tests/worker-answer.engine.test.ts index b2b10a9..03fd799 100644 --- a/tests/worker-answer.engine.test.ts +++ b/tests/worker-answer.engine.test.ts @@ -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.' }]) +}) diff --git a/tests/worker-answer.test.mjs b/tests/worker-answer.test.mjs index 9dd4241..8478d61 100644 --- a/tests/worker-answer.test.mjs +++ b/tests/worker-answer.test.mjs @@ -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() From d145be85f9b969e9b89215c1d7331fcb2d449fe8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 8 Oct 2026 22:27:07 -0300 Subject: [PATCH 2/2] fix(agent-cards): word the native toast for every reason lich keeps a worker native The text named the depth limit, but lich also keeps a worker native when the user turned subagent cards off. The test pins the toast's wording, which is what changed. --- hooks/agent-cards.js | 2 +- tests/agent-cards.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index 3e1326f..a462910 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -313,7 +313,7 @@ async function runAsSession($, state, e, next) { if (!lich || !session) return next(e) if (cards === CARDS_OFF) { if (!isWorkerDepth(depth)) return next(e) - return runNatively($, e, next, "this session is itself a lich subagent, and lich keeps its own subagents native at this depth") + 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) { diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index 52e379e..6475e50 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -273,7 +273,7 @@ test('a worker lich keeps native at its depth limit runs natively, with a toast 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, and lich keeps its own subagents native at this depth', + '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', ]) })