Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 32 additions & 3 deletions docs/agent-cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 <name>`, having nothing of its own to keep, and an isolated
one has its turn stopped with `$LICH_BIN control <name> 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.
Expand All @@ -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
Expand Down
139 changes: 136 additions & 3 deletions hooks/agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<string, Worker>, 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
*/
Expand Down Expand Up @@ -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
Expand All @@ -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))
}
45 changes: 43 additions & 2 deletions tests/agent-cards.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ function worldWith(on: On, env: Record<string, string>, ...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])
Expand All @@ -63,7 +68,11 @@ function worldWith(on: On, env: Record<string, string>, ...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) => {
Expand Down Expand Up @@ -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'])
})
Loading
Loading