Delegate read-only review and analysis to Codex, Antigravity (agy) or pi in the background, with a live pane beside the chat

A Claude Code mod that delegates read-only review and analysis tasks to another coding agent in the background, with a live pane beside the chat. Ask in plain words, for example "ask agy to find the bug in the parser", and Claude starts the agent you named.
| Agent | CLI | How it stays read-only |
|---|---|---|
codex | Codex CLI | codex exec --json -s read-only; it can also do a code review (codex review) |
agy | Antigravity CLI | agy -p denies each write and each shell command that has no allow rule in ~/.gemini/antigravity-cli/settings.json; the mod also compares git status before and after and tells Claude what changed |
pi | pi | pi -p --mode json --tools read,grep,find,ls, with no extensions, MCP servers or skills |
mcp__subagent__run (start a task; its agent is one of the agents found on PATH when the session starts), mcp__subagent__result (read a task's status and result) and mcp__subagent__stop (stop a running task; the pane has a stop button too)./subagent.prompt.md, out.md, events.jsonl and stderr.Needs Claude Code v2.1.287 or later and at least one of the agents. With mise: mise use -g agy@latest pi@latest; Codex: brew install --cask codex. Sign in to each agent once in a terminal.
A mod is code that runs inside Claude Code with your permissions. To see what this one hooks and calls before you install it, run
claude plugin validate ./mods/subagentand read thehooks:andcalls:lines.
In a Claude Code session:
/plugin install subagent --marketplace dimadem/claude-code-tools
From a shell:
claude plugin install subagent --marketplace dimadem/claude-code-tools
If you already added the marketplace: /plugin install subagent@dimadem-claude-code-tools. Get a new version with claude plugin update subagent@dimadem-claude-code-tools, then /reload-plugins in an open session.
From a clone of this repository:
claude --plugin-dir ./mods/subagent
Then ask Claude, for example: "ask codex to review the uncommitted changes" or "ask pi what this function does".
Option (/config → Pane background): terminal (no fill, the default), black, graphite, slate, navy.
Bun runs the checks; the mod itself runs inside Claude Code (no Node, no Bun globals).
The code follows Feature-Sliced Design: each layer imports only the layers below it, and another slice only through its index.ts.
| Layer | What it holds |
|---|---|
hooks/register.tsx (app) | Every hook, every $ call and the state atoms. Claude Code reads an atom only in the file that makes it, so this is the one imperative file. |
hooks/widgets/tasks-pane | The pane as a pure view: elements, data and callbacks in, a tree out |
hooks/features/tools | The three tools as pure parts: specs, planStart with typed StartErrors, the result and stop texts, the completion message |
hooks/entities/* | agent: one pure adapter per agent (its command line, and a reducer from its JSON events to steps); task: task list changes and TaskError; run: a running child, RunEnd, the job queue and the stop with its retries |
hooks/shared | Config and Result, ok, err, unreachable |
Errors are values: a function that can fail returns Result<T, E>, where E is a union of kinds, and a switch with unreachable names every kind. The code has no ?: fields, no ternaries and no undefined values: a missing value is null, a choice is a union (Mode, Input, RunEnd), and untyped JSON (an agent's events, a tool's input) is read with shared/lib/json, which gives one type per field.
cd mods/subagent
bun install
bun run check # biome, tsc, knip (unused files and exports), dependency-cruiser (the layer rules), claude plugin test, claude plugin validate
bun run fix # biome format and safe fixes
.dependency-cruiser.cjs holds the layer rules; pure-below-app fails when a layer below register.tsx imports more than types from claude-code. .claude-plugin/types/ is written by Claude Code each time it loads the mod, and is not committed; tsc and dependency-cruiser need it, so load the mod once (claude --plugin-dir ./mods/subagent) before the first check.
hooks/register.tsx 329 lines1// The app layer and the one imperative file: every hook, every `$` call and the
2// state atoms live here, as the validator reads an atom only in the file that
3// makes it. The layers below are pure: widgets → features → entities → shared.
4// - Change an atom's `shape` tag when its type changes: it declines older values.
5// - session.start finds the agents, registers the tools, and runs a 1 s clock that
6// copies the changed runs into `tasks`, and a dispatcher that runs each queued
7// job. A second session.start (after /clear) cancels the first clock; the first
8// dispatcher then waits on the queue for good.
9// - `stream.result` is handled right after the spawn: it rejects when the child
10// cannot start or is stopped. The generator's own `return` asks for a result,
11// so the stream is closed through an `AsyncGenerator<unknown, unknown>` view.
12// - prompt.submit resolves with `drop` when the message did not enter; the
13// result then stays in the pane and with the result tool.
14// - The ui.render matcher id is a literal, so the validator can list it.
15
16import { atom, read, update } from 'claude-code'
17import type { Register, Timer } from 'claude-code'
18
19import type { Task } from '../types'
20import {
21 AGENTS,
22 type Agent,
23 addNote,
24 agentByName,
25 detectAgents,
26 foldLine,
27 initialState,
28} from './entities/agent'
29import {
30 type Job,
31 type Run,
32 type RunEnd,
33 addStderr,
34 createRun,
35 enqueue,
36 restLine,
37 runs,
38 spawnRequest,
39 statusOf,
40 stopRun,
41 takeJob,
42 takeLines,
43} from './entities/run'
44import { addTask, endTask, findTask, markLost, withLive } from './entities/task'
45import {
46 completionText,
47 describeStartError,
48 describeStop,
49 formatResult,
50 planStart,
51 readStartInput,
52 resultTool,
53 runTool,
54 stopTool,
55} from './features/tools'
56import { LINE_CHARS, PANE, RESULT_CHARS, shadeOf } from './shared/config'
57import { changedLines, ignore, settle, text } from './shared/lib'
58import { renderPane } from './widgets/tasks-pane'
59
60const tasks = atom({ plugin: 'subagent', key: 'tasks' } as const, [] as Task[], { shape: 'tasks-3' })
61const now = atom({ plugin: 'subagent', key: 'now' } as const, 0)
62const selected = atom({ plugin: 'subagent', key: 'selected' } as const, null as string | null)
63
64let installed: readonly Agent[] = []
65
66let ticker: Timer | null = null
67
68function takeChanges(run: Run) {
69 if (run.state === run.drawn && run.stderr === run.drawnStderr) return null
70 run.drawn = run.state
71 run.drawnStderr = run.stderr
72 return { steps: run.state.steps, sessionId: run.state.sessionId, stderr: run.stderr }
73}
74
75export const register: Register = (on, options) => {
76 const background = shadeOf(options.background)
77
78 on('session.start', async ($, e, next) => {
79 const found = await settle(
80 $.process.run(
81 ['sh', '-c', 'for a; do command -v "$a" >/dev/null 2>&1 && echo "$a"; done', 'sh'].concat(
82 AGENTS.map(one => one.name),
83 ),
84 ),
85 )
86 let onPath = ''
87 if (found.ok) onPath = found.value.stdout
88 installed = detectAgents(onPath)
89 const registered: Promise<unknown>[] = [
90 $.command.register({ name: 'subagent', description: 'Show the subagent tasks pane' }),
91 ]
92 if (installed.length > 0) {
93 registered.push(
94 $.tool.register(runTool(installed)),
95 $.tool.register(resultTool),
96 $.tool.register(stopTool),
97 )
98 }
99 await Promise.all(registered)
100 const startedAt = await $.clock.now()
101 await update($, tasks, list => markLost(list, id => runs.has(id), startedAt))
102 const started = await next(e)
103
104 const scrollToEnd = () => {
105 $.ui.scroll({ in: PANE.id, to: { key: PANE.endKey } }).catch(ignore)
106 }
107
108 if (ticker !== null) ticker.cancel()
109 ticker = $.clock.every(1000, () => {
110 if (runs.size === 0) return
111 void (async () => {
112 const at = await $.clock.now()
113 await update($, now, () => at)
114 const changed = new Map<string, NonNullable<ReturnType<typeof takeChanges>>>()
115 for (const [id, run] of runs) {
116 const changes = takeChanges(run)
117 if (changes !== null) changed.set(id, changes)
118 }
119 if (changed.size === 0) return
120 await update($, tasks, list => withLive(list, id => changed.get(id) ?? null))
121 const shown = await read($, selected)
122 if (shown === null || changed.has(shown)) scrollToEnd()
123 })()
124 })
125
126 const runJob = async (job: Job) => {
127 const { task, argv, cwd, input, before } = job
128 const agent = agentByName(task.agent)
129 const stream = $.process.spawn(spawnRequest(task.dir, argv, cwd, input))
130 const closable: AsyncGenerator<unknown, unknown> = stream
131 const run = createRun(initialState(cwd), task.dir, {
132 close: () => void closable.return(null),
133 readFile: path => $.fs.read(path),
134 kill: pid => $.process.run(['kill', '-TERM', pid]).then(ignore),
135 sleep: ms => $.clock.sleep(ms),
136 })
137 runs.set(task.id, run)
138
139 const pump = async (): Promise<RunEnd> => {
140 const exit = settle(stream.result)
141 const feed = (lines: string[]) => {
142 for (const line of lines) run.state = foldLine(run.state, line, agent.reduce)
143 }
144 try {
145 for await (const { stream: pipe, text: chunk } of stream) {
146 if (pipe === 'stderr') {
147 addStderr(run, chunk)
148 continue
149 }
150 const taken = takeLines(run, chunk)
151 feed(taken.lines)
152 if (taken.dropped > 0) {
153 run.state = addNote(run.state, `dropped an output line longer than ${LINE_CHARS} characters`)
154 }
155 }
156 feed(restLine(run))
157 const ended = await exit
158 if (run.isStopped) return { kind: 'stopped' }
159 let code: number | null = null
160 if (ended.ok) code = ended.value.code
161 return { kind: 'exited', code }
162 } catch (error) {
163 if (run.isStopped) return { kind: 'stopped' }
164 return { kind: 'crashed', reason: String(error) }
165 }
166 }
167
168 const end = await pump()
169 runs.delete(task.id)
170 run.end()
171 if (end.kind === 'crashed') addStderr(run, `\n[subagent mod] ${end.reason}\n`)
172
173 let changed: string[] = []
174 if (before !== null) {
175 const after = await settle($.process.run(['git', 'status', '--porcelain'], { cwd }))
176 if (after.ok && after.value.exitCode === 0) changed = changedLines(before, after.value.stdout)
177 if (changed.length > 0) {
178 run.state = addNote(run.state, `git status changed while the task ran:\n${changed.join('\n')}`)
179 }
180 }
181
182 const writes = [
183 $.fs.write(`${task.dir}/events.jsonl`, run.events),
184 $.fs.write(`${task.dir}/stderr`, run.stderr),
185 ]
186 let out = ''
187 if (run.state.out !== null) {
188 out = run.state.out.trim()
189 writes.push($.fs.write(`${task.dir}/out.md`, run.state.out))
190 } else out = (await $.fs.read(`${task.dir}/out.md`).catch(() => '')).trim()
191 await Promise.all(writes.map(one => one.catch(ignore)))
192
193 let steps = run.state.steps
194 let result: string | null = null
195 if (out) {
196 steps = steps.filter(one => one.id !== run.state.answerId)
197 result = out.slice(0, RESULT_CHARS)
198 }
199 const status = statusOf(end, out)
200 let code: number | null = null
201 if (end.kind === 'exited') code = end.code
202 const endedAt = await $.clock.now()
203 await update($, tasks, list =>
204 endTask(list, task.id, {
205 status,
206 code,
207 sessionId: run.state.sessionId,
208 steps,
209 result,
210 stderr: run.stderr,
211 at: endedAt,
212 }),
213 ).catch(ignore)
214 scrollToEnd()
215
216 $.ui.toast(`${agent.name} ${task.id} ${status}: ${task.label}`)
217 $.ui.notify(`${task.label}: ${status}`, { title: `${agent.title} ${task.id}` }).catch(ignore)
218 if (status === 'stopped') return
219
220 const notes = steps.flatMap(one => {
221 if (one.kind === 'note') return [one.text]
222 return []
223 })
224 const sent = await settle(
225 $.prompt.submit({
226 text: completionText({ title: agent.title, task, status, code, out, notes, changed }),
227 }),
228 )
229 if (!sent.ok || typeof sent.value.drop === 'string') {
230 $.ui.toast(`subagent: ${task.id} ended, but its result did not reach Claude; ask for it`)
231 }
232 }
233
234 void (async () => {
235 for (;;) void runJob(await takeJob())
236 })()
237
238 return started
239 })
240
241 on('command.run', { command: 'subagent' }, async $ => {
242 await $.ui.open({ id: PANE.id, title: PANE.title })
243 return { text: 'Agents pane opened.' }
244 })
245
246 on('tool.call', { tool: 'mcp__subagent__run' }, async ($, e) => {
247 const plan = planStart(readStartInput(e), installed)
248 if (!plan.ok) return { deny: describeStartError(plan.error) }
249 const { agent, prompt, label, mode } = plan.value
250
251 const [cwd, tmpdir] = await Promise.all([$.session.cwd(), $.env.get('TMPDIR')])
252 const tmp = (tmpdir ?? '/tmp').replace(/\/$/, '')
253 const made = await $.process.run(['mktemp', '-d', `${tmp}/${agent.name}.XXXXXX`])
254 if (made.exitCode !== 0)
255 return { deny: describeStartError({ kind: 'no-run-dir', reason: made.stderr.trim() }) }
256 const dir = made.stdout.trim()
257 if (prompt) await $.fs.write(`${dir}/prompt.md`, prompt)
258 const { argv, input } = agent.command({ prompt, mode, cwd, dir })
259
260 let before: string | null = null
261 if (agent.guardsWrites) {
262 const status = await settle($.process.run(['git', 'status', '--porcelain'], { cwd }))
263 if (status.ok && status.value.exitCode === 0) before = status.value.stdout
264 }
265
266 const createdAt = await $.clock.now()
267 let task: Task | null = null
268 await update($, tasks, list => {
269 const added = addTask(list, { agent: agent.name, label, dir, at: createdAt })
270 task = added.task
271 return added.list
272 })
273 if (task === null) return { deny: describeStartError({ kind: 'not-recorded' }) }
274 const started: Task = task
275 await update($, selected, () => null)
276
277 enqueue({ task: started, argv, cwd, input, before })
278 $.ui.open({ id: PANE.id, title: PANE.title }).catch(ignore)
279
280 return {
281 result:
282 `Started ${agent.title} task ${started.id} (${label}) in the background. ` +
283 `Its result arrives as a message from the subagent plugin when it ends. Run directory: ${dir}`,
284 }
285 }).catch(() => ({ deny: 'The subagent mod failed to start the task; run claude --debug for the reason.' }))
286
287 on('tool.call', { tool: 'mcp__subagent__stop' }, async ($, e) => {
288 const found = findTask(await read($, tasks), text(e, 'id').trim())
289 if (!found.ok) return { deny: found.error }
290 const outcome = await stopRun(found.value.id)
291 if (outcome.kind === 'not-running') return { deny: describeStop(outcome, found.value) }
292 return { result: describeStop(outcome, found.value) }
293 }).catch(() => ({ deny: 'The subagent mod failed to stop the task; run claude --debug for the reason.' }))
294
295 on('tool.call', { tool: 'mcp__subagent__result' }, async ($, e) => {
296 const found = findTask(await read($, tasks), text(e, 'id').trim())
297 if (!found.ok) return { deny: found.error }
298 const task = found.value
299 let out = ''
300 if (task.status !== 'running') {
301 out = (await $.fs.read(`${task.dir}/out.md`).catch(() => task.result ?? '')).trim()
302 }
303 return { result: formatResult(task, out, await $.clock.now()) }
304 }).catch(() => ({ deny: 'The subagent mod failed to read the task; run claude --debug for the reason.' }))
305
306 on('ui.render', { component: 'Pane', requestId: 'subagent' satisfies typeof PANE.id }, async ($, e) => {
307 const [list, chosen, ticked] = await Promise.all([read($, tasks), read($, selected), read($, now)])
308 return renderPane(
309 $.ui.resolve(e),
310 {
311 tasks: list,
312 selected: chosen,
313 at: ticked || (await $.clock.now()),
314 background,
315 columns: e.props.bodyColumns,
316 rows: e.props.scroll.bodyRows,
317 },
318 {
319 select: id => {
320 update($, selected, () => id).catch(ignore)
321 },
322 stop: id => {
323 stopRun(id).catch(ignore)
324 },
325 },
326 )
327 })
328}
329hooks/entities/agent/index.ts 4 lines1export { addNote, foldLine, initialState } from './lib/state'
2export { AGENTS, agentByName, detectAgents } from './model/installed'
3export type { Agent, AgentState, Input, Mode } from './model/types'
4hooks/entities/run/index.ts 189 lines1// A running child process and the job queue. The run tool queues a job and the
2// session's dispatcher starts it, so the child outlives the tool call.
3// - The child is `sh -c 'echo $$ > "$0/pid"; exec "$@"'`: stop kills it by that
4// pid, as closing the stream alone waits until the child next prints.
5// - events.jsonl is cut by whole lines once it is twice EVENTS_CHARS; an output
6// line longer than LINE_CHARS is dropped and counted.
7// - Unloading the module kills the children; session.start marks their tasks lost.
8
9import type { ProcessSpawnRequest } from 'claude-code'
10
11import type { Task, TaskStatus } from '../../../types'
12import {
13 END_WAIT_MS,
14 EVENTS_CHARS,
15 LINE_CHARS,
16 PID_WAIT_MS,
17 STDERR_CHARS,
18 STOP_POLL_MS,
19} from '../../shared/config'
20import { unreachable } from '../../shared/lib'
21import type { AgentState, Input } from '../agent'
22
23type RunIo = {
24 close: () => void
25 readFile: (path: string) => Promise<string>
26 kill: (pid: string) => Promise<void>
27 sleep: (ms: number) => Promise<void>
28}
29
30export type Run = {
31 state: AgentState
32 readonly dir: string
33 readonly io: RunIo
34 readonly ended: Promise<void>
35 readonly end: () => void
36 isStopped: boolean
37 events: string
38 stderr: string
39 partial: string
40 drawn: AgentState | null
41 drawnStderr: string
42}
43
44export type RunEnd =
45 | { kind: 'exited'; code: number | null }
46 | { kind: 'stopped' }
47 | { kind: 'crashed'; reason: string }
48
49export type Job = {
50 task: Task
51 argv: readonly string[]
52 cwd: string
53 input: Input
54 before: string | null
55}
56
57export const runs = new Map<string, Run>()
58
59const queue: Job[] = []
60let wake = (): void => {}
61
62export function enqueue(job: Job): void {
63 queue.push(job)
64 wake()
65}
66
67export async function takeJob(): Promise<Job> {
68 for (;;) {
69 const job = queue.shift()
70 if (job !== undefined) return job
71 await new Promise<void>(resolve => {
72 wake = resolve
73 })
74 }
75}
76
77export function createRun(state: AgentState, dir: string, io: RunIo): Run {
78 let end = (): void => {}
79 const ended = new Promise<void>(resolve => {
80 end = resolve
81 })
82 return {
83 state,
84 dir,
85 io,
86 ended,
87 end,
88 isStopped: false,
89 events: '',
90 stderr: '',
91 partial: '',
92 drawn: null,
93 drawnStderr: '',
94 }
95}
96
97export function spawnRequest(
98 dir: string,
99 argv: readonly string[],
100 cwd: string,
101 input: Input,
102): ProcessSpawnRequest {
103 switch (input.kind) {
104 case 'stdin':
105 return { argv: ['sh', '-c', 'echo $$ > "$0/pid"; exec "$@"', dir, ...argv], cwd, input: input.text }
106 case 'closed':
107 return { argv: ['sh', '-c', 'echo $$ > "$0/pid"; exec "$@" </dev/null', dir, ...argv], cwd }
108 default:
109 return unreachable(input)
110 }
111}
112
113export function addStderr(run: Run, text: string): void {
114 run.stderr = (run.stderr + text).slice(-STDERR_CHARS)
115}
116
117export function takeLines(run: Run, text: string): { lines: string[]; dropped: number } {
118 run.events += text
119 if (run.events.length > 2 * EVENTS_CHARS) {
120 const cut = run.events.indexOf('\n', run.events.length - EVENTS_CHARS)
121 if (cut === -1) run.events = run.events.slice(-EVENTS_CHARS)
122 else run.events = run.events.slice(cut + 1)
123 }
124 let dropped = 0
125 if (!text.includes('\n')) {
126 run.partial += text
127 if (run.partial.length > LINE_CHARS) {
128 run.partial = ''
129 dropped = 1
130 }
131 return { lines: [], dropped }
132 }
133 const lines = (run.partial + text).split('\n')
134 run.partial = lines.pop() ?? ''
135 return { lines: lines.filter(line => line.trim()), dropped }
136}
137
138export function restLine(run: Run): string[] {
139 const rest = run.partial
140 run.partial = ''
141 if (!rest.trim()) return []
142 return [rest]
143}
144
145function parsePid(text: string): string | null {
146 const pid = text.trim()
147 if (!/^\d+$/.test(pid)) return null
148 return pid
149}
150
151export type StopOutcome =
152 | { kind: 'not-running' }
153 | { kind: 'stopped' }
154 | { kind: 'no-pid' }
155 | { kind: 'still-running' }
156
157export async function stopRun(id: string): Promise<StopOutcome> {
158 const run = runs.get(id)
159 if (run === undefined) return { kind: 'not-running' }
160 run.isStopped = true
161 run.io.close()
162 const readPid = async () => parsePid(await run.io.readFile(`${run.dir}/pid`).catch(() => ''))
163 let pid = await readPid()
164 for (let waited = 0; pid === null && waited < PID_WAIT_MS; waited += STOP_POLL_MS) {
165 await run.io.sleep(STOP_POLL_MS)
166 pid = await readPid()
167 }
168 if (pid === null) return { kind: 'no-pid' }
169 await run.io.kill(pid).catch(() => {})
170 const ended = run.ended.then(() => 'ended' as const)
171 const waited = run.io.sleep(END_WAIT_MS).then(() => 'waited' as const)
172 if ((await Promise.race([ended, waited])) === 'waited') return { kind: 'still-running' }
173 return { kind: 'stopped' }
174}
175
176export function statusOf(end: RunEnd, out: string): TaskStatus {
177 switch (end.kind) {
178 case 'stopped':
179 return 'stopped'
180 case 'crashed':
181 return 'failed'
182 case 'exited':
183 if (end.code === 0 && out) return 'done'
184 return 'failed'
185 default:
186 return unreachable(end)
187 }
188}
189hooks/entities/task/index.ts 2 lines1export { addTask, endTask, findTask, markLost, taskElapsed, withLive } from './model/task'
2hooks/features/tools/index.ts 217 lines1// The three tools the model calls (run, result, stop) as pure parts: their specs,
2// the check of the run input with typed StartErrors, and the texts they answer
3// with, the message when a task ends included. register.tsx runs them.
4
5import type { ToolSpec } from 'claude-code'
6
7import type { AgentName, Task, TaskStatus } from '../../../types'
8import type { Agent, Mode } from '../../entities/agent'
9import type { StopOutcome } from '../../entities/run'
10import { taskElapsed } from '../../entities/task'
11import { INLINE_RESULT_CHARS, RESULT_TOOL_CHARS } from '../../shared/config'
12import { type Result, err, ok, oneOf, text, unreachable } from '../../shared/lib'
13
14type StartInput = { agent: string; prompt: string; label: string; review: string }
15
16type StartPlan = { agent: Agent; prompt: string; label: string; mode: Mode }
17
18type StartError =
19 | { kind: 'unknown-agent'; installed: readonly AgentName[] }
20 | { kind: 'review-unsupported'; agent: AgentName; reviewers: readonly AgentName[] }
21 | { kind: 'bad-scope'; review: string }
22 | { kind: 'empty' }
23 | { kind: 'prompt-too-long'; agent: AgentName; limit: number }
24 | { kind: 'no-run-dir'; reason: string }
25 | { kind: 'not-recorded' }
26
27export function readStartInput(input: unknown): StartInput {
28 return {
29 agent: text(input, 'agent'),
30 prompt: text(input, 'prompt').trim(),
31 label: text(input, 'label').trim(),
32 review: text(input, 'review').trim(),
33 }
34}
35
36const REVIEW_HELP = 'Run a code review in place of a task: "uncommitted", "base:<branch>" or "commit:<sha>".'
37
38function reviewersOf(installed: readonly Agent[]): AgentName[] {
39 return installed.filter(one => one.canReview).map(one => one.name)
40}
41
42function modeOf(review: string, agent: Agent, installed: readonly Agent[]): Result<Mode, StartError> {
43 if (!review) return ok({ kind: 'exec' })
44 if (!agent.canReview) {
45 return err({ kind: 'review-unsupported', agent: agent.name, reviewers: reviewersOf(installed) })
46 }
47 if (review === 'uncommitted') return ok({ kind: 'review', args: ['--uncommitted'] })
48 const colon = review.indexOf(':')
49 const scope = review.slice(0, colon)
50 const value = review.slice(colon + 1)
51 if (colon > 0 && value && scope === 'base') return ok({ kind: 'review', args: ['--base', value] })
52 if (colon > 0 && value && scope === 'commit') return ok({ kind: 'review', args: ['--commit', value] })
53 return err({ kind: 'bad-scope', review })
54}
55
56export function planStart(input: StartInput, installed: readonly Agent[]): Result<StartPlan, StartError> {
57 const agent = installed.find(one => one.name === input.agent)
58 if (agent === undefined) return err({ kind: 'unknown-agent', installed: installed.map(one => one.name) })
59 const mode = modeOf(input.review, agent, installed)
60 if (!mode.ok) return mode
61 if (mode.value.kind === 'exec' && !input.prompt) return err({ kind: 'empty' })
62 if (input.prompt.length > agent.maxPromptChars) {
63 return err({ kind: 'prompt-too-long', agent: agent.name, limit: agent.maxPromptChars })
64 }
65 const label = (input.label || input.prompt.slice(0, 40) || agent.name).slice(0, 60)
66 return ok({ agent, prompt: input.prompt, label, mode: mode.value })
67}
68
69export function describeStartError(error: StartError): string {
70 switch (error.kind) {
71 case 'unknown-agent':
72 return `agent must be one of: ${error.installed.join(', ')}.`
73 case 'review-unsupported':
74 if (error.reviewers.length === 0)
75 return `${error.agent} cannot do a review; put the review task in the prompt.`
76 return `${error.agent} cannot do a review; use ${oneOf(error.reviewers)}, or put the review task in the prompt.`
77 case 'bad-scope':
78 return `review must be "uncommitted", "base:<branch>" or "commit:<sha>", not "${error.review}".`
79 case 'empty':
80 return 'Give a prompt, or a review scope.'
81 case 'prompt-too-long':
82 return `The prompt for ${error.agent} must be shorter than ${error.limit} characters.`
83 case 'no-run-dir':
84 return `Cannot make a run directory: ${error.reason}`
85 case 'not-recorded':
86 return 'Cannot record the task.'
87 default:
88 return unreachable(error)
89 }
90}
91
92export function runTool(installed: readonly Agent[]): ToolSpec {
93 return {
94 name: 'run',
95 description: [
96 'Delegate a read-only review or analysis task to another coding agent (a separate model).',
97 'It runs in the background, shows live progress in the Agents pane, and writes no files.',
98 'The call returns at once with a task id; when the agent ends, its result arrives as a message from the subagent plugin.',
99 'Use it when the user asks for one of these agents or for a second opinion; pick the agent the user names, else the first one.',
100 'Put all the context the agent needs in the prompt. The agents:',
101 ...installed.map(one => `- ${one.name}: ${one.hint}`),
102 ].join('\n'),
103 inputSchema: {
104 type: 'object',
105 properties: {
106 agent: {
107 type: 'string',
108 enum: installed.map(one => one.name),
109 description: 'The agent that does the task.',
110 },
111 prompt: {
112 type: 'string',
113 description:
114 'The task for the agent, with all the context it needs. Optional with review: extra review instructions.',
115 },
116 label: { type: 'string', description: 'A short name for the pane, 2-6 words.' },
117 review: {
118 type: 'string',
119 description: `Only for ${reviewersOf(installed).join(', ') || 'no installed agent'}. ${REVIEW_HELP}`,
120 },
121 },
122 required: ['agent', 'label'],
123 },
124 isDeferred: false,
125 }
126}
127
128const ID_INPUT = {
129 type: 'object',
130 properties: { id: { type: 'string', description: 'The task id, for example "c1".' } },
131 required: ['id'],
132}
133
134export const resultTool: ToolSpec = {
135 name: 'result',
136 description: 'Read the status and the result of a subagent task by its id.',
137 inputSchema: ID_INPUT,
138 isDeferred: true,
139}
140
141function exitText(code: number | null): string {
142 if (code === null) return ''
143 return `, exit code ${code}`
144}
145
146function cappedOut(out: string, dir: string): string {
147 if (out.length <= RESULT_TOOL_CHARS) return out
148 return `${out.slice(0, RESULT_TOOL_CHARS)}\n\n[cut at ${RESULT_TOOL_CHARS} characters; the full text is in ${dir}/out.md]`
149}
150
151export function formatResult(task: Task, out: string, at: number): string {
152 const lines = [
153 `${task.agent} task ${task.id} (${task.label}): ${task.status}, ${taskElapsed(task, at)}${exitText(task.code)}`,
154 `run directory: ${task.dir}`,
155 ]
156 if (task.sessionId !== null) lines.push(`${task.agent} session: ${task.sessionId}`)
157 if (out) return [...lines, '', cappedOut(out, task.dir)].join('\n')
158 const recent = task.steps.slice(-15).map(step => {
159 if (step.kind === 'tool') return `${step.text} (${step.status})`
160 return step.text
161 })
162 if (recent.length === 0) recent.push('(none)')
163 lines.push('', 'last steps:', ...recent)
164 if (task.stderr.trim()) lines.push('', 'stderr:', task.stderr.slice(-2000))
165 return lines.join('\n')
166}
167
168export const stopTool: ToolSpec = {
169 name: 'stop',
170 description:
171 'Stop a running subagent task by its id. Use it only when the user asks to stop the task. ' +
172 'A stopped task sends no result message.',
173 inputSchema: ID_INPUT,
174 isDeferred: true,
175}
176
177export function describeStop(outcome: StopOutcome, task: Task): string {
178 switch (outcome.kind) {
179 case 'stopped':
180 return `Stopped task ${task.id} (${task.label}).`
181 case 'not-running':
182 return `Task ${task.id} is not running: it is ${task.status}.`
183 case 'no-pid':
184 return `Task ${task.id} is marked stopped, but its process had not started yet; it ends when it next prints. Call stop again to kill it.`
185 case 'still-running':
186 return `Sent SIGTERM to task ${task.id}; it has not ended yet. Call stop again, or read it with result.`
187 default:
188 return unreachable(outcome)
189 }
190}
191
192type Completion = {
193 title: string
194 task: Task
195 status: TaskStatus
196 code: number | null
197 out: string
198 notes: readonly string[]
199 changed: readonly string[]
200}
201
202export function completionText(end: Completion): string {
203 const { title, task, status, code, out, notes, changed } = end
204 const parts = [`${title} task ${task.id} (${task.label}) ended: ${status}${exitText(code)}.`]
205 if (changed.length > 0) {
206 const note =
207 'by the agent or by another writer; a file that was already changed before is not checked again'
208 parts.push(`Warning: git status changed while it ran (${note}):\n${changed.join('\n')}`)
209 }
210 if (out && out.length <= INLINE_RESULT_CHARS) parts.push(`Its result:\n\n${out}`)
211 else {
212 if (!out && notes.length > 0) parts.push(`Its notes:\n${notes.slice(-5).join('\n')}`)
213 parts.push(`Read the result with mcp__subagent__result, id "${task.id}". Run directory: ${task.dir}`)
214 }
215 return parts.join('\n\n')
216}
217hooks/shared/config/index.ts 36 lines1// Names and limits. The pane draws up to 100,000 characters: PANE_CHARS of the
2// shown task, the result first, then the newest steps. A step keeps
3// MESSAGE_CHARS, NOTE_CHARS or TOOL_TEXT_CHARS of its text. Of out.md, the state
4// keeps RESULT_CHARS, the result tool answers with RESULT_TOOL_CHARS and the end
5// message holds up to INLINE_RESULT_CHARS. The stop tool waits PID_WAIT_MS for
6// the pid file, then END_WAIT_MS for the run to end.
7
8export const PANE = { id: 'subagent', title: 'Agents', endKey: 'output-end' } as const
9
10const SHADES = new Map([
11 ['black', '#000000'],
12 ['graphite', '#131417'],
13 ['slate', '#161b22'],
14 ['navy', '#0b1220'],
15])
16
17export function shadeOf(option: unknown): string | null {
18 if (typeof option !== 'string') return null
19 return SHADES.get(option) ?? null
20}
21
22export const EVENTS_CHARS = 1_000_000
23export const STDERR_CHARS = 4000
24export const MAX_STEPS = 300
25export const TOOL_TEXT_CHARS = 200
26export const MESSAGE_CHARS = 8000
27export const NOTE_CHARS = 2000
28export const LINE_CHARS = 1_000_000
29export const RESULT_TOOL_CHARS = 40_000
30export const STOP_POLL_MS = 100
31export const PID_WAIT_MS = 2000
32export const END_WAIT_MS = 3000
33export const RESULT_CHARS = 60_000
34export const PANE_CHARS = 90_000
35export const INLINE_RESULT_CHARS = 20_000
36hooks/shared/lib/index.ts 4 lines1export { flag, get, int, list, nonEmpty, strings, text } from './json'
2export { type Result, err, ignore, ok, settle, unreachable } from './result'
3export { changedLines, oneOf, relativePath } from './text'
4hooks/widgets/tasks-pane/index.ts 2 lines1export { renderPane } from './view'
2hooks/entities/agent/lib/state.ts 74 lines1// Pure helpers for AgentState: add or replace a step (its text cut to what its
2// kind keeps, at most MAX_STEPS kept), ids for steps the agent gives none, tool
3// lines such as `read src/a.ts`, and the fold of one output line (a line that is
4// not JSON becomes a note).
5
6import type { StepStatus, TaskStep } from '../../../../types'
7import { MAX_STEPS, MESSAGE_CHARS, NOTE_CHARS, TOOL_TEXT_CHARS } from '../../../shared/config'
8import { relativePath, strings } from '../../../shared/lib'
9import type { AgentState } from '../model/types'
10
11export function initialState(cwd: string): AgentState {
12 return { cwd, steps: [], sessionId: null, out: null, answerId: null, seq: 0 }
13}
14
15const STEP_CHARS: Record<TaskStep['kind'], number> = {
16 message: MESSAGE_CHARS,
17 note: NOTE_CHARS,
18 tool: TOOL_TEXT_CHARS,
19}
20
21export function setStep(state: AgentState, full: TaskStep): AgentState {
22 let step = full
23 if (full.text.length > STEP_CHARS[full.kind])
24 step = { ...full, text: full.text.slice(0, STEP_CHARS[full.kind]) }
25 const at = state.steps.findLastIndex(one => one.id === step.id)
26 if (at === -1) return { ...state, steps: [...state.steps, step].slice(-MAX_STEPS) }
27 const steps = [...state.steps]
28 steps[at] = step
29 return { ...state, steps }
30}
31
32export function nextId(state: AgentState, letter: string): { state: AgentState; id: string } {
33 const seq = state.seq + 1
34 return { state: { ...state, seq }, id: `${letter}${seq}` }
35}
36
37export function addNote(before: AgentState, text: string): AgentState {
38 const { state, id } = nextId(before, 'n')
39 return setStep(state, { kind: 'note', id, text })
40}
41
42export function findStep(state: AgentState, id: string): TaskStep | null {
43 return state.steps.findLast(one => one.id === id) ?? null
44}
45
46export function messageText(state: AgentState, id: string): string {
47 const step = findStep(state, id)
48 if (step !== null && step.kind === 'message') return step.text
49 return ''
50}
51
52export function toolText(name: string, args: unknown, cwd: string): string {
53 const values = strings(args).map(value => relativePath(value, cwd))
54 return [name || 'tool', ...values].join(' ')
55}
56
57export function toolStep(id: string, text: string, status: StepStatus, exitCode: number | null): TaskStep {
58 return { kind: 'tool', id, text, status, exitCode }
59}
60
61export function foldLine(
62 state: AgentState,
63 line: string,
64 reduce: (state: AgentState, event: unknown) => AgentState,
65): AgentState {
66 let event: unknown
67 try {
68 event = JSON.parse(line)
69 } catch {
70 return addNote(state, line)
71 }
72 return reduce(state, event)
73}
74hooks/entities/agent/model/installed.ts 20 lines1// The adapters by name (a Record, so a new AgentName fails the type check until
2// it has an adapter), and the ones `command -v` found on PATH.
3
4import type { AgentName } from '../../../../types'
5import { agy } from '../adapters/agy'
6import { codex } from '../adapters/codex'
7import { pi } from '../adapters/pi'
8import type { Agent } from './types'
9
10const BY_NAME: Record<AgentName, Agent> = { codex, agy, pi }
11
12export const AGENTS: readonly Agent[] = Object.values(BY_NAME)
13
14export const agentByName = (name: AgentName): Agent => BY_NAME[name]
15
16export const detectAgents = (stdout: string): readonly Agent[] => {
17 const names = stdout.split('\n')
18 return AGENTS.filter(one => names.includes(one.name))
19}
20hooks/entities/agent/model/types.ts 34 lines1// An agent: how to start it (argv and stdin) and a pure reducer from one JSON
2// event of its output to AgentState. `out` and `answerId` are the answer and its
3// step: codex writes out.md itself, agy and pi give the answer in an event.
4// `guardsWrites`: the agent has no read-only sandbox, so the mod compares
5// git status before and after the run.
6
7import type { AgentName, TaskStep } from '../../../../types'
8
9export type AgentState = {
10 readonly cwd: string
11 readonly steps: readonly TaskStep[]
12 readonly sessionId: string | null
13 readonly out: string | null
14 readonly answerId: string | null
15 readonly seq: number
16}
17
18export type Mode = { readonly kind: 'exec' } | { readonly kind: 'review'; readonly args: readonly string[] }
19
20export type Input = { readonly kind: 'stdin'; readonly text: string } | { readonly kind: 'closed' }
21
22type Command = { readonly argv: readonly string[]; readonly input: Input }
23
24export type Agent = {
25 readonly name: AgentName
26 readonly title: string
27 readonly hint: string
28 readonly canReview: boolean
29 readonly guardsWrites: boolean
30 readonly maxPromptChars: number
31 readonly command: (request: { prompt: string; mode: Mode; cwd: string; dir: string }) => Command
32 readonly reduce: (state: AgentState, event: unknown) => AgentState
33}
34hooks/entities/task/model/task.ts 75 lines1// Pure changes to the task list: add (ids are the agent's first letter and a
2// number: c1, a2, p3), mark lost after a reload, copy the changed runs in, end,
3// find.
4
5import type { AgentName, Task, TaskStatus, TaskStep } from '../../../../types'
6import { type Result, err, ok } from '../../../shared/lib'
7
8type NewTask = { agent: AgentName; label: string; dir: string; at: number }
9
10export function addTask(list: readonly Task[], spec: NewTask): { list: Task[]; task: Task } {
11 const n = Math.max(0, ...list.map(one => Number(one.id.slice(1)) || 0)) + 1
12 const task: Task = {
13 id: `${spec.agent[0]}${n}`,
14 agent: spec.agent,
15 label: spec.label,
16 dir: spec.dir,
17 startedAt: spec.at,
18 endedAt: null,
19 status: 'running',
20 code: null,
21 sessionId: null,
22 steps: [],
23 result: null,
24 stderr: '',
25 }
26 return { list: [...list, task], task }
27}
28
29export function markLost(list: readonly Task[], isLive: (id: string) => boolean, at: number): Task[] {
30 return list.map(task => {
31 if (task.status !== 'running' || isLive(task.id)) return task
32 return { ...task, status: 'lost', endedAt: at }
33 })
34}
35
36type Live = { steps: readonly TaskStep[]; sessionId: string | null; stderr: string }
37
38export function withLive(list: readonly Task[], live: (id: string) => Live | null): Task[] {
39 return list.map(task => {
40 const run = live(task.id)
41 if (run === null) return task
42 return { ...task, steps: [...run.steps], sessionId: run.sessionId, stderr: run.stderr }
43 })
44}
45
46type Ending = Live & { status: TaskStatus; code: number | null; result: string | null; at: number }
47
48export function endTask(list: readonly Task[], id: string, end: Ending): Task[] {
49 return list.map(task => {
50 if (task.id !== id) return task
51 return {
52 ...task,
53 status: end.status,
54 code: end.code,
55 sessionId: end.sessionId,
56 steps: [...end.steps],
57 result: end.result,
58 stderr: end.stderr,
59 endedAt: end.at,
60 }
61 })
62}
63
64export function findTask(list: readonly Task[], id: string): Result<Task, string> {
65 const task = list.find(one => one.id === id)
66 if (task === undefined) return err(`No subagent task "${id}" in this session.`)
67 return ok(task)
68}
69
70export function taskElapsed(task: Task, at: number): string {
71 const s = Math.max(0, Math.floor(((task.endedAt ?? at) - task.startedAt) / 1000))
72 if (s < 60) return `${s}s`
73 return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
74}
75