From 46d02dbf96850306781cfcc15d32296096468f95 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 5 Oct 2026 10:41:56 -0300 Subject: [PATCH] feat: open a subagent's lich session with --subagent, in the caller's checkout unless it asks for a worktree The mod now always passes `lich open --subagent`, so lich types the worker's full report at the caller's prompt and the model no longer has to collect it. Like Claude Code's native agent, the worker shares the caller's checkout; only `isolation: "worktree"` gets `--worktree subagent/-` and `--base`, and only then is git asked for the current branch. A worker in the shared checkout is recognised by LICH_SUBAGENT_CARDS=off, which lich sets on every `--subagent` session; the subagent/ branch rule still covers isolated calls. --- CHANGELOG.md | 18 ++-- README.md | 9 +- docs/agent-cards.md | 68 +++++++------ hooks/agent-cards.js | 63 ++++++------ tests/agent-cards.engine.test.ts | 18 +++- tests/agent-cards.test.mjs | 159 ++++++++++++++++++++----------- 6 files changed, 213 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c7d17..a4d6b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 86122af..a683068 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/agent-cards.md b/docs/agent-cards.md index 4435429..5f6c025 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -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 @@ -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 [--base ] [--model ] --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 [--base ]] [--model ] --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 | @@ -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 @@ -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. @@ -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. diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index a0f3a8f..4544674 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -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 @@ -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 @@ -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} */ 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", @@ -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"), @@ -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.`, ], } @@ -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 @@ -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 } : {}), } } @@ -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.` } } } @@ -285,10 +293,11 @@ 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 { @@ -296,7 +305,7 @@ async function runAsSession($, state, e, next) { } 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)) diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index 989fb8e..af1d252 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -78,9 +78,23 @@ test('a task lich took comes back at once as a backgrounded agent', async ($, on expect(ran.result).toEqual( expect.objectContaining({ status: 'async_launched', agentId: OPENED.name, outputFile: '' }), ) - expect(ran.context).toEqual([expect.stringContaining('wait_for_answer')]) + expect(ran.context).toEqual([expect.stringContaining('in this same checkout')]) expect(w.native).toEqual([]) - expect(w.argvs.map((argv) => argv[1])).toEqual(['branch', 'open']) + expect(w.argvs).toEqual([['/opt/lich/bin/lich', 'open', '--kind', 'claude', '--subagent', '--prompt', CALL.prompt, '--json']]) +}) + +test('an isolated task opens in a worktree off the current branch', async ($, on) => { + const w = world( + on, + exited(2, { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } }), + ) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + const ran = await $.tool.call({ ...CALL, isolation: 'worktree' }) + expect(ran.context).toEqual([expect.stringContaining(`on branch ${BRANCH}`)]) + expect(w.argvs.map((argv) => argv.slice(1, 9))).toEqual([ + ['branch', '--show-current'], + ['open', '--kind', 'claude', '--subagent', '--worktree', BRANCH, '--base', 'main'], + ]) }) test('a failed open runs the native agent, with a toast saying why', async ($, on) => { diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index 67c2561..d93c3f1 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -39,10 +39,14 @@ const AGENT = { prompt: 'Fix the login bug in auth/login.go and run its tests.', subagent_type: 'general-purpose', } +const ISOLATED = { ...AGENT, isolation: 'worktree' } +// A worker without isolation opens in the asking session's directory. +const SHARED = { ...OPENED, label: 'claude-2', name: 'claude-2-9f8e', path: '/w' } const NATIVE = { result: 'the native agent ran' } const report = (status, answer = '') => ({ ticket: TICKET, target: BRANCH, status, answer }) const opened = (status, answer) => ({ ...OPENED, delivery: report(status, answer) }) +const openedHere = (status, answer) => ({ ...SHARED, delivery: report(status, answer) }) /** One scripted `$.process.run` answer: the child exited with `code`. */ const exits = (code, stdout = '', stderr = '') => () => ({ @@ -116,11 +120,12 @@ async function delegate(runs, e = AGENT, options = {}) { return { mod, ...(await mod.call(e, options)) } } -function completed(text, { durationMs = 3000, prompt = AGENT.prompt } = {}) { +function completed(text, { durationMs = 3000, prompt = AGENT.prompt, worker = OPENED } = {}) { + const worktree = worker === OPENED ? { worktreePath: OPENED.path, worktreeBranch: BRANCH } : {} return { result: { status: 'completed', - agentId: OPENED.name, + agentId: worker.name, content: [{ type: 'text', text }], totalToolUseCount: 0, totalDurationMs: durationMs, @@ -135,27 +140,33 @@ function completed(text, { durationMs = 3000, prompt = AGENT.prompt } = {}) { cache_creation: null, }, prompt, - worktreePath: OPENED.path, - worktreeBranch: BRANCH, + ...worktree, }, } } -function backgrounded({ prompt = AGENT.prompt, description = AGENT.description } = {}) { +const reportArrives = + '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.' + +function backgrounded({ worker = OPENED } = {}) { + const where = + worker === OPENED + ? `on branch ${BRANCH} in ${OPENED.path}, not in this checkout` + : `in this same checkout, ${SHARED.path}, and edits its files as you do` return { - result: { status: 'async_launched', agentId: OPENED.name, description, prompt, outputFile: '' }, - context: [ - `The agent runs as the lich session "${BRANCH}", 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 ${TICKET}. SendMessage cannot reach that session; ` + - `send_to_session or lich send can.`, - ], + result: { status: 'async_launched', agentId: worker.name, description: AGENT.description, prompt: AGENT.prompt, outputFile: '' }, + context: [`The agent runs as the lich session "${worker.label}", ${where}. ${reportArrives}`], } } const whereItIs = `\n\nThe work is on branch ${BRANCH} in ${OPENED.path} (lich session "${BRANCH}"), not in this checkout. ` + `Reach that session with send_to_session or lich send, not SendMessage.` +const whereItIsHere = + `\n\nThe work is in this same checkout, ${SHARED.path} (lich session "${SHARED.label}"). ` + + `Reach that session with send_to_session or lich send, not SendMessage.` function assertNativeUntouched({ answer, passed, mod }, e = AGENT) { assert.deepEqual(passed, [e]) @@ -187,11 +198,11 @@ test('every type but general-purpose stays native, an unknown one included', asy } }) -test("a worker this mod opened keeps its own subagents native", async () => { +test("a worker on a branch this mod opened keeps its isolated subagents native", async () => { const mod = load({ runs: [onBranch('subagent/fix-the-auth-flow-ab12')] }) await mod.start() - const { answer, passed } = await mod.call() - assert.deepEqual(passed, [AGENT]) + const { answer, passed } = await mod.call(ISOLATED) + assert.deepEqual(passed, [ISOLATED]) assert.equal(answer, NATIVE) assert.equal(mod.ran.length, 1) }) @@ -240,12 +251,25 @@ test('outside lich every call stays native', async () => { // --------------------------------------------------------------- the open -- -test('a general-purpose call opens a Claude Code session off the current branch', async () => { - const { mod } = await delegate([onBranch('feat/login'), exits(0, opened('answered', 'done'))]) +test('a general-purpose call opens a Claude Code subagent session in this checkout, without asking git', async () => { + const { mod } = await delegate([exits(0, openedHere('answered', 'done'))]) + assert.deepEqual(mod.ran, [ + { + argv: [LICH_BIN, 'open', '--kind', 'claude', '--subagent', '--prompt', AGENT.prompt, '--json'], + init: { timeoutMs: 120000 }, + }, + ]) +}) + +test('an isolated call opens the session in a worktree off the current branch', async () => { + const { mod } = await delegate([onBranch('feat/login'), exits(0, opened('answered', 'done'))], ISOLATED) assert.deepEqual(mod.ran, [ { argv: ['git', 'branch', '--show-current'], init: undefined }, { - argv: [LICH_BIN, 'open', '--kind', 'claude', '--worktree', BRANCH, '--base', 'feat/login', '--prompt', AGENT.prompt, '--json'], + argv: [ + LICH_BIN, 'open', '--kind', 'claude', '--subagent', '--worktree', BRANCH, '--base', 'feat/login', + '--prompt', AGENT.prompt, '--json', + ], init: { timeoutMs: 120000 }, }, ]) @@ -254,21 +278,23 @@ test('a general-purpose call opens a Claude Code session off the current branch' test('a call with no type is a general-purpose one', async () => { const e = { ...AGENT } delete e.subagent_type - const { mod, passed } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], e) + const { mod, passed } = await delegate([exits(0, openedHere('answered', 'done'))], e) assert.deepEqual(passed, []) - assert.equal(mod.ran[1].argv[1], 'open') + assert.equal(mod.ran[0].argv[1], 'open') }) test('the model the call names is the model the session starts on', async () => { - const { mod } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], { ...AGENT, model: 'haiku' }) - assert.deepEqual(mod.ran[1].argv.slice(6, 10), ['--base', 'main', '--model', 'haiku']) + const { mod } = await delegate([exits(0, openedHere('answered', 'done'))], { ...AGENT, model: 'haiku' }) + assert.deepEqual(mod.ran[0].argv.slice(4, 7), ['--subagent', '--model', 'haiku']) + const isolated = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], { ...ISOLATED, model: 'haiku' }) + assert.deepEqual(isolated.mod.ran[1].argv.slice(7, 11), ['--base', 'main', '--model', 'haiku']) }) -test('with no current branch the session opens off lich\'s default base', async () => { +test('with no current branch an isolated session opens off lich\'s default base', async () => { for (const git of [exits(0, '\n'), exits(128, '', 'fatal: not a git repository'), rejects('git: not found')]) { - const { mod } = await delegate([git, exits(0, opened('answered', 'done'))]) + const { mod } = await delegate([git, exits(0, opened('answered', 'done'))], ISOLATED) assert.deepEqual(mod.ran[1].argv, [ - LICH_BIN, 'open', '--kind', 'claude', '--worktree', BRANCH, '--prompt', AGENT.prompt, '--json', + LICH_BIN, 'open', '--kind', 'claude', '--subagent', '--worktree', BRANCH, '--prompt', AGENT.prompt, '--json', ]) } }) @@ -285,38 +311,57 @@ test('the branch is subagent/, a slug of the description and the end of the call ['', 'agent'], ] for (const [description, slug] of cases) { - const { mod } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], { ...AGENT, description }) - assert.equal(mod.ran[1].argv[5], `subagent/${slug}-ab12`, description) + const { mod } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], { ...ISOLATED, description }) + assert.equal(mod.ran[1].argv[6], `subagent/${slug}-ab12`, description) } }) test('the suffix is the last four letters or digits of the call id, lowercased', async () => { - const e = { ...AGENT, tool_use_id: 'toolu_01XyZ_9Q' } + const e = { ...ISOLATED, tool_use_id: 'toolu_01XyZ_9Q' } const { mod } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], e) - assert.equal(mod.ran[1].argv[5], 'subagent/fix-the-auth-flow-yz9q') + assert.equal(mod.ran[1].argv[6], 'subagent/fix-the-auth-flow-yz9q') }) // ------------------------------------------------------------- the report -- -test('an answer at the open comes back as the completed subagent', async () => { - const { answer, passed, mod } = await delegate([onBranch('main'), exits(0, opened('answered', 'Fixed; tests pass.'))]) - assert.deepEqual(answer, completed(`Fixed; tests pass.${whereItIs}`)) +test('an answer at the open comes back as the completed subagent, with no worktree of its own', async () => { + const { answer, passed, mod } = await delegate([exits(0, openedHere('answered', 'Fixed; tests pass.'))]) + assert.deepEqual(answer, completed(`Fixed; tests pass.${whereItIsHere}`, { worker: SHARED })) assert.deepEqual(passed, []) assert.deepEqual(mod.toasts, []) }) +test('an isolated answer at the open comes back with its worktree', async () => { + const { answer } = await delegate([onBranch('main'), exits(0, opened('answered', 'Fixed; tests pass.'))], ISOLATED) + assert.deepEqual(answer, completed(`Fixed; tests pass.${whereItIs}`)) +}) + test('a task still pending at the open comes back at once as a backgrounded agent, never waited on', async () => { - const { answer, passed, mod } = await delegate([onBranch('main'), exits(2, opened('pending'))]) - assert.deepEqual(answer, backgrounded()) - assert.equal(mod.ran.length, 2) + const { answer, passed, mod } = await delegate([exits(2, openedHere('pending'))]) + assert.deepEqual(answer, backgrounded({ worker: SHARED })) + assert.equal(mod.ran.length, 1) assert.deepEqual(passed, []) assert.deepEqual(mod.toasts, []) }) +test('an isolated task still pending at the open says which branch the worker is on', async () => { + const { answer } = await delegate([onBranch('main'), exits(2, opened('pending'))], ISOLATED) + assert.deepEqual(answer, backgrounded()) +}) + test('a worker that ended its turn without reporting is a completed subagent saying so', async () => { - const { answer } = await delegate([onBranch('main'), exits(3, opened('unanswered'))]) + const { answer } = await delegate([exits(3, openedHere('unanswered'))]) assert.deepEqual( answer, + completed( + `"${SHARED.label}" ended its turn without reporting back through lich. What it did is on its card and in ` + + `this checkout; a report it sends later arrives here as a [lich] note.`, + { worker: SHARED }, + ), + ) + const isolated = await delegate([onBranch('main'), exits(3, opened('unanswered'))], ISOLATED) + assert.deepEqual( + isolated.answer, completed( `"${BRANCH}" 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.`, @@ -326,7 +371,7 @@ test('a worker that ended its turn without reporting is a completed subagent say test('a task that never reached the worker is denied, never run natively', async () => { for (const status of ['unread', 'undelivered']) { - const { answer, passed } = await delegate([onBranch('main'), exits(3, opened(status))]) + const { answer, passed } = await delegate([onBranch('main'), exits(3, opened(status))], ISOLATED) assert.deepEqual(answer, { deny: `the task never reached "${BRANCH}" (${status}): open its card.` }) assert.deepEqual(passed, []) } @@ -347,12 +392,12 @@ test('a task over lich\'s byte limit runs natively, with a toast saying why', as test('a task exactly at the byte limit goes to lich', async () => { const e = { ...AGENT, prompt: 'é'.repeat(4096) } - const { passed } = await delegate([onBranch('main'), exits(0, opened('answered', 'done'))], e) + const { passed } = await delegate([exits(0, openedHere('answered', 'done'))], e) assert.deepEqual(passed, []) }) test('an open that failed runs natively, with lich\'s reason in a toast', async () => { - const { mod, answer, passed } = await delegate([onBranch('main'), exits(1, '', 'lich: no lich is running\n')]) + const { mod, answer, passed } = await delegate([exits(1, '', 'lich: no lich is running\n')]) assert.deepEqual(passed, [AGENT]) assert.equal(answer, NATIVE) assert.deepEqual(mod.toasts, ['lich: ran "Fix the auth flow" as a Claude Code subagent: lich: no lich is running']) @@ -360,7 +405,6 @@ test('an open that failed runs natively, with lich\'s reason in a toast', async test('an open whose task never reached the session runs natively, naming the card', async () => { const { mod, passed } = await delegate([ - onBranch('main'), exits(1, OPENED, 'lich: the session is open, but the task did not reach it: its terminal ended\n'), ]) assert.deepEqual(passed, [AGENT]) @@ -369,7 +413,7 @@ test('an open whose task never reached the session runs natively, naming the car }) test('an open that could not run at all runs natively, with a toast', async () => { - const { mod, passed } = await delegate([onBranch('main'), rejects('$.process.run(lich) aborted: still running after 120000ms')]) + const { mod, passed } = await delegate([rejects('$.process.run(lich) aborted: still running after 120000ms')]) assert.deepEqual(passed, [AGENT]) assert.deepEqual(mod.toasts, [ 'lich: ran "Fix the auth flow" as a Claude Code subagent: $.process.run(lich) aborted: still running after 120000ms', @@ -379,21 +423,24 @@ test('an open that could not run at all runs natively, with a toast', async () = // Esc while the open is in flight: lich may already have opened the card and // handed it the task, so running the agent natively as well could do it twice. test('an open the user interrupted is denied, never run natively', async () => { - const stop = new AbortController() - const { answer, passed, mod } = await delegate( - [ - onBranch('main'), - () => { - stop.abort() - return Promise.reject(new Error('$.process.run(lich) aborted')) - }, - ], - AGENT, - { signal: stop.signal }, - ) + const interrupted = () => { + const stop = new AbortController() + const run = () => { + stop.abort() + return Promise.reject(new Error('$.process.run(lich) aborted')) + } + return { run, signal: stop.signal } + } + const here = interrupted() + const { answer, passed, mod } = await delegate([here.run], AGENT, { signal: here.signal }) assert.deepEqual(passed, []) assert.deepEqual(mod.toasts, []) assert.deepEqual(answer, { + deny: 'interrupted; a lich session may already have the task, and a report it sends arrives here as a [lich] note.', + }) + const isolated = interrupted() + const onItsBranch = await delegate([onBranch('main'), isolated.run], ISOLATED, { signal: isolated.signal }) + assert.deepEqual(onItsBranch.answer, { deny: `interrupted; a lich session on branch ${BRANCH} may already have the task, and a report it sends arrives here as a [lich] note.`, }) }) @@ -401,7 +448,9 @@ test('an open the user interrupted is denied, never run natively', async () => { // ------------------------------------------------ never native after delegation -- test('a status this mod does not know is denied, never run natively', async () => { - const { answer, passed } = await delegate([onBranch('main'), exits(3, opened('mislaid'))]) + const { answer, passed } = await delegate([exits(3, openedHere('mislaid'))]) assert.deepEqual(passed, []) - assert.deepEqual(answer, { deny: `lich answered "mislaid" about "${BRANCH}", on branch ${BRANCH}: open its card.` }) + assert.deepEqual(answer, { deny: `lich answered "mislaid" about "${SHARED.label}": open its card.` }) + const isolated = await delegate([onBranch('main'), exits(3, opened('mislaid'))], ISOLATED) + assert.deepEqual(isolated.answer, { deny: `lich answered "mislaid" about "${BRANCH}", on branch ${BRANCH}: open its card.` }) })