From 645b1759e29fc37437e7fa83324099a9cf939817 Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 5 Oct 2026 11:46:54 -0300 Subject: [PATCH] feat: count running lich workers in Claude Code's status line and stop them with TaskStop A subagent the mod runs as a lich session now behaves like a native background agent in two more ways. While any runs, the status line shows "lich: N workers", read every 5 seconds from `lich sessions --json`: a worker listed with a done turn, or no longer listed (lich closes one in the asking checkout once it reported), is finished. And TaskStop on a worker's agentId, which Claude Code 2.1.289 measured is how a background agent is stopped (Esc leaves it running), is taken by the mod: a worker in the asking checkout is closed with `lich close`, an isolated one has its turn stopped with `lich control abort`, and the result is TaskStop's own shape. The test harness chains several hooks on one event, as the engine does, now that the module registers two tool.call hooks. --- CHANGELOG.md | 9 ++ docs/agent-cards.md | 35 +++++++- hooks/agent-cards.js | 139 +++++++++++++++++++++++++++++- tests/agent-cards.engine.test.ts | 45 +++++++++- tests/agent-cards.test.mjs | 141 ++++++++++++++++++++++++++++++- 5 files changed, 358 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d6b98..eb7696e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Claude Code's status line counts the lich workers a session waits on.** + While a subagent it opened as a lich session runs, `lich: N workers` shows + under the prompt, as Claude Code's own hint does for its background agents, + and it clears when the last one is done. +- **TaskStop stops a subagent running as a lich session.** As with a native + background agent, Esc leaves it running and the model's TaskStop ends it: a + worker in the asking session's checkout is closed, and one on a worktree of + its own has its turn stopped and keeps its card and worktree. + - **lich can run a Claude Code session's slash commands.** The mod takes a `command` naming one (`/compact`, `/clear`, …) and its arguments, runs it once the session is idle, and acks it with Claude Code's own error when the name is diff --git a/docs/agent-cards.md b/docs/agent-cards.md index 5f6c025..f3cce3b 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -54,7 +54,7 @@ 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 | +| `pending` | `async_launched` at once, plus a note on how the report comes back; the worker is counted in the status line | | `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 | @@ -107,6 +107,29 @@ for an isolated call. 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. +- **Claude Code's status line counts the workers still running**, as its own + "N agents" hint counts background agents: `N workers` under the prompt + (`$.ui.status`), which Claude Code shows after the plugin's name as + `lich: N workers`, cleared at none. Every 5 seconds while any runs, the mod reads + `$LICH_BIN sessions --json`; a worker lich lists with a turn `done`, or no + longer lists at all, is finished. lich closes a worker in this checkout once + it reported and its turn ended (`lich open --subagent` in lich's docs/cli.md), + which is what takes it off the list. A list lich could not give leaves the + count as it was until the next read. +- **Esc does not stop a worker, and TaskStop does**, as with a native background + agent. Measured on Claude Code 2.1.289: Esc in the asking session interrupts + its own turn and leaves its background agents running (a second Esc at the + idle prompt too; Claude Code stops them all with its own `ctrl+x ctrl+k`, + pressed twice), and the model stops one with `TaskStop({ task_id })`, the + `agentId` the Agent call answered, which answers + `{ message, task_id, task_type: "local_agent", command }`. The mod takes a + TaskStop whose `task_id` (or the deprecated `shell_id`) is a worker it opened + and is still counting: a worker in this checkout is closed with + `$LICH_BIN close `, having nothing of its own to keep, and an isolated + one has its turn stopped with `$LICH_BIN control abort`, its card and + worktree left for the user. Either answers TaskStop's own result shape; a + command lich refused denies the call with lich's reason. Any other task id + goes to Claude Code. - **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, and lich tells the asking session when the worker is waiting on one. @@ -125,8 +148,14 @@ for an isolated call. - **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. + background-task list and Claude Code sends no completion notification for it; + the status line and TaskStop above are the mod's, not Claude Code's. So + `ctrl+x ctrl+k` does not stop a worker, and a worker is counted only while + this module remembers it: a reload forgets the count, and TaskStop then goes + to Claude Code, which does not know the id. +- **The status line lags a worker's end by up to 5 seconds**, the period of the + `lich sessions --json` read, and matches a worker by the roster name `lich + open` printed, so a worker renamed in Claude Code is read as finished. - **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 diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index 4544674..a31e761 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -9,6 +9,8 @@ // Once lich has the task the Agent call answers at once, as a background // 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. +// While workers run, the status line counts them from `$LICH_BIN sessions +// --json`, and Claude Code's TaskStop on one is taken here and done by 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 @@ -47,6 +49,12 @@ const FALLBACK_SLUG = "agent" // session with LICH_SUBAGENT_CARDS=off instead. const WORKER_BRANCH_PREFIX = "subagent/" +// How often the status line asks lich which workers still run. Claude Code's +// own "N agents" hint moves as each agent ends; a worker's end reaches lich, not +// this session, so it is read from `lich sessions --json`, one short run of the +// CLI per period while any worker runs. +const WORKER_POLL_MS = 5000 + // The end of the Agent call's id makes each branch new: lich checks out an // existing branch as it stands, so a bare slug could land on someone's work. const SUFFIX_CHARS = 4 @@ -55,7 +63,9 @@ const SUFFIX_CHARS = 4 * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ ticket: string, target: string, status: string, answer: string }} Report * @typedef {{ label: string, name: string, path: string, delivery?: Report }} Opened - * @typedef {{ interactive: boolean }} State + * @typedef {{ label: string, name: string, description: string, shared: boolean }} Worker + * @typedef {{ interactive: boolean, workers: Map, watching: boolean, lich: string }} State + * @typedef {{ label: string, name: string, state: string }} Peer * @typedef {{ tool: "Agent", tool_use_id: string, agentId?: string, description: string, prompt: string, * subagent_type?: string, model?: string, team_name?: string, isolation?: string }} AgentCall */ @@ -310,13 +320,135 @@ async function runAsSession($, state, e, next) { } return runNatively($, e, next, messageOf(error)) } - return answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs) + const answer = answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs) + if (opened.delivery.status === "pending") trackWorker($, state, lich, opened, e, branch) + return answer +} + +/** + * Pins how many workers this session waits on under its prompt, as Claude + * Code's own hint does for its background agents, and clears it at none. + * Claude Code puts the plugin's name before the line, so it reads + * "lich: 2 workers" (measured on 2.1.289). + * + * @param {Engine} $ + * @param {State} state + */ +function showWorkers($, state) { + const count = state.workers.size + $.ui.status(count === 0 ? undefined : `${count} worker${count === 1 ? "" : "s"}`) +} + +/** + * Keeps one watch running while any worker does. + * + * @param {Engine} $ + * @param {State} state + */ +function watchWorkers($, state) { + if (state.watching || state.workers.size === 0) return + state.watching = true + $.clock.after(WORKER_POLL_MS, () => refreshWorkers($, state)) +} + +/** + * A worker still runs while lich lists it with a turn that is not done: busy, + * waiting on a permission, or not reported yet, which is a worker whose first + * turn has not started. One lich no longer lists was closed, which lich does to + * a worker in this checkout once it reported. + * + * @param {Worker} worker + * @param {Peer[]} peers + */ +function stillRuns(worker, peers) { + return peers.some((p) => p.name === worker.name && p.state !== "done") +} + +/** + * Drops the workers lich lists as finished. A list lich could not give leaves + * the count as it is until the next period. + * + * @param {Engine} $ + * @param {State} state + */ +async function refreshWorkers($, state) { + state.watching = false + try { + const { exitCode, stdout } = await $.process.run([state.lich, "sessions", "--json"]) + /** @type {Peer[] | undefined} */ + const peers = exitCode === 0 ? parsedOrUndefined(stdout) : undefined + const before = state.workers.size + if (Array.isArray(peers)) { + for (const [id, worker] of state.workers) { + if (!stillRuns(worker, peers)) state.workers.delete(id) + } + } + if (state.workers.size !== before) showWorkers($, state) + } catch { + // A rejection here reaches no hook; the next period asks again. + } + watchWorkers($, state) +} + +/** + * @param {Engine} $ + * @param {State} state + * @param {string} lich + * @param {Opened} opened + * @param {AgentCall} e + * @param {string} branch + */ +function trackWorker($, state, lich, opened, e, branch) { + state.lich = lich + state.workers.set(opened.name, { label: opened.label, name: opened.name, description: e.description, shared: !branch }) + showWorkers($, state) + watchWorkers($, state) +} + +/** + * Claude Code's TaskStop for a worker this mod opened: Esc in this session + * leaves a background agent running, and TaskStop is how the model stops one + * (both measured on 2.1.289). A worker in this checkout is closed, having + * nothing of its own to keep; an isolated one has its turn stopped, and its + * card and worktree stay for the user. Any other task goes to Claude Code. + * + * @param {Engine} $ + * @param {State} state + * @param {{ tool: "TaskStop", task_id?: string, shell_id?: string }} e + * @param {any} next + */ +async function stopWorker($, state, e, next) { + const id = e.task_id ?? e.shell_id ?? "" + const worker = state.workers.get(id) + if (worker === undefined) return next(e) + const argv = worker.shared + ? [state.lich, "close", worker.name] + : [state.lich, "control", worker.name, "abort"] + let outcome + try { + outcome = await $.process.run(argv) + } catch (error) { + return { deny: `lich could not stop "${worker.label}": ${messageOf(error)}` } + } + if (outcome.exitCode !== 0) { + return { deny: `lich could not stop "${worker.label}": ${outcome.stderr.trim() || `lich exited ${outcome.exitCode}`}` } + } + state.workers.delete(id) + showWorkers($, state) + return { + result: { + message: `Successfully stopped task: ${id} (${worker.description})`, + task_id: id, + task_type: "local_agent", + command: worker.description, + }, + } } /** @param {import('claude-code').On} on */ export function register(on) { /** @type {State} */ - const state = { interactive: false } + const state = { interactive: false, workers: new Map(), watching: false, lich: "" } // A `claude -p` started from a tool inside a lich session inherits its // variables; its subagents are its own and stay inside it. The matcher is @@ -328,4 +460,5 @@ export function register(on) { }) on("tool.call", { tool: "Agent" }, ($, e, next) => runAsSession($, state, e, next)) + on("tool.call", { tool: "TaskStop" }, ($, e, next) => stopWorker($, state, e, next)) } diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index af1d252..e0b34fa 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -45,8 +45,13 @@ function worldWith(on: On, env: Record, ...lich: ProcessRunResul const native: string[] = [] const argvs: string[][] = [] const toasts: string[] = [] + const statuses: (string | undefined)[] = [] mock.env(on, env) - mock.clock(on) + const clock = mock.clock(on) + on('ui.status', async (_$, e) => { + statuses.push(e.text) + return { value: undefined } + }) on('session.start', async (_$, e) => ({ cwd: e.cwd })) on('process.run', async (_$, e) => { argvs.push([...e.argv]) @@ -63,7 +68,11 @@ function worldWith(on: On, env: Record, ...lich: ProcessRunResul native.push(e.description) return { deny: 'the native agent ran' } }) - return { native, argvs, toasts } + on('tool.call', { tool: 'TaskStop' }, async (_$, e) => { + native.push(`TaskStop ${e.task_id}`) + return { deny: 'the native TaskStop ran' } + }) + return { native, argvs, toasts, statuses, clock } } test('a task lich took comes back at once as a backgrounded agent', async ($, on) => { @@ -128,3 +137,35 @@ test('turned off in lich, every subagent stays native and lich is never run', as expect(w.native).toEqual(['Fix the auth flow']) expect(w.argvs).toEqual([]) }) + +test('a running worker shows in the status line until lich lists it done, 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 }) + const w = world( + on, + exited(2, pending), + exited(2, { ...pending, label: 'second', name: 'second-1a2b' }), + exited(0, [peer(OPENED.name, 'busy'), peer('second-1a2b', 'busy')]), + exited(0, ''), + exited(0, [peer(OPENED.name, 'done')]), + ) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await $.tool.call(CALL) + await $.tool.call({ ...CALL, tool_use_id: 'toolu_second' }) + expect(w.statuses).toEqual(['1 worker', '2 workers']) + + await w.clock.advance(5000) + expect(w.argvs.at(-1)).toEqual(['/opt/lich/bin/lich', 'sessions', '--json']) + expect(w.statuses.at(-1)).toBe('2 workers') + + const stopped = await $.tool.call({ tool: 'TaskStop', task_id: 'second-1a2b' }) + expect(stopped.result).toEqual(expect.objectContaining({ task_id: 'second-1a2b', task_type: 'local_agent' })) + expect(w.argvs.at(-1)).toEqual(['/opt/lich/bin/lich', 'close', 'second-1a2b']) + expect(w.statuses.at(-1)).toBe('1 worker') + + await w.clock.advance(5000) + expect(w.statuses.at(-1)).toBe(undefined) + + await $.tool.call({ tool: 'TaskStop', task_id: 'a712043a56e1aafb1' }) + expect(w.native).toEqual(['TaskStop a712043a56e1aafb1']) +}) diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index d93c3f1..5c7d750 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -71,16 +71,25 @@ function load({ env = ENV, runs = [], times = [1000, 4000] } = {}) { const hook = rest.at(-1) const matcher = rest.length > 1 ? rest[0] : {} const matches = (e) => Object.entries(matcher).every(([field, value]) => e[field] === value) - hooks.set(event, ($, e, next) => (matches(e) ? hook($, e, next) : next(e))) + const below = hooks.get(event) ?? ((_$, e, next) => next(e)) + hooks.set(event, ($, e, next) => (matches(e) ? hook($, e, next) : below($, e, next))) }) const ran = [] const toasts = [] + const statuses = [] + const timers = [] const script = [...runs] const clock = [...times] const $ = { env: { get: async (name) => env[name] }, - clock: { now: async () => clock.shift() }, + clock: { + now: async () => clock.shift(), + after: (ms, fn) => { + timers.push({ ms, fn }) + return { cancel: () => {} } + }, + }, process: { run: async (argv, init) => { ran.push({ argv, init }) @@ -89,12 +98,22 @@ function load({ env = ENV, runs = [], times = [1000, 4000] } = {}) { return answer(argv, init) }, }, - ui: { toast: (text) => toasts.push(text) }, + ui: { toast: (text) => toasts.push(text), status: (text) => statuses.push(text) }, } return { ran, toasts, + statuses, + timers, + /** Scripts what the next `$.process.run` calls answer. */ + answer: (...more) => script.push(...more), + /** Fires the timers pending now, `runs` scripting what they run, and waits for them. */ + async tick(...more) { + script.push(...more) + const due = timers.splice(0) + await Promise.all(due.map(({ fn }) => fn())) + }, start: (isInteractive = true) => hooks.get('session.start')($, { cwd: '/w', surface: isInteractive ? 'terminal' : null, isInteractive }, async (e) => e), /** Raises one Agent call; `passed` is what reached the native agent. */ @@ -454,3 +473,119 @@ test('a status this mod does not know is denied, never run natively', async () = 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.` }) }) + +// ---------------------------------------------------- workers in the status line -- + +// What `lich sessions --json` prints (relay.Peer in lich): the live sessions +// this one can address, each with the state its hooks last reported. +const peer = (worker, state) => ({ label: worker.label, name: worker.name, project: worker.project, kind: 'claude', state }) +const listed = (...peers) => exits(0, peers) +const SESSIONS = [LICH_BIN, 'sessions', '--json'] + +test('a backgrounded worker shows in the status line until lich lists its turn done', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + assert.deepEqual(mod.statuses, ['1 worker']) + assert.equal(mod.timers.length, 1) + + await mod.tick(listed(peer(SHARED, 'busy'))) + assert.deepEqual(mod.ran.at(-1).argv, SESSIONS) + assert.deepEqual(mod.statuses, ['1 worker']) + assert.equal(mod.timers.length, 1) + + await mod.tick(listed(peer(SHARED, 'done'))) + assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.equal(mod.timers.length, 0) +}) + +test('a worker waiting on a permission, or not reported yet, is still running', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + await mod.tick(listed(peer(SHARED, 'waiting'))) + await mod.tick(listed(peer(SHARED, ''))) + assert.deepEqual(mod.statuses, ['1 worker']) + assert.equal(mod.timers.length, 1) +}) + +test('a worker lich no longer lists, closed once it reported, is finished', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + await mod.tick(listed()) + assert.deepEqual(mod.statuses, ['1 worker', undefined]) + assert.equal(mod.timers.length, 0) +}) + +test('two workers count as two, and one finishing leaves one', async () => { + const mod = load({ runs: [exits(2, openedHere('pending')), onBranch('main'), exits(2, opened('pending'))], times: [1, 2, 3, 4] }) + await mod.start() + await mod.call(AGENT) + await mod.call(ISOLATED) + assert.deepEqual(mod.statuses, ['1 worker', '2 workers']) + assert.equal(mod.timers.length, 1, 'one watch for every worker') + + await mod.tick(listed(peer(SHARED, 'done'), peer(OPENED, 'busy'))) + assert.deepEqual(mod.statuses.at(-1), '1 worker') +}) + +test('a list lich could not give keeps the count and asks again', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + await mod.tick(exits(1, '', 'lich: no lich is running\n')) + await mod.tick(rejects('$.process.run(lich) failed')) + assert.deepEqual(mod.statuses, ['1 worker']) + assert.equal(mod.timers.length, 1) +}) + +test('a worker that answered at the open is never counted', async () => { + const { mod } = await delegate([exits(0, openedHere('answered', 'done'))]) + assert.deepEqual(mod.statuses, []) + assert.equal(mod.timers.length, 0) +}) + +// ---------------------------------------------------------------- TaskStop -- + +// Measured on Claude Code 2.1.289: TaskStop takes the agentId the Agent call +// answered as `task_id`, and answers with this shape. +const taskStop = (id) => ({ tool: 'TaskStop', task_id: id }) +const stopped = (id) => ({ + result: { + message: `Successfully stopped task: ${id} (${AGENT.description})`, + task_id: id, + task_type: 'local_agent', + command: AGENT.description, + }, +}) + +test('TaskStop on a worker in this checkout closes its session, which keeps nothing of its own', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + mod.answer(exits(0, `Closed "${SHARED.label}".\n`)) + const { answer, passed } = await mod.call(taskStop(SHARED.name)) + assert.deepEqual(mod.ran.at(-1).argv, [LICH_BIN, 'close', SHARED.name]) + assert.deepEqual(passed, []) + assert.deepEqual(answer, stopped(SHARED.name)) + assert.deepEqual(mod.statuses, ['1 worker', undefined]) +}) + +test('TaskStop on an isolated worker stops its turn and leaves its worktree and card', async () => { + const { mod } = await delegate([onBranch('main'), exits(2, opened('pending'))], ISOLATED) + mod.answer(exits(0, `"${OPENED.label}" stopped its turn.\n`)) + const { answer } = await mod.call(taskStop(OPENED.name)) + assert.deepEqual(mod.ran.at(-1).argv, [LICH_BIN, 'control', OPENED.name, 'abort']) + assert.deepEqual(answer, stopped(OPENED.name)) + assert.deepEqual(mod.statuses.at(-1), undefined) +}) + +test('TaskStop on a task that is not a lich worker goes to Claude Code untouched', async () => { + const mod = load() + await mod.start() + const e = taskStop('a712043a56e1aafb1') + const { answer, passed } = await mod.call(e) + assert.deepEqual(passed, [e]) + assert.equal(answer, NATIVE) + assert.deepEqual(mod.ran, []) +}) + +test('TaskStop lich could not carry out is refused with its reason, and the worker still counts', async () => { + const { mod } = await delegate([exits(2, openedHere('pending'))]) + mod.answer(exits(1, '', `lich: no session named "${SHARED.name}"\n`)) + const { answer, passed } = await mod.call(taskStop(SHARED.name)) + assert.deepEqual(passed, []) + assert.deepEqual(answer, { deny: `lich could not stop "${SHARED.label}": lich: no session named "${SHARED.name}"` }) + assert.deepEqual(mod.statuses, ['1 worker']) +})