From 938f1aa6ddccbbd1efdc9b3d87f913a833b76e2f Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 5 Oct 2026 10:03:39 -0300 Subject: [PATCH] feat: run a subagent's lich session in the background, and let lich turn it off Once lich open hands the task over, the Agent call answers with Claude Code's async_launched arm at once instead of holding the asking turn on chunked lich wait calls. The report comes back as an ordinary lich errand: a [lich] note at the asking session's prompt, collected with wait_for_answer or lich wait. A context line after the result tells the model so, and that SendMessage cannot reach the worker. outputFile is "", Claude Code's own value for an agent with no output file; without canReadOutputFile it is never shown to the model. A session started with LICH_SUBAGENT_CARDS=off (lich's "Subagents as lich sessions" setting, off) leaves every Agent call native. Tests removed because the contract they pinned is gone (nothing waits after the open anymore): the chunked wait, "a wait that failed after the task reached the worker is denied" and "Esc during the wait stops the wait". The unread/undelivered, unanswered and unknown-status tests now read the status off the open's delivery, where the wait used to supply it; their outcomes are unchanged. --- CHANGELOG.md | 13 +++-- CLAUDE.md | 4 +- README.md | 6 +- docs/agent-cards.md | 77 ++++++++++++++----------- docs/providers.md | 5 +- hooks/agent-cards.js | 96 +++++++++++++++----------------- tests/agent-cards.engine.test.ts | 46 +++++++-------- tests/agent-cards.test.mjs | 95 +++++++++++-------------------- 8 files changed, 163 insertions(+), 179 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9894fe1..93c7d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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, and hands its report back as the subagent's result. - 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. Esc stops the wait, not the session; its report then arrives as a - [lich] note. Needs Claude Code 2.1.280 or later with mods on (measured on - 2.1.289). + 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 + 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). - **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/CLAUDE.md b/CLAUDE.md index ce1495f..f9d9509 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [docs/session-title.md](docs/session-title.md) — the provider's own session title, via `PostToolUse`/`PreInvocation` and `Stop` - [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only) - [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system -- [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` and `lich wait` CLI rather than an HTTP contract; Claude Code only +- [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only - [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report @@ -94,7 +94,7 @@ mod-control fixtures and its polls against the commands lich sends; [tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its usage reports against the mod-usage fixtures. [tests/agent-cards.test.mjs](tests/agent-cards.test.mjs) hands the agent-cards -mod the same kind of fake `$` and asserts the `lich open` and `lich wait` argv +mod the same kind of fake `$` and asserts the `lich open` argv it runs, the Agent result it answers each outcome with, and that a call it does not take, or one lich cannot take, reaches the native agent untouched. [tests/contract.mjs](tests/contract.mjs) is the fixtures, the assertions and the diff --git a/README.md b/README.md index 7167f52..86122af 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ 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 waits for that session's report -and hands it back as the subagent's result; Explore, Plan and other agent types -stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the +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 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 c6aed6f..4435429 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -5,9 +5,8 @@ card the user can watch and steer, where Claude Code would otherwise run the agent hidden inside the asking session. There is no HTTP contract behind it. It drives the lich CLI the asking session -already has, `$LICH_BIN open` and `$LICH_BIN wait`, so what it reads is what -those commands print (`docs/cli.md` in the lich repository: `open --json`, -`wait --json`, Exit status). +already has, `$LICH_BIN open`, so what it reads is what that command prints +(`docs/cli.md` in the lich repository: `open --json`, Exit status). | client | Claude Code | Codex | Antigravity | opencode | omp | Crush | |------------------------|-------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| @@ -30,7 +29,12 @@ of these hold, and every other one goes on to Claude Code untouched: - 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. + 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. + A session's environment is fixed when it starts, so turning the setting over + reaches the sessions started after that. A taken call runs: @@ -38,43 +42,49 @@ A taken call runs: 2. `$LICH_BIN open --kind claude --worktree [--base ] [--model ] --prompt --json`, given two minutes, the sum of the waits lich's own client allows an open with a task. -3. While the delivery is `pending`, `$LICH_BIN wait --timeout 540 --json `, - each given ten minutes, which is the most `$.process.run` allows. -| outcome | the Agent call answers | +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 | | `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 | | the open failed | the native agent runs, with a toast saying why | -| the wait failed, or a status it does not know | denied, naming the card and its branch | -The `completed` result is the Agent tool's own arm: `agentId` is the worker's -roster name, `worktreePath` and `worktreeBranch` are its checkout, and the token -and tool counts are zero because they are the worker's session's, not this one's. +`pending` is the usual case: a worker rarely reports within the open's own +wait. Both results are the Agent tool's own arms, and `agentId` is the worker's +roster name in each. `async_launched` carries the call's description and prompt +and an empty `outputFile`, which is the value Claude Code gives its own agents +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. ## How it behaves -- **The call is held until the worker reports**, as a foreground subagent holds - it: the asking turn waits. Claude Code's hook budget does not count the time a - `$.process.run` is in flight, so a held call can last as long as the worker - does (measured past 12 minutes on 2.1.289). Parallel Agent calls run their - hooks concurrently, so each opens its own card at once. Answering at once - (`async_launched`) was turned down: it promises a background task Claude Code - can notify about, read and stop, and none of that exists for a lich session. +- **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. - **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 and decides. A task over lich's 8192-byte limit, or an open that failed, runs - natively with a toast saying why, naming the card when one was opened. Once - the task reached the worker, a failure denies the call instead: a native agent - beside the worker would do the work twice. A worker's later report still - arrives at the asking session's prompt as a `[lich]` note. -- **Esc stops the wait, never the worker.** Claude Code aborts the call and - ends the `lich wait` child; the worker keeps running on its card, its ticket is - no longer waited on, and its report arrives as a `[lich]` note. Esc while the - open is still running denies the call too, because lich may already have - handed the task over. + natively with a toast saying why, naming the card when one was opened. Esc + 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 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, @@ -89,9 +99,6 @@ and tool counts are zero because they are the worker's session's, not this one's - **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. -- **The worker is reached through lich.** The result tells the model to use - `send_to_session` or `lich send`: Claude Code's hand-back suggests - `SendMessage`, which cannot reach a lich session. ## Known ceilings @@ -101,7 +108,13 @@ and tool counts are zero because they are the worker's session's, not this one's so they stay native. - **The result reports no tokens and no tool uses.** Both are counted in the worker's own session. +- **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. - **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. Claude Code defers a reload while a turn runs, so a held call is - never cut by one. + until then. diff --git a/docs/providers.md b/docs/providers.md index b8190df..2f4ff0f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -4,7 +4,10 @@ lich drives an agent CLI inside a PTY and injects the same three variables (`LICH_PORT`, `LICH_TOKEN`, `LICH_SESSION_ID`) into every one it spawns. The [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*. +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)). This repository is one plugin, packaged for each harness it supports: diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index c6dddca..a0f3a8f 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -1,22 +1,20 @@ // 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`, `$LICH_BIN wait`), whose -// output and exit codes are docs/cli.md in the lich repository. +// behind it: it drives the lich CLI (`$LICH_BIN open`), whose output and exit +// codes are docs/cli.md in the lich repository. // -// The Agent call is held until the worker reports, as a foreground subagent -// holds it. Answering at once (`async_launched`) promises a task Claude Code -// can notify about, read and stop, and none of that exists for a lich session; -// held, the ticket also stays attended, so lich never expires it under a long -// run. Esc turns the held call into the unattended case on its own: the report -// then arrives at this session's prompt as a [lich] note. +// 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. // // 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 // place (measured on 2.1.289). Every failure is therefore caught here and // decided: before lich has the task, the native agent runs with a toast saying -// why; after, the call is denied, because a native agent beside the worker -// would do the work twice. +// why; once lich may have it, the call is denied, because a native agent beside +// the worker would do the work twice. // // Every function that takes `$` is declared at the top level: the loader // refuses a module that hands `$` to a nested function or keeps it in a variable. @@ -30,17 +28,9 @@ const PROMPT_LIMIT_BYTES = 8192 // internal/cli/cli.go. This bound only has to outlast lich's own. const OPEN_TIMEOUT_MS = 120000 -// `$.process.run` kills a child after ten minutes at most, and `lich wait` -// holds the line for its timeout plus a 30s client slack, so each chunk ends -// on lich's answer, never on the kill. -const WAIT_CHUNK_SECONDS = 540 -const WAIT_TIMEOUT_MS = 600000 - -// docs/cli.md, Exit status: a ticket came back (2), the errand is over with no -// answer coming (3). Both print their result like an answer (0) does. -const EXIT_PENDING = 2 -const EXIT_NO_ANSWER = 3 -const REPORTING_EXITS = new Set([0, EXIT_PENDING, EXIT_NO_ANSWER]) +// The value lich's Settings › Providers › Claude Code writes into a session's +// environment when "Subagents as lich sessions" is off. +const CARDS_OFF = "off" // The bounds lich's own worktree dialog slugs a typed name with // (`toBranchName`, frontend/src/lib/git/branch-name.ts there), so a branch @@ -169,22 +159,34 @@ async function openWorker($, lich, e, branch, base) { } /** - * Waits on the ticket a chunk at a time until the errand has an outcome. + * The Agent tool's `async_launched` arm, plus what the model reads after it. + * Claude Code's own text for this arm promises a task notification and points + * at SendMessage, and neither reaches a lich session, so `context` says how the + * report really comes back. `outputFile` is "", Claude Code's own value for an + * agent with no output file: without `canReadOutputFile` the model is never + * shown it. * - * @param {Engine} $ - * @param {string} lich + * @param {AgentCall} e + * @param {Opened} opened + * @param {string} branch * @param {Report} delivery - * @returns {Promise} */ -async function awaitReport($, lich, delivery) { - let report = delivery - while (report.status === "pending") { - const argv = [lich, "wait", "--timeout", String(WAIT_CHUNK_SECONDS), "--json", delivery.ticket] - const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: WAIT_TIMEOUT_MS }) - if (!REPORTING_EXITS.has(exitCode)) throw new Error(stderr.trim() || `lich wait exited ${exitCode}`) - report = JSON.parse(stdout) +function backgrounded(e, opened, branch, delivery) { + return { + result: { + status: /** @type {const} */ ("async_launched"), + agentId: opened.name, + description: e.description, + prompt: e.prompt, + 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; ` + + `send_to_session or lich send can.`, + ], } - return report } /** @@ -230,6 +232,8 @@ 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) case "answered": { const where = `The work is on branch ${branch} in ${opened.path} (lich session "${opened.label}"), not in this checkout. ` + @@ -246,7 +250,7 @@ function answerFor(e, opened, branch, report, durationMs) { case "undelivered": return { deny: `the task never reached "${opened.label}" (${report.status}): open its card.` } default: - throw new Error(`lich answered "${report.status}"`) + return { deny: `lich answered "${report.status}" about "${opened.label}", on branch ${branch}: open its card.` } } } @@ -269,8 +273,13 @@ function runNatively($, e, next, reason) { */ async function runAsSession($, state, e, next) { if (!isDelegable(e, next, state)) return next(e) - const [lich, session] = await Promise.all([$.env.get("LICH_BIN"), $.env.get("LICH_SESSION_ID")]) + const [lich, session, cards] = await Promise.all([ + $.env.get("LICH_BIN"), + $.env.get("LICH_SESSION_ID"), + $.env.get("LICH_SUBAGENT_CARDS"), + ]) if (!lich || !session) return next(e) + if (cards === CARDS_OFF) return next(e) 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}`) @@ -292,22 +301,7 @@ async function runAsSession($, state, e, next) { } return runNatively($, e, next, messageOf(error)) } - - try { - const report = await awaitReport($, lich, opened.delivery) - return answerFor(e, opened, branch, report, (await $.clock.now()) - startedMs) - } catch (error) { - if (next.signal.aborted) { - return { - deny: `interrupted; "${opened.label}" keeps running on branch ${branch}, and a report it sends arrives here as a [lich] note.`, - } - } - return { - deny: - `lich stopped answering about "${opened.label}" (${messageOf(error)}). It may still be running on branch ` + - `${branch}, but a report it sends reaches this session only while the lich that opened it runs: open its card.`, - } - } + return answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs) } /** @param {import('claude-code').On} on */ diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index c643115..989fb8e 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -6,15 +6,15 @@ // // tests/agent-cards.test.mjs is the suite CI runs; this one needs a claude // binary, and proves the module loads beside mod-control.js and its hooks -// chain in the engine that runs it: a deny and a fallback to the native agent -// reach the caller as the engine relays them. The kit does not check a -// `tool.call` result against the Agent tool's output schema (measured on -// 2.1.289), so the `completed` shape is pinned by a live run, not here. +// chain in the engine that runs it: a backgrounded result and a fallback to the +// native agent reach the caller as the engine relays them. The kit does not +// check a `tool.call` result against the Agent tool's output schema (measured +// on 2.1.289), so the `async_launched` shape is pinned by a live run, not here. import { expect, mock, test } from 'claude-code/testing' import type { On, ProcessRunResult } from 'claude-code' -const ENV = { LICH_BIN: '/opt/lich/bin/lich', LICH_SESSION_ID: 'lich-1' } +const ENV: Record = { LICH_BIN: '/opt/lich/bin/lich', LICH_SESSION_ID: 'lich-1' } const BRANCH = 'subagent/fix-the-auth-flow-ab12' const OPENED = { id: '9f8e', label: BRANCH, name: `${BRANCH}-9f8e`, kind: 'claude', path: `/wt/${BRANCH}` } const CALL = { @@ -38,10 +38,14 @@ const exited = (exitCode: number, stdout: unknown = '', stderr = ''): ProcessRun * stands for the native agent and the engine's own session start. */ function world(on: On, ...lich: ProcessRunResult[]) { + return worldWith(on, ENV, ...lich) +} + +function worldWith(on: On, env: Record, ...lich: ProcessRunResult[]) { const native: string[] = [] const argvs: string[][] = [] const toasts: string[] = [] - mock.env(on, ENV) + mock.env(on, env) mock.clock(on) on('session.start', async (_$, e) => ({ cwd: e.cwd })) on('process.run', async (_$, e) => { @@ -62,33 +66,21 @@ function world(on: On, ...lich: ProcessRunResult[]) { return { native, argvs, toasts } } -test('an answered task is the Agent tool\'s completed result', async ($, on) => { +test('a task lich took comes back at once as a backgrounded agent', async ($, on) => { const w = world( on, - exited(0, { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } }), - exited(0, { ticket: 't1', target: BRANCH, status: 'answered', answer: 'Fixed.' }), + 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) expect(ran.deny).toBe(undefined) expect(ran.isError).toBe(undefined) expect(ran.result).toEqual( - expect.objectContaining({ status: 'completed', agentId: OPENED.name, worktreeBranch: BRANCH }), - ) - expect(w.native).toEqual([]) - expect(w.argvs.map((argv) => argv[1])).toEqual(['branch', 'open', 'wait']) -}) - -test('a failed wait after the open is denied and the native agent never runs', async ($, on) => { - const w = world( - on, - exited(0, { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } }), - exited(1, '', 'lich: no lich is running\n'), + expect.objectContaining({ status: 'async_launched', agentId: OPENED.name, outputFile: '' }), ) - await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) - const ran = await $.tool.call(CALL) - expect(ran.deny).toEqual(expect.stringContaining(`lich stopped answering about "${BRANCH}"`)) + expect(ran.context).toEqual([expect.stringContaining('wait_for_answer')]) expect(w.native).toEqual([]) + expect(w.argvs.map((argv) => argv[1])).toEqual(['branch', 'open']) }) test('a failed open runs the native agent, with a toast saying why', async ($, on) => { @@ -114,3 +106,11 @@ test('an Explore agent stays native and lich is never run', async ($, on) => { expect(w.native).toEqual(['Fix the auth flow']) expect(w.argvs).toEqual([]) }) + +test('turned off in lich, every subagent stays native and lich is never run', async ($, on) => { + const w = worldWith(on, { ...ENV, LICH_SUBAGENT_CARDS: 'off' }) + 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([]) +}) diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index baa46b7..67c2561 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -1,13 +1,13 @@ // Pins the Claude Code mod that runs a subagent as a lich session -// (hooks/agent-cards.js) to the lich CLI it drives: the argv of `lich open` and -// `lich wait`, how each outcome they print is answered, and the rule that a -// call the mod does not take goes to the native agent untouched. +// (hooks/agent-cards.js) to the lich CLI it drives: the argv of `lich open`, how +// each outcome it prints is answered, and the rule that a call the mod does not +// take goes to the native agent untouched. // // Like tests/mod-control.test.mjs it imports the module and hands it a fake // engine. The `e` fields, `next.origin`, `next.signal`, the Agent result arms // and the `$.process.run` behaviour were measured on Claude Code 2.1.289; the // CLI's output shapes and exit codes are docs/cli.md in lich (`open --json`, -// `wait --json`, Exit status). +// Exit status). // // Run: node --test tests/ @@ -55,8 +55,6 @@ const exits = (code, stdout = '', stderr = '') => () => ({ const rejects = (message) => () => Promise.reject(new Error(message)) const onBranch = (name) => exits(0, `${name}\n`) -const waitArgv = [LICH_BIN, 'wait', '--timeout', '540', '--json', TICKET] - /** * Registers the module against a fake engine. `runs` answers each * `$.process.run` in turn; one past the script fails the test. `times` is what @@ -143,6 +141,18 @@ function completed(text, { durationMs = 3000, prompt = AGENT.prompt } = {}) { } } +function backgrounded({ prompt = AGENT.prompt, description = AGENT.description } = {}) { + 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.`, + ], + } +} + 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.` @@ -212,6 +222,12 @@ test('a session that never reported its start stays native', async () => { assertNativeUntouched({ mod, ...(await mod.call()) }) }) +test('subagents as lich sessions turned off in lich keeps every call native', async () => { + const mod = load({ env: { ...ENV, LICH_SUBAGENT_CARDS: 'off' } }) + await mod.start() + assertNativeUntouched({ mod, ...(await mod.call()) }) +}) + test('outside lich every call stays native', async () => { for (const missing of ['LICH_BIN', 'LICH_SESSION_ID']) { const env = { ...ENV } @@ -289,25 +305,16 @@ test('an answer at the open comes back as the completed subagent', async () => { assert.deepEqual(mod.toasts, []) }) -test('a pending task is waited on in chunks until it is answered', async () => { - const { answer, mod } = await delegate([ - onBranch('main'), - exits(0, opened('pending')), - exits(2, report('pending')), - exits(0, report('answered', 'Fixed.')), - ]) - assert.deepEqual( - mod.ran.slice(2), - [ - { argv: waitArgv, init: { timeoutMs: 600000 } }, - { argv: waitArgv, init: { timeoutMs: 600000 } }, - ], - ) - assert.deepEqual(answer, completed(`Fixed.${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) + assert.deepEqual(passed, []) + assert.deepEqual(mod.toasts, []) }) test('a worker that ended its turn without reporting is a completed subagent saying so', async () => { - const { answer } = await delegate([onBranch('main'), exits(0, opened('pending')), exits(3, report('unanswered'))]) + const { answer } = await delegate([onBranch('main'), exits(3, opened('unanswered'))]) assert.deepEqual( answer, completed( @@ -319,14 +326,9 @@ 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']) { - for (const runs of [ - [onBranch('main'), exits(0, opened(status))], - [onBranch('main'), exits(0, opened('pending')), exits(3, report(status))], - ]) { - const { answer, passed } = await delegate(runs) - assert.deepEqual(answer, { deny: `the task never reached "${BRANCH}" (${status}): open its card.` }) - assert.deepEqual(passed, []) - } + const { answer, passed } = await delegate([onBranch('main'), exits(3, opened(status))]) + assert.deepEqual(answer, { deny: `the task never reached "${BRANCH}" (${status}): open its card.` }) + assert.deepEqual(passed, []) } }) @@ -398,37 +400,8 @@ test('an open the user interrupted is denied, never run natively', async () => { // ------------------------------------------------ never native after delegation -- -test('a wait that failed after the task reached the worker is denied, never run natively', async () => { - for (const wait of [exits(1, '', 'lich: no lich is running\n'), rejects('$.process.run(lich) aborted: still running after 600000ms')]) { - const { answer, passed, mod } = await delegate([onBranch('main'), exits(0, opened('pending')), wait]) - assert.deepEqual(passed, []) - assert.deepEqual(mod.toasts, []) - assert.match(answer.deny, new RegExp(`^lich stopped answering about "${BRANCH}" \\(.+\\)\\. It may still be running on branch ${BRANCH}, but a report it sends reaches this session only while the lich that opened it runs: open its card\\.$`)) - } -}) - test('a status this mod does not know is denied, never run natively', async () => { - const { answer, passed } = await delegate([onBranch('main'), exits(0, opened('pending')), exits(3, report('mislaid'))]) + const { answer, passed } = await delegate([onBranch('main'), exits(3, opened('mislaid'))]) assert.deepEqual(passed, []) - assert.match(answer.deny, /^lich stopped answering about .+ \(lich answered "mislaid"\)/) -}) - -test('Esc during the wait stops the wait, not the worker', async () => { - const stop = new AbortController() - const { answer, passed } = await delegate( - [ - onBranch('main'), - exits(0, opened('pending')), - () => { - stop.abort() - return Promise.reject(new Error('$.process.run(lich) aborted')) - }, - ], - AGENT, - { signal: stop.signal }, - ) - assert.deepEqual(passed, []) - assert.deepEqual(answer, { - deny: `interrupted; "${BRANCH}" keeps running on branch ${BRANCH}, and a report it sends arrives here as a [lich] note.`, - }) + assert.deepEqual(answer, { deny: `lich answered "mislaid" about "${BRANCH}", on branch ${BRANCH}: open its card.` }) })