让 Claude Code 的原生子代理用 pi 跑第三方模型(DeepSeek、GLM、Qwen、Gemini…),主代理可纠偏、续接、定期监督

Claude Code 的一个 mod:让原生子代理用 pi 跑第三方模型(DeepSeek、GLM、Qwen、MiniMax、Gemini、GPT…),主代理(Claude)当监督者。
子代理仍是 Claude Code 自己的原生子代理:出现在 ↓ 任务列表里、原生完成通知、原生 worktree 隔离、TaskStop、SendMessage 续接都照常可用。mod 只做一件事——拦下这个子代理的模型请求,不发给 Claude,而是交给一个 pi --mode rpc 进程去跑,再把 pi 的最终答案作为"模型回复"交回引擎。
enabledModels,即 /scoped-models),清单连同上下文长度、思考、图片能力一起写进 Agent 工具描述,主代理据此挑模型;清单外的模型直接拒绝。不指定时用 pi 的 defaultModel。@default:subagent_type: "general-purpose@default" 明确表示“用 pi 当前的默认模型”(每次启动时重新读 pi 的 defaultModel,不是会话开始时的快照),可再接思考强度 @default:high。Agent 工具描述里告诉主代理:用户没点名模型时就用 @default,不必每次手动交代模型。SendMessage 给它,会变成 pi 的 RPC steer,在它当前这批工具调用结束后读到。SendMessage 走原生续接,接着同一个 pi 会话(mod 重载、会话重开后也能续)。TaskStop 会连同 pi 的整个进程树一起停掉。mcp__pi-subagent__subagent_activity:读子代理最近的公开活动(文字、工具调用与结果、模型报错和重试),支持游标翻页和单条详情。mcp__pi-subagent__watch_subagent:每隔 N 秒把子代理新增的活动推给主代理;主代理正忙时并入当前这一轮,空闲时唤醒它。isolation: "worktree"):引擎默认从 origin/main 建 worktree,而不是主会话当前分支。mod 在 pi 第一次启动前,若 worktree 干净,就把它 reset --hard 到主会话当前的 HEAD 提交(主会话的仓库在 Agent 调用时记下;主仓里未提交的改动不会带进去);做不到时(有改动、reset 失败)如实告诉 pi。续接时不再 reset。pi 会被告知 worktree 路径和“别碰主仓、改完在该分支上提交”,答案末尾带 [worktree · 路径 · branch 分支]。找不到该 worktree(例如引擎已把无改动的 worktree 清掉)时拒绝运行,不会回落到别的目录,答案里说明原因(续接时建议另起一个子代理)。Claude 自己跑的子代理仍用引擎原生的 worktree(基于 origin/main)。thinking 块交给引擎(只显示、不记录)。pi 没有工具在跑却一直不出声(模型挂住)时不发心跳,照旧 600 秒后被判卡死。Explore、Plan、claude-code-guide 只给 pi read,grep,find,ls 四个工具,改不了文件。model 参数是 Claude Code 固定的枚举(只能填 Claude 模型),所以第三方模型写在 subagent_type 的 @ 后缀里:
| 想要 | 写法 |
|---|---|
| pi 的默认模型 | subagent_type: "general-purpose@default"(或省略 @) |
| 指定模型 | subagent_type: "Explore@codebuddy/deepseek-v4.1-flash" |
| 指定思考强度 | subagent_type: "general-purpose@minimax-cn/MiniMax-M3.1-Flash-Preview:high",或加 effort: "high" |
| 改用 Claude | 填 model: "haiku"(或 sonnet / opus / fable),或 subagent_type: "general-purpose@claude" |
模型名可省略价格倍率后缀、不区分大小写,唯一时可只写模型名。fork 和 isolation: "remote" 始终由 Claude 跑。
平时不用手写:主代理会从工具描述里的清单挑模型,你只要说"这个用 codebuddy 的 deepseek"。
| 依赖 | 说明 |
|---|---|
| Claude Code | 需支持 Claude Mods(function hooks);在 2.1.302 上开发和验证 |
| pi | 在 PATH 里;按 pi 1.2.x 的 RPC 命令和 JSON 事件格式编写,其他版本启动时会提示 |
| pi 的 scoped models | ~/.pi/agent/settings.json(或项目的 .pi/settings.json)里的 enabledModels;为空时不限制模型 |
子代理会话存放在 ~/.pi/claude-subagents/。
在 Claude Code 的终端会话里输入:
/plugin install pi-subagent --marketplace yu1745/claude-code-pi-subagent
提示 Add marketplace? 时输入 y,再选择安装范围(user 即对所有会话生效)。
在 shell 里:
claude plugin install pi-subagent --marketplace yu1745/claude-code-pi-subagent
桌面应用里:点输入框旁的 +,选 Plugins → Add marketplace,填 yu1745/claude-code-pi-subagent,再 Add plugin。
git clone https://github.com/yu1745/claude-code-pi-subagent.git
claude --plugin-dir ./claude-code-pi-subagent
claude plugin test ./claude-code-pi-subagent # 单元测试 + 引擎级集成测试
subagent_tokens 由引擎自己计算,不反映 pi 的真实用量;真实用量看答案末尾那一行或 subagent_activity。fork(要继承 Claude 的上下文)和 remote(云端运行)无法用 pi。MIT
hooks/register.tsx 851 lines1import { update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Live } from '../types'
4import {
5 type Activity,
6 type RunStats,
7 KILL_TREE,
8 SUPERVISION_NOTE,
9 THINKING_LEVELS,
10 WATCH_DEFAULT_S,
11 WATCH_MAX_S,
12 WATCH_MIN_S,
13 activityPage,
14 clampInterval,
15 clip,
16 detailPage,
17 DEFAULT_NOTE,
18 dialogId,
19 digest,
20 findAgentWorktree,
21 hasThinkingSuffix,
22 missingWorktreeAdvice,
23 type ModelInfo,
24 parseDefault,
25 parseModelList,
26 parseWorktrees,
27 resolveScoped,
28 scopeModels,
29 scopedNote,
30 isReadOnlyType,
31 newStats,
32 piArgv,
33 piStderr,
34 pushActivity,
35 resolveAgent,
36 rpc,
37 splitBeats,
38 splitType,
39 totalTokens,
40 worktreeFooter,
41 worktreePreface,
42
43 piVersionNote,
44 watchSection,
45} from './core'
46
47// The subagent itself is the engine's own (tasks list, transcript, notification,
48// worktree, TaskStop, resume): only its model requests are answered by pi.
49const AGENT_HEAD = `
50
51Subagents here think with pi on third-party models by default: the engine runs the agent as usual (tasks list, worktree, notification, resume), and its model is pi. A fork, a remote agent, or a call that sets the \`model\` parameter (sonnet, opus, haiku, fable) or appends \`@claude\` to subagent_type runs on Claude instead.
52
53An agent with \`isolation: "worktree"\` that pi runs gets its own git worktree. Before pi starts, the mod points it at the session's current HEAD commit (uncommitted changes in the main checkout are not in it), tells pi the worktree's path and to stay inside it, and ends the answer with the worktree's path and branch. A Claude-run agent's worktree is Claude Code's own, created from origin/main.`
54
55const AGENT_TAIL = `
56- \`effort\` sets pi's thinking level when the model carries no \`:<thinking>\`.
57- SendMessage to an agent while it works steers it (pi reads it after its current tool calls); after it finished, SendMessage resumes it as usual. TaskStop stops it.
58- Supervision (opt-in; third-party models can drift or stall): mcp__pi-subagent__subagent_activity reads its recent activity without consuming anything; mcp__pi-subagent__watch_subagent sends you periodic activity evidence while it works. Do not poll by default.`
59
60const ANY_MODEL_NOTE =
61 `- Model: append \`@<provider>/<model>\` to subagent_type (\`general-purpose@codebuddy/glm-5.3\`), optionally \`:<thinking>\`; omitted, pi's default. \`pi --list-models <search>\` in Bash lists models.\n- ${DEFAULT_NOTE}`
62
63// The models subagents may use: pi's scoped models (`enabledModels`, the project's
64// over the user's), with pi's own facts on each; none scoped, any model goes.
65type Scope = { models: ModelInfo[]; fallback?: string }
66let scope: Promise<Scope> | undefined
67
68// pi's settings, the project's over the user's.
69async function readPiSettings($: EngineInterface) {
70 const home = (await $.env.get('HOME')) ?? ''
71 type PiSettings = { enabledModels?: string[]; defaultProvider?: string; defaultModel?: string }
72 const readJson = async (path: string): Promise<PiSettings> =>
73 (await $.fs.exists(path)) ? (JSON.parse(await $.fs.read(path)) as PiSettings) : {}
74 const user = await readJson(`${home}/.pi/agent/settings.json`).catch((): PiSettings => ({}))
75 const project = await readJson('.pi/settings.json').catch((): PiSettings => ({}))
76 return {
77 enabled: project.enabledModels?.length ? project.enabledModels : (user.enabledModels ?? []),
78 provider: project.defaultProvider ?? user.defaultProvider,
79 name: project.defaultModel ?? user.defaultModel,
80 }
81}
82
83// pi's default model among the scoped ones; the first scoped one when pi names none of them.
84function defaultOf(models: readonly ModelInfo[], provider?: string, name?: string) {
85 const preferred = provider && name ? resolveScoped(`${provider}/${name}`, models) : undefined
86 return preferred && 'model' in preferred ? preferred.model : models[0]?.id
87}
88
89async function readScope($: EngineInterface): Promise<Scope> {
90 const { enabled, provider, name } = await readPiSettings($)
91 if (!enabled.length) return { models: [] }
92 const listed = await $.process.run(['pi', '--list-models'], { timeoutMs: 120_000 }).then(r => parseModelList(r.stdout), () => [])
93 const models = scopeModels(enabled, listed)
94 return { models, fallback: defaultOf(models, provider, name) }
95}
96
97// The scoped models as pi's settings name them, without pi's facts on each.
98async function scopeNames($: EngineInterface): Promise<Scope> {
99 const { enabled, provider, name } = await readPiSettings($)
100 const models = scopeModels(enabled, [])
101 return { models, fallback: defaultOf(models, provider, name) }
102}
103
104// A cold `pi --list-models` can outlast the engine's 10s budget for tool.describe:
105// past this wait the description names the scoped models without their facts.
106const DESCRIBE_WAIT_MS = 3_000
107
108const ACTIVITY_TOOL = 'mcp__pi-subagent__subagent_activity'
109const WATCH_TOOL = 'mcp__pi-subagent__watch_subagent'
110const WATCH_TAG = '<pi-subagent-watch>'
111const LIVE = { plugin: 'pi-subagent', key: 'live' } as const
112const LIVE_LINES = 40
113const LIVE_MARK: Record<string, { mark: string; color: string }> = {
114 tool: { mark: '⏺', color: 'suggestion' },
115 result: { mark: '⎿', color: 'subtle' },
116 text: { mark: '◆', color: 'claude' },
117 error: { mark: '✗', color: 'error' },
118 retry: { mark: '↻', color: 'warning' },
119 note: { mark: '·', color: 'subtle' },
120}
121
122type Watch = { intervalS: number; brief?: string; criteria?: string; cursor: number; timer: { cancel: () => void } }
123
124// How a subagent's pi is configured, kept in $.store so a resume after a reload still uses pi.
125type Kept = {
126 id: string
127 name?: string
128 type: string
129 description: string
130 model?: string
131 thinking?: string
132 readOnly: boolean
133 isolated: boolean
134 // The caller's checkout, read where the Agent call was made: what an isolated
135 // agent's worktree is pointed at and pi is told to keep out of.
136 repo?: string
137 // The commit its worktree was pointed at: set once pi has started in it.
138 based?: string
139}
140
141type Agent = Kept & {
142 status: 'idle' | 'running' | 'completed' | 'failed' | 'stopped'
143 bornAt: number
144 runStartedAt: number
145 pid?: string
146 cmdFile?: string
147 lastTool?: string
148 worktree?: { path: string; branch?: string }
149 // This run could not start pi (no worktree): retrying in the same session does not help.
150 notStarted?: boolean
151 // This run yielded a beat, so its answer's text is block 1, not 0.
152 beaten?: boolean
153 events: Activity[]
154 seq: number
155 stats: RunStats
156 // User rows the engine appended to the agent's conversation since its last step.
157 inbox: string[]
158 watch?: Watch
159 // A band write in flight, and whether another is owed after it.
160 publishing?: boolean
161 republish?: boolean
162}
163
164// What an Agent call asked for, until its spawn names the agent.
165type Asked = Omit<Kept, 'id' | 'description'>
166
167const asked = new Map<string, Asked>()
168const agents = new Map<string, Agent>()
169const sections = new Map<string, { text: string; cursor: number }>()
170let delivering = false
171// Whether the main loop is mid-turn: a watch notice then joins the running turn
172// instead of starting one. Every main model request says so again, so a reload
173// mid-turn learns it at the next request.
174let mainBusy = false
175
176async function subagentDir($: EngineInterface) {
177 return `${(await $.env.get('HOME')) ?? '/tmp'}/.pi/claude-subagents`
178}
179
180async function appendLine($: EngineInterface, file: string, text: string) {
181 const ran = await $.process.run(['bash', '-c', 'printf "%s\\n" "$1" >> "$2"', 'pi-subagent', text, file])
182 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || 'append failed')
183}
184
185function showStatus($: EngineInterface) {
186 const running = [...agents.values()].filter(a => a.status === 'running')
187 $.ui.status(
188 running.length
189 ? `pi: ${running
190 .map(a => `${a.name ?? a.type}${a.lastTool ? ` ${a.lastTool}` : ''}${a.watch ? ' (watched)' : ''}`)
191 .join(' · ')}`
192 : undefined,
193 )
194}
195
196async function keep($: EngineInterface, a: Agent) {
197 const kept: Kept = {
198 id: a.id,
199 name: a.name,
200 type: a.type,
201 description: a.description,
202 model: a.model,
203 thinking: a.thinking,
204 readOnly: a.readOnly,
205 isolated: a.isolated,
206 repo: a.repo,
207 based: a.based,
208 }
209 await $.store.set(`agent:${a.id}`, kept)
210}
211
212async function findKept($: EngineInterface, id: string): Promise<Agent | undefined> {
213 const live = agents.get(id)
214 if (live) return live
215 const kept = (await $.store.get(`agent:${id}`)) as Kept | undefined
216 if (!kept) return undefined
217 const now = Date.now()
218 const a: Agent = { ...kept, status: 'idle', bornAt: now, runStartedAt: now, events: [], seq: 1, stats: newStats(), inbox: [] }
219 agents.set(id, a)
220 return a
221}
222
223// The checkout the caller is working in now: the git toplevel of its live working
224// directory, which follows a `cd` into another repo; `$.session.root()` stays the
225// directory the session started in and is only the fallback.
226async function callerRepo($: EngineInterface): Promise<string> {
227 try {
228 const wd = (await $.process.run(['pwd'])).stdout.trim()
229 if (wd) {
230 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: wd })
231 if (top.exitCode === 0 && top.stdout.trim()) return top.stdout.trim()
232 }
233 } catch {}
234 return await $.session.root()
235}
236
237// Where pi starts: the agent's directory, or for an isolated agent its own worktree.
238// On its first run the worktree is pointed at the session's HEAD (the engine makes it
239// from origin/main), when nothing in it could be lost. A worktree that cannot be found
240// is an error: pi never falls back to another checkout.
241async function enterWorktree($: EngineInterface, a: Agent): Promise<{ cwd: string; preface?: string } | { error: string }> {
242 // Found again on every run: a worktree of an earlier run may be gone by now.
243 a.worktree = undefined
244 if (!a.isolated) return { cwd: (await $.process.run(['pwd'])).stdout.trim() }
245 // The checkout the Agent call was made from. Which root `$.session.root()` answers
246 // inside the agent's own hook is not documented, so it is only the fallback for
247 // an agent kept before the call recorded it.
248 const session = a.repo ?? (await $.session.root())
249 const git = async (cwd: string, ...args: string[]) => {
250 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 120_000 })
251 return { ok: r.exitCode === 0, out: r.stdout.trim() }
252 }
253 const listed = await git(session, 'worktree', 'list', '--porcelain')
254 if (!listed.ok) return { error: `its isolated worktree could not be listed (git worktree list failed in ${session})` }
255 const trees = parseWorktrees(listed.out)
256 const found = findAgentWorktree(trees, a.id)
257 if ('error' in found) return { error: `${found.error} ${missingWorktreeAdvice(Boolean(a.based))}` }
258 const { path, branch } = found.worktree
259 a.worktree = { path, branch }
260 if (a.based) return { cwd: path }
261
262 const sessionHead = (await git(session, 'rev-parse', 'HEAD')).out
263 let head = (await git(path, 'rev-parse', 'HEAD')).out
264 if (!head) return { error: `its isolated worktree ${path} is not a git checkout` }
265 let behind: string | undefined
266 if (sessionHead && sessionHead !== head) {
267 const status = await git(path, 'status', '--porcelain')
268 const clean = status.ok && status.out === ''
269 if (clean && (await git(path, 'reset', '--hard', sessionHead)).ok) head = sessionHead
270 else behind = sessionHead
271 }
272 const subject = (await git(path, 'log', '-1', '--format=%h %s')).out
273 a.based = head
274 await keep($, a)
275 return { cwd: path, preface: worktreePreface({ path, branch, repo: session, head: subject || head, sessionHead: behind }) }
276}
277
278// Watch notices go to the main agent as one prompt once it is idle; each
279// agent's latest section replaces its earlier undelivered one.
280async function flush($: EngineInterface) {
281 if (delivering || !sections.size) return
282 const secs = [...sections.entries()]
283 sections.clear()
284 delivering = true
285 let delivered = false
286 try {
287 const text = [WATCH_TAG, SUPERVISION_NOTE, ...secs.map(([, s]) => s.text), '</pi-subagent-watch>'].join('\n\n')
288 if (mainBusy) {
289 // The model reads it from the running turn's next request on.
290 const kept = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
291 if (kept.deny !== undefined) throw new Error(kept.deny)
292 } else {
293 const sent = await $.prompt.submit({ text })
294 if (sent.drop !== undefined) throw new Error(sent.drop)
295 }
296 delivered = true
297 for (const [id, s] of secs) {
298 const w = agents.get(id)?.watch
299 if (w) w.cursor = Math.max(w.cursor, s.cursor)
300 }
301 } catch (err) {
302 $.ui.log(`pi-subagent: a watch notice was not delivered (${(err as Error).message}); retrying`, { to: 'debug' })
303 for (const [id, s] of secs) if (!sections.has(id)) sections.set(id, s)
304 } finally {
305 delivering = false
306 }
307 if (!delivered) $.clock.after(10_000, () => void flush($))
308 else if (sections.size) void flush($)
309}
310
311function stopWatch(a: Agent) {
312 a.watch?.timer.cancel()
313 a.watch = undefined
314 sections.delete(a.id)
315}
316
317function tick($: EngineInterface, a: Agent) {
318 if (!a.watch) return
319 if (a.status !== 'running') return stopWatch(a)
320 sections.set(
321 a.id,
322 watchSection(
323 { id: a.id, label: `${a.name ? `${a.name}, ` : ''}${a.type}: ${a.description}`, status: a.status, brief: a.watch.brief, criteria: a.watch.criteria },
324 a.events,
325 a.watch.cursor,
326 a.runStartedAt,
327 Date.now(),
328 ),
329 )
330 void flush($)
331}
332
333function liveOf(a: Agent): Live {
334 return {
335 label: `${a.name ? `${a.name} · ` : ''}${a.type}: ${a.description}`,
336 status: a.status,
337 model: a.stats.model ?? a.model,
338 steps: a.stats.steps,
339 lines: a.events
340 .filter(x => x.kind !== 'user')
341 .slice(-LIVE_LINES)
342 .map(x => ({ seq: x.seq, kind: x.kind, text: x.summary })),
343 }
344}
345
346// Writes the agent's latest activity for the band; one write at a time, the
347// last one taking whatever arrived while the one before was in flight.
348function publish($: EngineInterface, a: Agent) {
349 if (a.publishing) {
350 a.republish = true
351 return
352 }
353 a.publishing = true
354 a.republish = false
355 void update($, { ...LIVE, id: a.id }, () => liveOf(a))
356 .catch(() => undefined)
357 .finally(() => {
358 a.publishing = false
359 if (a.republish) publish($, a)
360 })
361}
362
363// Shows pi's work in the subagent's own transcript, as notices the model never
364// reads (ctrl+o draws them, dim), and in the band while that transcript is open.
365function mirror($: EngineInterface, a: Agent, items: Omit<Activity, 'seq' | 'at'>[]) {
366 if (items.some(item => item.kind !== 'user')) publish($, a)
367 for (const item of items) {
368 if (item.kind === 'user') continue
369 const mark = item.kind === 'tool' ? '⏺' : item.kind === 'error' ? '✗' : item.kind === 'retry' ? '↻' : item.kind === 'text' ? '◆' : '⎿'
370 void $.session
371 .append({ agentId: a.id, message: { type: 'system', content: [{ type: 'text', text: `${mark} pi ${item.summary}` }] } })
372 .catch(() => undefined)
373 }
374}
375
376// A piece of "thinking" with nothing in it: the engine shows it and does not record it,
377// and it counts as progress of the agent's model request, whose stream watchdog stops an
378// agent after 600 s of nothing. pi runs a long command in silence, so pi's output alone
379// is not enough to keep it alive.
380const BEAT = { kind: 'thinking' as const, index: 0, text: '' }
381const BEAT_MIN_MS = 5_000
382
383// Runs one pi turn for the agent: a fresh pi session on its first, the same one after.
384// It yields BEAT while pi shows signs of life, so a long run is not taken for a stall.
385async function* runPi($: EngineInterface, a: Agent, message: string, signal: AbortSignal): AsyncGenerator<typeof BEAT, number> {
386 a.status = 'running'
387 a.pid = undefined
388 a.lastTool = undefined
389 a.stats = newStats()
390 a.runStartedAt = Date.now()
391 a.beaten = false
392 a.notStarted = false
393 showStatus($)
394 publish($, a)
395
396 const fail = (error: string) => {
397 a.stats.error = error
398 const items = [{ kind: 'error' as const, summary: clip(error, 240), detail: error }]
399 a.seq = pushActivity(a.events, a.seq, Date.now(), items)
400 mirror($, a, items)
401 }
402 let beats = 0
403 // Left at a yield (the engine closed the stream), the run never reaches its end:
404 // the agent is then stopped, not left "running" with its watch ticking.
405 let closed = true
406 try {
407 const entered = await enterWorktree($, a)
408 if ('error' in entered) {
409 a.notStarted = true
410 fail(`pi was not started: ${entered.error}`)
411 } else {
412 beats = yield* runChild($, a, entered.preface ? `${entered.preface}\n\n${message}` : message, entered.cwd, signal)
413 }
414 closed = false
415 } catch (err) {
416 closed = false
417 if (!a.stats.error) fail(`pi could not be run: ${(err as Error).message}`)
418 } finally {
419 const stopped = closed || signal.aborted || a.stats.aborted
420 if (!stopped && !a.stats.error && !a.stats.answer) a.stats.error = 'pi ended without an answer'
421 a.status = stopped ? 'stopped' : a.stats.error ? 'failed' : 'completed'
422 a.lastTool = undefined
423 a.pid = undefined
424 stopWatch(a)
425 showStatus($)
426 publish($, a)
427 }
428 return beats
429}
430
431// Runs pi in `cwd` until it settles; returns how many beats it yielded.
432async function* runChild($: EngineInterface, a: Agent, message: string, cwd: string, signal: AbortSignal): AsyncGenerator<typeof BEAT, number> {
433 const dir = await subagentDir($)
434 const sessionFile = (await $.fs.list(dir).catch(() => [])).find(f => f.name.endsWith(`_${a.id}.jsonl`))
435 const session = sessionFile ? ['--session', `${dir}/${sessionFile.name}`] : ['--session-dir', dir, '--session-id', a.id]
436 a.cmdFile = `${dir}/${a.id}.cmd.jsonl`
437 if (sessionFile) a.seq = pushActivity(a.events, a.seq, a.runStartedAt, [{ kind: 'note', summary: 'resumed its pi session', detail: '' }])
438 await $.fs.write(a.cmdFile, rpc.prompt(message) + '\n')
439
440 const child = $.process.spawn({
441 argv: piArgv({ cmdFile: a.cmdFile, session, readOnly: a.readOnly, model: a.model, thinking: a.thinking }),
442 cwd,
443 })
444 const kill = () => {
445 if (a.pid) void $.process.run(['bash', '-c', KILL_TREE, 'pi-subagent', a.pid])
446 }
447 signal.addEventListener('abort', kill, { once: true })
448 let pending = ''
449 let stderr = ''
450 // Tools pi has started and not finished: while one runs, pi's silence is the command's.
451 let open = 0
452 // pi's own output can be a burst of lines; the wrapper's beats are already 20 s apart.
453 let yielded = 0
454 let lastBeat = 0
455 // Once stopped, nothing is yielded: the engine may have stopped reading, and the run
456 // must still reach its end.
457 const due = () => {
458 const now = Date.now()
459 if (signal.aborted || now - lastBeat < BEAT_MIN_MS) return false
460 lastBeat = now
461 return true
462 }
463 // Whether the loop ran to pi's end (or its settling); otherwise pi's tree is killed.
464 let drained = false
465 try {
466 for await (const { stream, text } of child) {
467 if (stream === 'stderr') {
468 const { beats, rest } = splitBeats(text)
469 stderr = (stderr + rest).slice(-8000)
470 a.pid ??= /PI_PID (\d+)/.exec(stderr)?.[1]
471 if (signal.aborted) kill()
472 if (beats && open > 0 && !signal.aborted) {
473 yielded += 1
474 a.beaten = true
475 yield BEAT
476 }
477 continue
478 }
479 if (due()) {
480 yielded += 1
481 a.beaten = true
482 yield BEAT
483 }
484 pending += text
485 const lines = pending.split('\n')
486 pending = lines.pop() ?? ''
487 for (const raw of lines) {
488 const line = raw.replace(/\r$/, '')
489 if (!line.startsWith('{')) continue
490 let ev: { type: string; [key: string]: unknown }
491 try {
492 ev = JSON.parse(line)
493 } catch {
494 continue
495 }
496 if (ev.type === 'message_update' || ev.type === 'tool_execution_update') continue
497 const dialog = dialogId(ev)
498 if (dialog) {
499 void appendLine($, a.cmdFile, rpc.cancelDialog(dialog)).catch(() => undefined)
500 continue
501 }
502 const items = digest(ev, a.stats)
503 a.seq = pushActivity(a.events, a.seq, Date.now(), items)
504 mirror($, a, items)
505 if (ev.type === 'tool_execution_start') {
506 open += 1
507 a.lastTool = clip(String(ev.toolName), 24)
508 showStatus($)
509 } else if (ev.type === 'tool_execution_end') open = Math.max(0, open - 1)
510 // A turn ends once its tools have: one whose end never came (a nested call that
511 // threw) must not keep beating for a model that has gone silent since.
512 else if ((ev.type === 'turn_end' || ev.type === 'agent_end') && !ev.parentToolCallId) open = 0
513 }
514 if (a.stats.settled) break
515 }
516 drained = true
517 } catch (err) {
518 a.stats.error ??= `pi could not run: ${(err as Error).message}`
519 } finally {
520 signal.removeEventListener('abort', kill)
521 // Leaving the loop early ends only pi itself; the commands it runs go with the tree.
522 if (!drained) kill()
523 }
524 let code: number | null = null
525 if (!a.stats.settled) code = await child.result.then(r => r.code, () => null)
526 const stopped = signal.aborted || a.stats.aborted
527 if (!stopped && !a.stats.settled && !a.stats.error) {
528 a.stats.error = `pi exited (code ${code}) before finishing: ${clip(piStderr(stderr), 600) || 'no output'}`
529 const items = [{ kind: 'error' as const, summary: clip(a.stats.error, 240), detail: a.stats.error }]
530 a.seq = pushActivity(a.events, a.seq, Date.now(), items)
531 mirror($, a, items)
532 }
533 return yielded
534}
535
536function answerOf(a: Agent) {
537 const foot = a.worktree ? `\n${worktreeFooter(a.worktree)}` : ''
538 const head = `[pi · ${a.stats.model ?? a.model ?? "pi's default model"} · ${a.stats.steps} tool uses]`
539 if (a.status === 'failed') {
540 const retry = a.notStarted ? '' : '\n\nSendMessage to this agent retries in the same pi session.'
541 return `${head}\npi failed: ${a.stats.error}${a.stats.answer ? `\n\nLast answer before it failed:\n${a.stats.answer}` : ''}${retry}${foot}`
542 }
543 if (a.status === 'stopped') return `${head}\npi was stopped.${a.stats.answer ? `\n\nLast answer:\n${a.stats.answer}` : ''}${foot}`
544 const u = a.stats.usage
545 return `${a.stats.answer}\n\n[pi · ${a.stats.model ?? 'model unknown'} · ${a.stats.steps} tool uses · ${totalTokens(u)} tokens: ${u.input} in, ${u.output} out, ${u.cacheRead} cache read]${foot}`
546}
547
548function piUsage(a: Agent) {
549 if (!a.stats.model) return null
550 return {
551 input_tokens: a.stats.usage.input,
552 output_tokens: a.stats.usage.output,
553 cache_creation_input_tokens: a.stats.usage.cacheWrite,
554 cache_read_input_tokens: a.stats.usage.cacheRead,
555 model: a.stats.model,
556 }
557}
558
559const textOf = (content: readonly { type: string; text?: string }[]) =>
560 content
561 .filter(b => b.type === 'text' && typeof b.text === 'string')
562 .map(b => b.text)
563 .join('\n')
564 .trim()
565
566export const register: Register = on => {
567 on('session.start', async ($, e, next) => {
568 const started = await next(e)
569 scope = readScope($)
570 void $.process.run(['pi', '--version'], { timeoutMs: 30_000 }).then(
571 r => {
572 const note = piVersionNote(r.exitCode === 0 ? r.stdout : undefined)
573 if (note) $.ui.toast(note)
574 },
575 () => $.ui.toast(piVersionNote(undefined) ?? ''),
576 )
577 await $.tool.register({
578 name: 'subagent_activity',
579 isDeferred: false,
580 description:
581 "Read a pi-backed subagent's recent public activity (its text, tool calls and results, model errors and retries) without consuming its result. " +
582 'No agent_id: lists the pi-backed subagents of this session. With no cursor: a recent snapshot; pass the returned cursor for what came after. ' +
583 'activity_id (an event number from a page) returns that one event in full, paged by offset/limit. Use only for supervision, not polling.',
584 inputSchema: {
585 type: 'object',
586 properties: {
587 agent_id: { type: 'string', description: 'Agent id, name, or a unique id prefix of 8+ characters. Omit to list agents.' },
588 limit: { type: 'integer', minimum: 1, maximum: 20, description: 'Events per page (default 8).' },
589 cursor: { type: 'string', description: 'The cursor a previous page returned.' },
590 activity_id: { type: 'string', description: 'One event number to read in full.' },
591 offset: { type: 'integer', minimum: 0, description: 'Character offset into the event (activity_id only).' },
592 },
593 },
594 })
595 await $.tool.register({
596 name: 'watch_subagent',
597 isDeferred: false,
598 description:
599 'Start, stop or inspect periodic supervision of a working pi-backed subagent: every interval_seconds you get a bounded notice of what it did since the last one (deferred while you are busy; several agents coalesce into one notice). ' +
600 'The watch ends when the agent finishes its turn. Use it for long or risky delegated work on less reliable models. ' +
601 SUPERVISION_NOTE,
602 inputSchema: {
603 type: 'object',
604 properties: {
605 action: { type: 'string', enum: ['start', 'stop', 'status'] },
606 agent_id: { type: 'string', description: 'Agent id, name, or a unique id prefix of 8+ characters.' },
607 interval_seconds: {
608 type: 'integer',
609 minimum: WATCH_MIN_S,
610 maximum: WATCH_MAX_S,
611 description: `Seconds between notices (default ${WATCH_DEFAULT_S}).`,
612 },
613 review_brief: { type: 'string', description: 'What to look for, repeated in each notice.' },
614 criteria: { type: 'string', description: 'When to correct the agent, repeated in each notice.' },
615 },
616 required: ['action', 'agent_id'],
617 },
618 })
619 return started
620 })
621
622 on('tool.describe', { tool: 'Agent' }, async ($, e, next) => {
623 const described = await next(e)
624 scope ??= readScope($)
625 let timer: { cancel: () => void } | undefined
626 const waited = new Promise<undefined>(resolve => {
627 timer = $.clock.after(DESCRIBE_WAIT_MS, () => resolve(undefined))
628 })
629 const ready = await Promise.race([scope, waited])
630 timer?.cancel()
631 const { models, fallback } = ready ?? (await scopeNames($))
632 const note = models.length ? scopedNote(models, fallback ?? '') : ANY_MODEL_NOTE
633 return { ...described, description: `${described.description}${AGENT_HEAD}\n${note}${AGENT_TAIL}` }
634 })
635
636 // The engine spawns the agent; the call only leaves behind what pi needs.
637 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
638 if (e.tool !== 'Agent') return next(e)
639 const { type, model: requested } = splitType(e.subagent_type)
640 // Claude's own: a fork, a remote agent, a Claude model asked by name.
641 if (type === 'fork' || e.isolation === 'remote' || e.model || requested?.toLowerCase() === 'claude') {
642 return next({ ...e, subagent_type: type })
643 }
644 scope ??= readScope($)
645 const { models, fallback } = await scope
646 // `@default`, like no `@` at all, is pi's default model as it is now, not as the session began.
647 const asDefault = parseDefault(requested)
648 if (asDefault && 'error' in asDefault) return { deny: asDefault.error }
649 let model = asDefault ? undefined : requested
650 if (models.length) {
651 const now = await readPiSettings($).then(s => defaultOf(models, s.provider, s.name), () => undefined)
652 const named = model ? resolveScoped(model, models) : undefined
653 const resolved = named ?? ((now ?? fallback) ? { model: (now ?? fallback)! } : undefined)
654 if (resolved && 'error' in resolved) return { deny: resolved.error }
655 model = resolved?.model
656 }
657 // A level written after the model name wins over `effort`; a default model carries none of its own.
658 const effort = asDefault?.level ?? e.effort
659 const thinking = (!asDefault && hasThinkingSuffix(model)) || !effort || !THINKING_LEVELS.includes(effort) ? undefined : effort
660 asked.set(e.tool_use_id, {
661 name: e.name,
662 type,
663 model,
664 thinking,
665 readOnly: isReadOnlyType(type),
666 isolated: e.isolation === 'worktree',
667 // Read here, in the caller's own hook, where the session's root is the caller's.
668 repo: e.isolation === 'worktree' ? await callerRepo($) : undefined,
669 })
670 try {
671 return await next({ ...e, subagent_type: type })
672 } finally {
673 asked.delete(e.tool_use_id)
674 }
675 })
676
677 on('agent.spawn', async ($, e, next) => {
678 const ask = asked.get(e.tool_use_id)
679 const spawned = await next(e)
680 if (ask && !e.fork && spawned.agentId) {
681 const now = Date.now()
682 const a: Agent = {
683 ...ask,
684 id: spawned.agentId,
685 description: e.description,
686 status: 'idle',
687 bornAt: now,
688 runStartedAt: now,
689 events: [],
690 seq: 1,
691 stats: newStats(),
692 inbox: [],
693 }
694 agents.set(a.id, a)
695 await keep($, a)
696 }
697 return spawned
698 })
699
700 // What the engine hands the agent to read (its prompt, a resume's message) is pi's next input.
701 on('session.append', async ($, e, next) => {
702 const stored = await next(e)
703 const toAgent = e.agentId && !stored.deny && e.message.type === 'user' && e.message.role === 'user' && e.door !== 'tool-result' && e.door !== 'note'
704 // A resume after a reload appends its message before the agent's step loads it from the store.
705 const a = toAgent ? await findKept($, e.agentId!) : undefined
706 if (a) {
707 const text = textOf(e.message.content as { type: string; text?: string }[])
708 if (text) a.inbox.push(text)
709 }
710 return stored
711 })
712
713 on('turn.start', async ($, e, next) => {
714 if (e.agentId === undefined) mainBusy = true
715 return next(e)
716 })
717
718 on('turn.complete', async ($, e, next) => {
719 const done = await next(e)
720 if (e.agentId === undefined) {
721 mainBusy = false
722 if (sections.size) void flush($)
723 }
724 return done
725 })
726
727 // The agent's model request: pi answers it, and no request is sent.
728 on('turn.step', async function* ($, e, next) {
729 if (e.agentId === undefined) {
730 mainBusy = true
731 if (sections.size) void flush($)
732 }
733 const a = e.agentId ? await findKept($, e.agentId) : undefined
734 if (!a) return yield* next(e)
735 // pi echoes the message as its own user row, which the activity records.
736 const message = a.inbox.splice(0).join('\n\n') || 'Continue.'
737 const beats = yield* runPi($, a, message, next.signal)
738 const answer = answerOf(a) || '(pi gave no text)'
739 // The answer is the block after the beats' (one block holds one kind of piece).
740 yield { kind: 'text', index: beats ? BEAT.index + 1 : 0, text: answer }
741 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: piUsage(a) }
742 }).catch(async function* ($, e, next) {
743 // A pi-backed agent never falls back to a Claude request.
744 if (!e.agentId || !agents.has(e.agentId)) return yield* next(e)
745 const answer = `pi-subagent failed: ${String(next.error?.message ?? 'unknown error')}`
746 // The failed hook's beats stay in the response, in block 0.
747 yield { kind: 'text', index: agents.get(e.agentId)?.beaten ? BEAT.index + 1 : 0, text: answer }
748 return { turnId: e.turnId, index: e.index, answer, toolUses: [], stopReason: 'end_turn', usage: null }
749 })
750
751 // A message to an agent while pi works steers pi; otherwise the engine delivers or resumes.
752 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
753 if (e.tool !== 'SendMessage' || typeof e.to !== 'string' || typeof e.message !== 'string') return next(e)
754 const found = resolveAgent(agents.values(), e.to)
755 const a = found.agent
756 if (!a || a.status !== 'running' || !a.cmdFile) return next(e)
757 await appendLine($, a.cmdFile, rpc.steer(e.message))
758 return { result: { success: true, message: `Steering message queued for ${a.name ?? a.id}: pi reads it after its current tool calls, before its next model call.` } }
759 }).catch(($, e, next) => (next.called ? { deny: `${$.plugin.name}: steering the pi subagent failed.` } : next(e)))
760
761 on('tool.call', { tool: ACTIVITY_TOOL }, async ($, e) => {
762 const input = e as unknown as { agent_id?: string; limit?: number; cursor?: string; activity_id?: string; offset?: number }
763 if (!input.agent_id) {
764 const rows = [...agents.values()].map(
765 a =>
766 `${a.id}${a.name ? ` (${a.name})` : ''} · ${a.type} · ${a.status} · ${a.stats.model ?? a.model ?? "pi's default"} · ${a.stats.steps} tool uses${a.watch ? ` · watched every ${a.watch.intervalS}s` : ''} · ${a.description}`,
767 )
768 return { result: rows.length ? rows.join('\n') : 'No pi-backed subagents in this session.' }
769 }
770 const found = resolveAgent(agents.values(), input.agent_id)
771 const a = found.agent
772 if (!a) return { deny: found.error ?? 'No such pi-backed subagent.' }
773 if (input.activity_id !== undefined) {
774 return { result: detailPage(a.events.find(x => String(x.seq) === String(input.activity_id)), input.offset, input.limit) }
775 }
776 const head = `${a.id} · ${a.status} · ${a.stats.model ?? a.model ?? "pi's default"} · ${a.stats.steps} tool uses this run`
777 return { result: `${head}\n${activityPage(a.events, a.bornAt, { limit: input.limit, cursor: input.cursor }).text}` }
778 }).catch(($, e, next) => ({ deny: `${$.plugin.name}: reading the activity failed.` }))
779
780 on('tool.call', { tool: WATCH_TOOL }, async ($, e) => {
781 const input = e as unknown as { action?: string; agent_id?: string; interval_seconds?: number; review_brief?: string; criteria?: string }
782 const found = resolveAgent(agents.values(), input.agent_id ?? '')
783 const a = found.agent
784 if (!a) return { deny: found.error ?? 'No such pi-backed subagent.' }
785 if (input.action === 'status') {
786 return { result: a.watch ? `Watching ${a.id} every ${a.watch.intervalS}s; delivered up to event ${a.watch.cursor}.` : `${a.id} is not watched (${a.status}).` }
787 }
788 if (input.action === 'stop') {
789 const had = Boolean(a.watch)
790 stopWatch(a)
791 showStatus($)
792 return { result: had ? `Stopped watching ${a.id}.` : `${a.id} was not watched.` }
793 }
794 if (input.action !== 'start') return { deny: 'action must be start, stop or status.' }
795 if (a.status !== 'running') return { deny: `${a.id} is not working (${a.status}); there is nothing to watch.` }
796 const intervalS = clampInterval(input.interval_seconds)
797 const updated = Boolean(a.watch)
798 a.watch?.timer.cancel()
799 a.watch = {
800 intervalS,
801 brief: input.review_brief ?? a.watch?.brief,
802 criteria: input.criteria ?? a.watch?.criteria,
803 cursor: a.watch?.cursor ?? 0,
804 timer: $.clock.every(intervalS * 1000, () => tick($, a)),
805 }
806 showStatus($)
807 return { result: `${updated ? 'Updated' : 'Started'} watch on ${a.id} every ${intervalS}s; it ends when the agent finishes its turn.` }
808 }).catch(($, e, next) => ({ deny: `${$.plugin.name}: the watch request failed.` }))
809
810 // While a pi-backed subagent's transcript is open (↓, from the tasks list), the
811 // band above the prompt shows its latest steps live, in the transcript's colors.
812 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
813 const id = e.props.view.agentId
814 if (!id || e.props.hasSurvey) return next(e)
815 const live = (await $.state.get({ ...LIVE, id })).value
816 if (!live) return next(e)
817 const { Box, Text } = $.ui.resolve(e)
818 const room = Math.max(1, Math.min(LIVE_LINES, e.props.maxRows - 1))
819 const color = live.status === 'running' ? 'suggestion' : live.status === 'completed' ? 'success' : live.status === 'idle' ? 'subtle' : 'error'
820 return (
821 <Box flexDirection="column">
822 <Text wrap="truncate-end">
823 <Text bold>pi</Text> · {live.model ?? "pi's default model"} · <Text color={color}>{live.status}</Text> · {live.steps} tool uses ·{' '}
824 {live.label}
825 </Text>
826 {live.lines.slice(-room).map(line => {
827 const m = LIVE_MARK[line.kind] ?? { mark: '·', color: 'subtle' }
828 return (
829 <Text key={String(line.seq)} wrap="truncate-end">
830 <Text color={m.color}>{m.mark}</Text> {line.text}
831 </Text>
832 )
833 })}
834 </Box>
835 )
836 })
837
838 // A watch notice is drawn as one dim line; ctrl+o shows it whole.
839 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
840 if (e.props.isExpanded || e.props.origin.kind !== 'plugin' || !e.props.text.includes(WATCH_TAG)) return next(e)
841 const agentsIn = [...e.props.text.matchAll(/^## (\S+) \(([^,)]+)/gm)].map(m => m[2] ?? m[1])
842 const events = (e.props.text.match(/^\[\d+ /gm) ?? []).length
843 const { Text } = $.ui.resolve(e)
844 return (
845 <Text dimColor>
846 ⟳ pi watch · {agentsIn.join(', ') || 'subagents'} · {events} new events (ctrl+o to expand)
847 </Text>
848 )
849 })
850}
851hooks/core.ts 484 lines1// Pure helpers of the pi-subagent mod: no `$`, so the tests reach every branch.
2
3// Agent types that only look around: pi gets read-only tools and is told so.
4export const READ_ONLY_TYPES = new Set(['explore', 'plan', 'claude-code-guide'])
5export const READ_ONLY_NOTE =
6 'You are in read-only mode: you have only read, grep, find and ls. Inspect and report; do not try to change anything.'
7
8export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
9
10export const WATCH_DEFAULT_S = 240
11export const WATCH_MIN_S = 30
12export const WATCH_MAX_S = 3600
13export const MAX_EVENTS = 400
14export const ACTIVITY_DEFAULT_LIMIT = 8
15export const ACTIVITY_MAX_LIMIT = 20
16export const PAGE_CHARS = 9000
17export const SECTION_CHARS = 2100
18export const DETAIL_CHARS = 65536
19export const DETAIL_DEFAULT_LIMIT = 4096
20export const DETAIL_MAX_LIMIT = 8192
21
22export type ActivityKind = 'user' | 'text' | 'tool' | 'result' | 'error' | 'retry' | 'note'
23
24export type Activity = { seq: number; at: number; kind: ActivityKind; summary: string; detail: string }
25
26export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
27
28// What one pi run (a launch or a resume) came to.
29export type RunStats = {
30 steps: number
31 usage: Usage
32 model?: string
33 answer: string
34 error?: string
35 settled: boolean
36 aborted: boolean
37}
38
39export type PiEvent = {
40 type: string
41 [key: string]: unknown
42}
43
44export const newStats = (): RunStats => ({
45 steps: 0,
46 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
47 answer: '',
48 settled: false,
49 aborted: false,
50})
51
52// Splits `Explore@codebuddy/glm-5.3:high` into the agent type and pi's model pattern.
53export function splitType(subagentType?: string): { type: string; model?: string } {
54 const at = subagentType?.indexOf('@') ?? -1
55 if (!subagentType || at < 0) return { type: subagentType || 'general-purpose' }
56 return { type: subagentType.slice(0, at) || 'general-purpose', model: subagentType.slice(at + 1) || undefined }
57}
58
59export const isReadOnlyType = (type: string) => READ_ONLY_TYPES.has(type.toLowerCase())
60
61// Starts pi in RPC mode reading its commands from `cmdFile` as lines are appended.
62// `exec` keeps the shell's pid for pi, so `tail --pid` ends with pi, and the pid
63// on stderr is what a stop kills. While that pid lives a beat line goes to stderr
64// every BEAT_EVERY_S: pi is silent for as long as one command runs, and a stderr
65// line is what lets the agent's model request show the engine it is still working.
66export const BEAT_EVERY_S = 20
67const WRAPPER =
68 'f="$1"; shift; echo "PI_PID $$" >&2; ' +
69 `(n=0; while kill -0 $$ 2>/dev/null; do sleep 1; n=$((n+1)); if [ $((n % ${BEAT_EVERY_S})) -eq 0 ]; then echo PI_BEAT >&2; fi; done) >/dev/null & ` +
70 'exec "$@" < <(exec tail -n +1 -f --pid=$$ "$f")'
71
72export type PiRun = {
73 cmdFile: string
74 session: string[]
75 readOnly: boolean
76 model?: string
77 thinking?: string
78}
79
80export function piArgv({ cmdFile, session, readOnly, model, thinking }: PiRun): string[] {
81 const argv = ['bash', '-c', WRAPPER, 'pi-subagent', cmdFile, 'pi', '--mode', 'rpc', ...session]
82 if (model) argv.push('--model', model)
83 if (thinking) argv.push('--thinking', thinking)
84 if (readOnly) argv.push('--tools', 'read,grep,find,ls', '--append-system-prompt', READ_ONLY_NOTE)
85 return argv
86}
87
88// Kills a process and every descendant, collected before any dies so none is reparented away.
89export const KILL_TREE =
90 'tree() { echo "$1"; for c in $(pgrep -P "$1"); do tree "$c"; done; }; kill $(tree "$1") 2>/dev/null; true'
91
92// pi's stderr less the wrapper's pid and beat lines and the new-session warning.
93export function piStderr(stderr: string) {
94 return stderr
95 .split('\n')
96 .filter(l => !/^PI_PID \d+/.test(l) && l !== 'PI_BEAT' && !/^Warning: No project session found/.test(l))
97 .join('\n')
98}
99
100// A stderr chunk split into the wrapper's beats and everything else.
101export function splitBeats(text: string): { beats: number; rest: string } {
102 let beats = 0
103 const rest = text.replace(/^PI_BEAT\r?\n/gm, () => (beats++, ''))
104 return { beats, rest }
105}
106
107export type Worktree = { path: string; head?: string; branch?: string }
108
109// `git worktree list --porcelain`, the main working tree first.
110export function parseWorktrees(porcelain: string): Worktree[] {
111 const out: Worktree[] = []
112 for (const line of porcelain.split('\n')) {
113 if (line.startsWith('worktree ')) out.push({ path: line.slice('worktree '.length) })
114 else if (line.startsWith('HEAD ') && out.length) out.at(-1)!.head = line.slice('HEAD '.length)
115 else if (line.startsWith('branch ') && out.length) out.at(-1)!.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
116 }
117 return out
118}
119
120const baseName = (path: string) => path.replace(/\/+$/, '').split('/').at(-1) ?? ''
121
122// The worktree the engine made for an agent: `agent-<id>` on the branch `worktree-agent-<id>`.
123// It is never guessed at: no match, or two, is an error, so pi is not started in the wrong tree.
124export function findAgentWorktree(list: readonly Worktree[], agentId: string): { worktree: Worktree } | { error: string } {
125 const short = agentId.replace(/^a/, '').slice(0, 8)
126 const names = new Set([`agent-${agentId}`, `agent-a${short}`, `agent-${short}`])
127 const [, ...linked] = list
128 const found = linked.filter(w => names.has(baseName(w.path)) || [...names].some(n => w.branch === `worktree-${n}`))
129 if (found.length === 1) return { worktree: found[0]! }
130 const why = found.length ? `${found.length} worktrees match it` : `git worktree list has no worktree named agent-${agentId}`
131 return { error: `its isolated worktree was not found (${why}).` }
132}
133
134// What to do about a missing worktree: on a resume the engine has most likely removed it.
135export const missingWorktreeAdvice = (resumed: boolean) =>
136 resumed
137 ? 'The engine removes a worktree with no changes when the agent finishes, so start a new agent instead of resuming this one.'
138 : 'pi runs only in the worktree the engine made for this agent, never in another checkout; start the agent again.'
139
140export type WorktreeBrief = {
141 path: string
142 branch?: string
143 repo: string
144 head: string
145 // The session's HEAD, when the worktree could not be pointed at it.
146 sessionHead?: string
147}
148
149// What pi is told about the worktree it starts in.
150export function worktreePreface(w: WorktreeBrief) {
151 const lines = [
152 `You are working in an isolated git worktree: ${w.path}${w.branch ? ` (branch ${w.branch})` : ''}.`,
153 `It is at ${w.head}.`,
154 w.sessionHead
155 ? `The caller's checkout is at ${w.sessionHead}, which this worktree could not be pointed at; if the task depends on commits you cannot see, say so in your answer instead of working around it.`
156 : `That is the caller's checkout commit; uncommitted changes in the caller's checkout are not in this worktree.`,
157 `Stay inside ${w.path}: do not cd into or edit anything under ${w.repo}. If you change files, commit them on this branch when you are done, so the caller can merge them.`,
158 ]
159 return lines.join('\n')
160}
161
162// The last line of an answer from a worktree agent: where its work is.
163export const worktreeFooter = (w: { path: string; branch?: string }) => `[worktree · ${w.path}${w.branch ? ` · branch ${w.branch}` : ''}]`
164
165export const rpc = {
166 prompt: (message: string) => JSON.stringify({ type: 'prompt', message }),
167 steer: (message: string) => JSON.stringify({ type: 'steer', message }),
168 abort: () => JSON.stringify({ type: 'abort' }),
169 cancelDialog: (id: string) => JSON.stringify({ type: 'extension_ui_response', id, cancelled: true }),
170}
171
172const DIALOGS = new Set(['select', 'confirm', 'input', 'editor'])
173
174// An extension dialog pi waits on; nobody answers it here, so it is cancelled.
175export function dialogId(ev: PiEvent): string | undefined {
176 return ev.type === 'extension_ui_request' && DIALOGS.has(String(ev.method)) ? String(ev.id) : undefined
177}
178
179export function clip(text: string, max: number): string {
180 const flat = text.replace(/\s+/g, ' ').trim()
181 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
182}
183
184const cut = (text: string, max: number) => (text.length <= max ? text : `${text.slice(0, max)}\n… (${text.length - max} more characters)`)
185
186function textOf(content: unknown): string {
187 if (typeof content === 'string') return content
188 if (!Array.isArray(content)) return ''
189 return content
190 .filter((b): b is { type: 'text'; text: string } => b?.type === 'text' && typeof b.text === 'string')
191 .map(b => b.text)
192 .join('')
193}
194
195type PiMessage = {
196 role?: string
197 content?: unknown
198 provider?: string
199 model?: string
200 stopReason?: string
201 errorMessage?: string
202 usage?: Partial<Usage>
203}
204
205// Folds one pi event into the run's stats and returns the activity it shows, if any.
206export function digest(ev: PiEvent, stats: RunStats): Omit<Activity, 'seq' | 'at'>[] {
207 switch (ev.type) {
208 case 'message_end': {
209 const m = (ev.message ?? {}) as PiMessage
210 if (m.role === 'user') {
211 const text = textOf(m.content)
212 return text ? [{ kind: 'user', summary: clip(text, 180), detail: cut(text, DETAIL_CHARS) }] : []
213 }
214 if (m.role !== 'assistant') return []
215 stats.usage.input += m.usage?.input ?? 0
216 stats.usage.output += m.usage?.output ?? 0
217 stats.usage.cacheRead += m.usage?.cacheRead ?? 0
218 stats.usage.cacheWrite += m.usage?.cacheWrite ?? 0
219 if (m.model) stats.model = m.provider ? `${m.provider}/${m.model}` : m.model
220 const out: Omit<Activity, 'seq' | 'at'>[] = []
221 const text = textOf(m.content)
222 if (text) {
223 stats.answer = text
224 out.push({ kind: 'text', summary: clip(text, 180), detail: cut(text, DETAIL_CHARS) })
225 }
226 if (m.stopReason === 'error') {
227 stats.error = m.errorMessage || 'model error'
228 out.push({ kind: 'error', summary: clip(stats.error, 240), detail: cut(stats.error, DETAIL_CHARS) })
229 } else if (text || m.stopReason === 'toolUse') {
230 stats.error = undefined
231 }
232 return out
233 }
234 case 'tool_execution_start': {
235 stats.steps += 1
236 const args = JSON.stringify(ev.args ?? {})
237 const nested = ev.parentToolCallId ? '↳ ' : ''
238 return [
239 {
240 kind: 'tool',
241 summary: `${nested}${clip(String(ev.toolName), 48)} ${clip(args, 240)}`,
242 detail: cut(JSON.stringify(ev.args ?? {}, null, 2), DETAIL_CHARS),
243 },
244 ]
245 }
246 case 'tool_execution_end': {
247 const result = textOf((ev.result as { content?: unknown } | undefined)?.content)
248 const failed = ev.isError === true
249 return [
250 {
251 kind: failed ? 'error' : 'result',
252 summary: `${clip(String(ev.toolName), 48)}${failed ? ' failed' : ''}: ${clip(result || '(no output)', 180)}`,
253 detail: cut(result, DETAIL_CHARS),
254 },
255 ]
256 }
257 case 'auto_retry_start': {
258 const why = `model call failed (${String(ev.errorMessage ?? 'error')}); retry ${String(ev.attempt)}/${String(ev.maxAttempts)}`
259 return [{ kind: 'retry', summary: clip(why, 240), detail: why }]
260 }
261 case 'auto_retry_end':
262 return ev.success === false ? [{ kind: 'error', summary: 'model retries exhausted', detail: JSON.stringify(ev) }] : []
263 case 'compaction_start':
264 return [{ kind: 'note', summary: `context compaction (${String(ev.reason ?? '')})`, detail: '' }]
265 case 'agent_settled':
266 stats.settled = true
267 stats.aborted = ev.aborted === true
268 return []
269 case 'response':
270 if (ev.success === false) {
271 stats.error = `pi refused ${String(ev.command)}: ${String(ev.error ?? '')}`
272 return [{ kind: 'error', summary: clip(stats.error, 240), detail: stats.error }]
273 }
274 return []
275 default:
276 return []
277 }
278}
279
280export function pushActivity(events: Activity[], next: number, at: number, items: Omit<Activity, 'seq' | 'at'>[]) {
281 for (const item of items) {
282 events.push({ ...item, seq: next++, at })
283 if (events.length > MAX_EVENTS) events.shift()
284 }
285 return next
286}
287
288const line = (a: Activity, since: number) =>
289 `[${a.seq} +${Math.max(0, Math.round((a.at - since) / 1000))}s ${a.kind}] ${a.summary}`
290
291// A page of activity: the latest `limit` events, or those after `cursor`.
292export function activityPage(events: Activity[], since: number, options: { limit?: number; cursor?: string }) {
293 const limit = Math.min(Math.max(Math.trunc(options.limit ?? ACTIVITY_DEFAULT_LIMIT), 1), ACTIVITY_MAX_LIMIT)
294 const header: string[] = []
295 let picked: Activity[]
296 if (options.cursor === undefined || options.cursor === '') {
297 picked = events.slice(-limit)
298 const omitted = events.length - picked.length
299 if (omitted > 0) header.push(`(${omitted} earlier retained events omitted; this is the recent snapshot)`)
300 } else {
301 const after = Number(options.cursor)
302 if (!Number.isInteger(after) || after < 0) return { text: `Invalid cursor "${options.cursor}".`, cursor: options.cursor }
303 const oldest = events[0]?.seq
304 if (oldest !== undefined && after < oldest - 1) header.push(`(gap: events ${after + 1}..${oldest - 1} are no longer retained)`)
305 picked = events.filter(a => a.seq > after).slice(0, limit)
306 }
307 const lines: string[] = []
308 let size = header.join('\n').length
309 for (const a of picked) {
310 const l = line(a, since)
311 if (size + l.length + 1 > PAGE_CHARS) {
312 lines.push('(page full)')
313 break
314 }
315 lines.push(l)
316 size += l.length + 1
317 }
318 const shown = picked.slice(0, lines.filter(l => l !== '(page full)').length)
319 const last = shown.at(-1)?.seq ?? (options.cursor ? Number(options.cursor) : (events.at(-1)?.seq ?? 0))
320 const hasMore = events.some(a => a.seq > last)
321 const body = lines.length ? lines.join('\n') : '(no new activity)'
322 return { text: [...header, body, `cursor: ${last}${hasMore ? ' (more after it)' : ''}`].join('\n'), cursor: String(last) }
323}
324
325export function detailPage(event: Activity | undefined, offset = 0, limit = DETAIL_DEFAULT_LIMIT) {
326 if (!event) return 'Activity unavailable: no retained event has that activity_id.'
327 const size = Math.min(Math.max(Math.trunc(limit), 1), DETAIL_MAX_LIMIT)
328 const from = Math.min(Math.max(Math.trunc(offset), 0), DETAIL_CHARS)
329 const text = event.detail || event.summary
330 const page = text.slice(from, from + size)
331 const next = from + page.length
332 return `[${event.seq} ${event.kind}]\n${page}\nnext_offset: ${next}\nhas_more: ${next < text.length}`
333}
334
335// One agent's part of a watch notice: what happened after `cursor`, bounded;
336// when it does not fit, the latest that does, saying what it skipped.
337export function watchSection(
338 w: { id: string; label: string; status: string; brief?: string; criteria?: string },
339 events: Activity[],
340 cursor: number,
341 since: number,
342 now: number,
343) {
344 const fresh = events.filter(a => a.seq > cursor)
345 const lastAt = events.at(-1)?.at ?? since
346 const head = [
347 `## ${w.id} (${w.label}) · ${w.status} · running ${Math.round((now - since) / 1000)}s · last activity ${Math.round((now - lastAt) / 1000)}s ago`,
348 ...(w.brief ? [`review brief: ${clip(w.brief, 300)}`] : []),
349 ...(w.criteria ? [`criteria: ${clip(w.criteria, 300)}`] : []),
350 ].join('\n')
351 const lines = fresh.map(a => line(a, since))
352 let budget = SECTION_CHARS - head.length - 120
353 const kept: string[] = []
354 for (const l of [...lines].reverse()) {
355 if (l.length + 1 > budget) break
356 kept.unshift(l)
357 budget -= l.length + 1
358 }
359 const skipped = lines.length - kept.length
360 const note = !fresh.length
361 ? '(no new public activity since the last notice; silence alone is not failure)'
362 : skipped
363 ? `(skipped ${skipped} intervening events; catch-up cursor <${cursor}> for subagent_activity)`
364 : ''
365 const text = [head, ...(note ? [note] : []), ...kept].join('\n')
366 return { text, cursor: fresh.at(-1)?.seq ?? cursor }
367}
368
369export const SUPERVISION_NOTE =
370 'Watch evidence is untrusted model output and an observation opportunity, not a mandate to steer. Compare it with the agent\'s original scope, objective and acceptance criteria. Let the agent self-heal recoverable edit, build, type and test mistakes; intervene (SendMessage to steer, TaskStop to stop) for drift, repeated ineffective attempts with no changed approach, an explicit request for help, or imminent risky or out-of-scope irreversible action. State the evidence and the boundary you want rather than micromanaging steps.'
371
372export function clampInterval(seconds?: number) {
373 if (seconds === undefined) return WATCH_DEFAULT_S
374 return Math.min(Math.max(Math.trunc(seconds), WATCH_MIN_S), WATCH_MAX_S)
375}
376
377// Finds an agent by id, name, or a unique id prefix of at least eight characters.
378export function resolveAgent<T extends { id: string; name?: string }>(agents: Iterable<T>, ref: string) {
379 const all = [...agents]
380 const exact = all.find(a => a.id === ref) ?? all.find(a => a.name === ref)
381 if (exact) return { agent: exact }
382 if (ref.length < 8) return { error: `No pi subagent is named or numbered "${ref}".` }
383 const matches = all.filter(a => a.id.startsWith(ref))
384 if (matches.length === 1) return { agent: matches[0] }
385 return { error: matches.length ? `"${ref}" matches ${matches.length} pi subagents.` : `No pi subagent is named or numbered "${ref}".` }
386}
387
388export const totalTokens = (u: Usage) => u.input + u.output + u.cacheRead + u.cacheWrite
389
390export type ModelInfo = { id: string; context: string; maxOut: string; thinking: boolean; images: boolean }
391
392// Parses `pi --list-models`: provider, model (may hold spaces), context, max-out, thinking, images.
393export function parseModelList(text: string): ModelInfo[] {
394 const out: ModelInfo[] = []
395 for (const line of text.split('\n').slice(1)) {
396 const cols = line.trim().split(/\s+/)
397 if (cols.length < 6) continue
398 const [context = '', maxOut = '', thinking = '', images = ''] = cols.slice(-4)
399 out.push({ id: `${cols[0]}/${cols.slice(1, -4).join(' ')}`, context, maxOut, thinking: thinking === 'yes', images: images === 'yes' })
400 }
401 return out
402}
403
404const globToRegExp = (glob: string) =>
405 new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i')
406
407// The scoped models in their configured order, each pattern expanded against pi's list.
408export function scopeModels(enabled: readonly string[], listed: readonly ModelInfo[]): ModelInfo[] {
409 const seen = new Set<string>()
410 const out: ModelInfo[] = []
411 for (const pattern of enabled) {
412 const re = globToRegExp(pattern)
413 const hits = listed.filter(m => re.test(m.id))
414 const found = hits.length ? hits : [{ id: pattern, context: '?', maxOut: '?', thinking: false, images: false }]
415 for (const m of found) {
416 if (seen.has(m.id)) continue
417 seen.add(m.id)
418 out.push(m)
419 }
420 }
421 return out
422}
423
424// `codebuddy/deepseek-v4.1-flash (x0.11)` and `codebuddy/deepseek-v4.1-flash` name the same model.
425const bare = (id: string) => id.replace(/\s*\(x[\d.]+\)\s*$/, '').toLowerCase()
426
427// Resolves a requested `<provider>/<model>[:<thinking>]` to one scoped model, or says why not.
428export function resolveScoped(requested: string, scoped: readonly ModelInfo[]): { model: string } | { error: string } {
429 const colon = requested.lastIndexOf(':')
430 const level = colon > 0 ? requested.slice(colon + 1) : ''
431 const hasLevel = THINKING_LEVELS.includes(level)
432 const name = hasLevel ? requested.slice(0, colon) : requested
433 const want = bare(name)
434 let hits = scoped.filter(m => bare(m.id) === want)
435 if (!hits.length) hits = scoped.filter(m => bare(m.id.slice(m.id.indexOf('/') + 1)) === want)
436 if (hits.length === 1) return { model: hasLevel ? `${hits[0]!.id}:${level}` : hits[0]!.id }
437 const list = scoped.map(m => m.id).join(', ')
438 return {
439 error: hits.length
440 ? `"${name}" matches several scoped models (${hits.map(m => m.id).join(', ')}); name the provider.`
441 : `"${name}" is not one of the scoped models; subagents may use only: ${list}.`,
442 }
443}
444
445// Whether a model pattern ends in `:<thinking>`; a `:` inside a model id (`x:free`) is not one.
446export function hasThinkingSuffix(model?: string) {
447 const colon = model?.lastIndexOf(':') ?? -1
448 return colon > 0 && THINKING_LEVELS.includes(model!.slice(colon + 1))
449}
450
451// `default` or `default:<thinking>` after the `@`: pi's current default model, not a named one.
452export function parseDefault(requested?: string): { level?: string } | { error: string } | undefined {
453 const [word, level, ...extra] = (requested ?? '').toLowerCase().split(':')
454 if (word !== 'default') return undefined
455 if (level === undefined) return {}
456 if (extra.length || !THINKING_LEVELS.includes(level)) return { error: `"${requested}" is not a thinking level of pi's default model; use @default or @default:<${THINKING_LEVELS.join('|')}>.` }
457 return { level }
458}
459
460export const DEFAULT_NOTE =
461 "`@default` (`general-purpose@default`, optionally `:<thinking>`) runs pi's current default model: use it unless the user names a model."
462
463export function scopedNote(scoped: readonly ModelInfo[], fallback: string) {
464 const rows = scoped.map(
465 m =>
466 ` - ${m.id}${m.id === fallback ? ' (default)' : ''}${m.context === '?' && m.maxOut === '?' ? '' : ` · ctx ${m.context} · out ${m.maxOut}`}${m.thinking ? ' · thinking' : ''}${m.images ? ' · images' : ''}`,
467 )
468 return [
469 `- Models: subagents run only on these scoped models (append \`@<provider>/<model>\` to subagent_type, optionally \`:<thinking>\`; any other model is refused; omitted, the default):`,
470 ...rows,
471 `- ${DEFAULT_NOTE}${fallback ? ` It is ${fallback} now.` : ''}`,
472 ].join('\n')
473}
474
475// The pi release whose RPC commands and JSON events this mod was built and tested against.
476export const PI_TESTED = '1.2'
477
478export function piVersionNote(versionOutput: string | undefined): string | undefined {
479 if (versionOutput === undefined) return 'pi-subagent: `pi` is not on PATH; pi-backed subagents will fail until it is.'
480 const version = versionOutput.trim().split(/\s+/).pop() ?? ''
481 if (version === PI_TESTED || version.startsWith(`${PI_TESTED}.`)) return undefined
482 return `pi-subagent: tested with pi ${PI_TESTED}.x, found ${version || 'an unknown version'}; if subagents misbehave, check pi's RPC and JSON event changes.`
483}
484types/index.d.ts 12 lines1// One line of a subagent's live activity, as the band above the prompt draws it.
2export type LiveLine = { seq: number; kind: string; text: string }
3
4// What the band shows for one pi-backed subagent while its transcript is open.
5export type Live = { label: string; status: string; model?: string; steps: number; lines: LiveLine[] }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'pi-subagent': { live: StateFamily<Live> }
10 }
11}
12