pi-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,953 @@
1
+ /**
2
+ * Subagent Tool - Delegate tasks to specialized agents
3
+ *
4
+ * Spawns a separate `pi` process for each subagent invocation,
5
+ * giving it an isolated context window.
6
+ *
7
+ * Supports three modes:
8
+ * - Single: { agent: "name", task: "..." }
9
+ * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
10
+ * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
11
+ *
12
+ * Uses JSON mode to capture structured output from subagents.
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+ import * as fs from 'node:fs'
17
+ import * as os from 'node:os'
18
+ import * as path from 'node:path'
19
+ import type { AgentToolResult } from '@earendil-works/pi-agent-core'
20
+ import type { Message } from '@earendil-works/pi-ai'
21
+ import { StringEnum } from '@earendil-works/pi-ai'
22
+ import { type ExtensionAPI, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
23
+ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
24
+ import { Type } from 'typebox'
25
+ import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
26
+ import { backgroundStatusText, startBackgroundRun } from './background.js'
27
+
28
+ const MAX_PARALLEL_TASKS = 8
29
+ const MAX_CONCURRENCY = 4
30
+ const COLLAPSED_ITEM_COUNT = 10
31
+
32
+ function formatTokens(count: number): string {
33
+ if (count < 1000) return count.toString()
34
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`
35
+ if (count < 1000000) return `${Math.round(count / 1000)}k`
36
+ return `${(count / 1000000).toFixed(1)}M`
37
+ }
38
+
39
+ function formatUsageStats(
40
+ usage: {
41
+ input: number
42
+ output: number
43
+ cacheRead: number
44
+ cacheWrite: number
45
+ cost: number
46
+ contextTokens?: number
47
+ turns?: number
48
+ },
49
+ model?: string,
50
+ ): string {
51
+ const parts: string[] = []
52
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? 's' : ''}`)
53
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`)
54
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`)
55
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`)
56
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`)
57
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`)
58
+ if (usage.contextTokens && usage.contextTokens > 0) {
59
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`)
60
+ }
61
+ if (model) parts.push(model)
62
+ return parts.join(' ')
63
+ }
64
+
65
+ function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: Theme['fg']): string {
66
+ const shortenPath = (p: string) => {
67
+ const home = os.homedir()
68
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p
69
+ }
70
+
71
+ switch (toolName) {
72
+ case 'bash': {
73
+ const command = (args.command as string) || '...'
74
+ const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command
75
+ return themeFg('muted', '$ ') + themeFg('toolOutput', preview)
76
+ }
77
+ case 'read': {
78
+ const rawPath = (args.file_path || args.path || '...') as string
79
+ const filePath = shortenPath(rawPath)
80
+ const offset = args.offset as number | undefined
81
+ const limit = args.limit as number | undefined
82
+ let text = themeFg('accent', filePath)
83
+ if (offset !== undefined || limit !== undefined) {
84
+ const startLine = offset ?? 1
85
+ const endLine = limit !== undefined ? startLine + limit - 1 : ''
86
+ text += themeFg('warning', `:${startLine}${endLine ? `-${endLine}` : ''}`)
87
+ }
88
+ return themeFg('muted', 'read ') + text
89
+ }
90
+ case 'write': {
91
+ const rawPath = (args.file_path || args.path || '...') as string
92
+ const filePath = shortenPath(rawPath)
93
+ const content = (args.content || '') as string
94
+ const lines = content.split('\n').length
95
+ let text = themeFg('muted', 'write ') + themeFg('accent', filePath)
96
+ if (lines > 1) text += themeFg('dim', ` (${lines} lines)`)
97
+ return text
98
+ }
99
+ case 'edit': {
100
+ const rawPath = (args.file_path || args.path || '...') as string
101
+ return themeFg('muted', 'edit ') + themeFg('accent', shortenPath(rawPath))
102
+ }
103
+ case 'ls': {
104
+ const rawPath = (args.path || '.') as string
105
+ return themeFg('muted', 'ls ') + themeFg('accent', shortenPath(rawPath))
106
+ }
107
+ case 'find': {
108
+ const pattern = (args.pattern || '*') as string
109
+ const rawPath = (args.path || '.') as string
110
+ return themeFg('muted', 'find ') + themeFg('accent', pattern) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
111
+ }
112
+ case 'grep': {
113
+ const pattern = (args.pattern || '') as string
114
+ const rawPath = (args.path || '.') as string
115
+ return themeFg('muted', 'grep ') + themeFg('accent', `/${pattern}/`) + themeFg('dim', ` in ${shortenPath(rawPath)}`)
116
+ }
117
+ default: {
118
+ const argsStr = JSON.stringify(args)
119
+ const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr
120
+ return themeFg('accent', toolName) + themeFg('dim', ` ${preview}`)
121
+ }
122
+ }
123
+ }
124
+
125
+ interface UsageStats {
126
+ input: number
127
+ output: number
128
+ cacheRead: number
129
+ cacheWrite: number
130
+ cost: number
131
+ contextTokens: number
132
+ turns: number
133
+ }
134
+
135
+ interface SingleResult {
136
+ agent: string
137
+ agentSource: 'user' | 'project' | 'unknown'
138
+ task: string
139
+ exitCode: number
140
+ messages: Message[]
141
+ stderr: string
142
+ usage: UsageStats
143
+ model?: string
144
+ stopReason?: string
145
+ errorMessage?: string
146
+ step?: number
147
+ }
148
+
149
+ interface SubagentDetails {
150
+ mode: 'single' | 'parallel' | 'chain'
151
+ agentScope: AgentScope
152
+ projectAgentsDir: string | null
153
+ results: SingleResult[]
154
+ }
155
+
156
+ function getFinalOutput(messages: Message[]): string {
157
+ for (let i = messages.length - 1; i >= 0; i--) {
158
+ const msg = messages[i]
159
+ if (msg.role === 'assistant') {
160
+ for (const part of msg.content) {
161
+ if (part.type === 'text') return part.text
162
+ }
163
+ }
164
+ }
165
+ return ''
166
+ }
167
+
168
+ type DisplayItem = { type: 'text'; text: string } | { type: 'toolCall'; name: string; args: Record<string, unknown> }
169
+
170
+ function getDisplayItems(messages: Message[]): DisplayItem[] {
171
+ const items: DisplayItem[] = []
172
+ for (const msg of messages) {
173
+ if (msg.role === 'assistant') {
174
+ for (const part of msg.content) {
175
+ if (part.type === 'text') items.push({ type: 'text', text: part.text })
176
+ else if (part.type === 'toolCall') items.push({ type: 'toolCall', name: part.name, args: part.arguments })
177
+ }
178
+ }
179
+ }
180
+ return items
181
+ }
182
+
183
+ async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
184
+ if (items.length === 0) return []
185
+ const limit = Math.max(1, Math.min(concurrency, items.length))
186
+ const results: TOut[] = new Array(items.length)
187
+ let nextIndex = 0
188
+ const workers = new Array(limit).fill(null).map(async () => {
189
+ while (true) {
190
+ const current = nextIndex++
191
+ if (current >= items.length) return
192
+ results[current] = await fn(items[current], current)
193
+ }
194
+ })
195
+ await Promise.all(workers)
196
+ return results
197
+ }
198
+
199
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
200
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pi-subagent-'))
201
+ const safeName = agentName.replace(/[^\w.-]+/g, '_')
202
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`)
203
+ await withFileMutationQueue(filePath, async () => {
204
+ await fs.promises.writeFile(filePath, prompt, { encoding: 'utf-8', mode: 0o600 })
205
+ })
206
+ return { dir: tmpDir, filePath }
207
+ }
208
+
209
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
210
+ const currentScript = process.argv[1]
211
+ const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/')
212
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
213
+ return { command: process.execPath, args: [currentScript, ...args] }
214
+ }
215
+
216
+ const execName = path.basename(process.execPath).toLowerCase()
217
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName)
218
+ if (!isGenericRuntime) {
219
+ return { command: process.execPath, args }
220
+ }
221
+
222
+ return { command: 'pi', args }
223
+ }
224
+
225
+ type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void
226
+
227
+ async function runSingleAgent(defaultCwd: string, agents: AgentConfig[], agentName: string, task: string, cwd: string | undefined, step: number | undefined, signal: AbortSignal | undefined, onUpdate: OnUpdateCallback | undefined, makeDetails: (results: SingleResult[]) => SubagentDetails): Promise<SingleResult> {
228
+ const agent = agents.find((a) => a.name === agentName)
229
+
230
+ if (!agent) {
231
+ const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
232
+ return {
233
+ agent: agentName,
234
+ agentSource: 'unknown',
235
+ task,
236
+ exitCode: 1,
237
+ messages: [],
238
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
239
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
240
+ step,
241
+ }
242
+ }
243
+
244
+ const args: string[] = ['--mode', 'json', '-p', '--no-session']
245
+ if (agent.model) args.push('--model', agent.model)
246
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
247
+
248
+ let tmpPromptDir: string | null = null
249
+ let tmpPromptPath: string | null = null
250
+
251
+ const currentResult: SingleResult = {
252
+ agent: agentName,
253
+ agentSource: agent.source,
254
+ task,
255
+ exitCode: 0,
256
+ messages: [],
257
+ stderr: '',
258
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
259
+ model: agent.model,
260
+ step,
261
+ }
262
+
263
+ const emitUpdate = () => {
264
+ if (onUpdate) {
265
+ onUpdate({
266
+ content: [{ type: 'text', text: getFinalOutput(currentResult.messages) || '(running...)' }],
267
+ details: makeDetails([currentResult]),
268
+ })
269
+ }
270
+ }
271
+
272
+ try {
273
+ if (agent.systemPrompt.trim()) {
274
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt)
275
+ tmpPromptDir = tmp.dir
276
+ tmpPromptPath = tmp.filePath
277
+ args.push('--append-system-prompt', tmpPromptPath)
278
+ }
279
+
280
+ args.push(`Task: ${task}`)
281
+ let wasAborted = false
282
+
283
+ const exitCode = await new Promise<number>((resolve) => {
284
+ const invocation = getPiInvocation(args)
285
+ const proc = spawn(invocation.command, invocation.args, {
286
+ cwd: cwd ?? defaultCwd,
287
+ shell: false,
288
+ stdio: ['ignore', 'pipe', 'pipe'],
289
+ })
290
+ let buffer = ''
291
+
292
+ const processLine = (line: string) => {
293
+ if (!line.trim()) return
294
+ let event: { type?: string; message?: unknown }
295
+ try {
296
+ event = JSON.parse(line)
297
+ } catch {
298
+ return
299
+ }
300
+
301
+ if (event.type === 'message_end' && event.message) {
302
+ const msg = event.message as Message
303
+ currentResult.messages.push(msg)
304
+
305
+ if (msg.role === 'assistant') {
306
+ currentResult.usage.turns++
307
+ const usage = msg.usage
308
+ if (usage) {
309
+ currentResult.usage.input += usage.input || 0
310
+ currentResult.usage.output += usage.output || 0
311
+ currentResult.usage.cacheRead += usage.cacheRead || 0
312
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0
313
+ currentResult.usage.cost += usage.cost?.total || 0
314
+ currentResult.usage.contextTokens = usage.totalTokens || 0
315
+ }
316
+ if (!currentResult.model && msg.model) currentResult.model = msg.model
317
+ if (msg.stopReason) currentResult.stopReason = msg.stopReason
318
+ if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage
319
+ }
320
+ emitUpdate()
321
+ }
322
+
323
+ if (event.type === 'tool_result_end' && event.message) {
324
+ currentResult.messages.push(event.message as Message)
325
+ emitUpdate()
326
+ }
327
+ }
328
+
329
+ let killTimer: ReturnType<typeof setTimeout> | undefined
330
+ let onAbort: (() => void) | undefined
331
+ const cleanup = () => {
332
+ if (killTimer) clearTimeout(killTimer)
333
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
334
+ }
335
+
336
+ proc.stdout.on('data', (data) => {
337
+ buffer += data.toString()
338
+ const lines = buffer.split('\n')
339
+ buffer = lines.pop() || ''
340
+ for (const line of lines) processLine(line)
341
+ })
342
+ proc.stdout.on('error', () => {})
343
+
344
+ proc.stderr.on('data', (data) => {
345
+ currentResult.stderr += data.toString()
346
+ })
347
+ proc.stderr.on('error', () => {})
348
+
349
+ proc.on('close', (code) => {
350
+ cleanup()
351
+ if (buffer.trim()) processLine(buffer)
352
+ resolve(code ?? 0)
353
+ })
354
+
355
+ proc.on('error', () => {
356
+ cleanup()
357
+ resolve(1)
358
+ })
359
+
360
+ if (signal) {
361
+ onAbort = () => {
362
+ wasAborted = true
363
+ proc.kill('SIGTERM')
364
+ // proc.killed only reports that the signal was sent, not that the child died. Escalate
365
+ // on a timer that the 'close' handler clears once the child has actually exited.
366
+ killTimer = setTimeout(() => {
367
+ try {
368
+ proc.kill('SIGKILL')
369
+ } catch {
370
+ /* already gone */
371
+ }
372
+ }, 5000)
373
+ }
374
+ if (signal.aborted) onAbort()
375
+ else signal.addEventListener('abort', onAbort, { once: true })
376
+ }
377
+ })
378
+
379
+ currentResult.exitCode = exitCode
380
+ if (wasAborted) throw new Error('Subagent was aborted')
381
+ return currentResult
382
+ } finally {
383
+ if (tmpPromptPath)
384
+ try {
385
+ fs.unlinkSync(tmpPromptPath)
386
+ } catch {
387
+ /* ignore */
388
+ }
389
+ if (tmpPromptDir)
390
+ try {
391
+ fs.rmdirSync(tmpPromptDir)
392
+ } catch {
393
+ /* ignore */
394
+ }
395
+ }
396
+ }
397
+
398
+ const TaskItem = Type.Object({
399
+ agent: Type.String({ description: 'Name of the agent to invoke' }),
400
+ task: Type.String({ description: 'Task to delegate to the agent' }),
401
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
402
+ })
403
+
404
+ const ChainItem = Type.Object({
405
+ agent: Type.String({ description: 'Name of the agent to invoke' }),
406
+ task: Type.String({ description: 'Task with optional {previous} placeholder for prior output' }),
407
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
408
+ })
409
+
410
+ const AgentScopeSchema = StringEnum(['user', 'project', 'both'] as const, {
411
+ description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
412
+ default: 'user',
413
+ })
414
+
415
+ const SubagentParams = Type.Object({
416
+ agent: Type.Optional(Type.String({ description: 'Name of the agent to invoke (for single mode)' })),
417
+ task: Type.Optional(Type.String({ description: 'Task to delegate (for single mode)' })),
418
+ tasks: Type.Optional(Type.Array(TaskItem, { description: 'Array of {agent, task} for parallel execution' })),
419
+ chain: Type.Optional(Type.Array(ChainItem, { description: 'Array of {agent, task} for sequential execution' })),
420
+ agentScope: Type.Optional(AgentScopeSchema),
421
+ confirmProjectAgents: Type.Optional(Type.Boolean({ description: 'Prompt before running project-local agents. Default: true.', default: true })),
422
+ cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process (single mode)' })),
423
+ background: Type.Optional(Type.Boolean({ description: 'Run the single-mode task in the background: returns a run id immediately and a notification arrives when it completes.' })),
424
+ status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
425
+ })
426
+
427
+ /**
428
+ * Decide how to gate project-scoped agents, whose system prompt and tools are
429
+ * repo-controlled. Untrusted projects require interactive confirmation, and are
430
+ * refused when headless; trusted projects run unless confirmProjectAgents asks
431
+ * for a prompt anyway.
432
+ */
433
+ export function projectAgentGate(projectAgentCount: number, trusted: boolean, hasUI: boolean, confirmProjectAgents: boolean): 'allow' | 'confirm' | 'refuse' {
434
+ if (projectAgentCount === 0) return 'allow'
435
+ if (trusted && !confirmProjectAgents) return 'allow'
436
+ if (hasUI) return 'confirm'
437
+ return trusted ? 'allow' : 'refuse'
438
+ }
439
+
440
+ export default function (pi: ExtensionAPI) {
441
+ pi.registerTool({
442
+ name: 'subagent',
443
+ label: 'Subagent',
444
+ description: [
445
+ 'Delegate tasks to specialized subagents with isolated context.',
446
+ 'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
447
+ 'Single mode also supports background: true for long tasks; a notification arrives on completion and {status: true} lists runs.',
448
+ 'Default agent scope is "user" (from ~/.pi/agent/agents).',
449
+ 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
450
+ ].join(' '),
451
+ parameters: SubagentParams,
452
+
453
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
454
+ const agentScope: AgentScope = params.agentScope ?? 'user'
455
+ const discovery = discoverAgents(ctx.cwd, agentScope)
456
+ const agents = discovery.agents
457
+ const confirmProjectAgents = params.confirmProjectAgents ?? true
458
+
459
+ const hasChain = (params.chain?.length ?? 0) > 0
460
+ const hasTasks = (params.tasks?.length ?? 0) > 0
461
+ const hasSingle = Boolean(params.agent && params.task)
462
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle)
463
+
464
+ const makeDetails =
465
+ (mode: 'single' | 'parallel' | 'chain') =>
466
+ (results: SingleResult[]): SubagentDetails => ({
467
+ mode,
468
+ agentScope,
469
+ projectAgentsDir: discovery.projectAgentsDir,
470
+ results,
471
+ })
472
+
473
+ if (params.status) {
474
+ return { content: [{ type: 'text', text: backgroundStatusText() }], details: makeDetails('single')([]) }
475
+ }
476
+
477
+ if (modeCount !== 1) {
478
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(', ') || 'none'
479
+ return {
480
+ content: [
481
+ {
482
+ type: 'text',
483
+ text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
484
+ },
485
+ ],
486
+ details: makeDetails('single')([]),
487
+ }
488
+ }
489
+
490
+ // Gate repo-controlled project agents before any run (background included).
491
+ const requestedAgentNames = new Set<string>()
492
+ if (params.agent) requestedAgentNames.add(params.agent)
493
+ for (const step of params.chain ?? []) requestedAgentNames.add(step.agent)
494
+ for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
495
+ const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
496
+
497
+ const gateMode = hasChain ? 'chain' : hasTasks ? 'parallel' : 'single'
498
+ const gate = projectAgentGate(requestedProjectAgents.length, ctx.isProjectTrusted?.() ?? false, ctx.hasUI, confirmProjectAgents)
499
+ if (gate === 'refuse') {
500
+ const names = requestedProjectAgents.map((a) => a.name).join(', ')
501
+ return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
502
+ }
503
+ if (gate === 'confirm') {
504
+ const names = requestedProjectAgents.map((a) => a.name).join(', ')
505
+ const dir = discovery.projectAgentsDir ?? '(unknown)'
506
+ const ok = await ctx.ui.confirm('Run project-local agents?', `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`)
507
+ if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
508
+ }
509
+
510
+ if (params.background) {
511
+ const task = params.task
512
+ const agentName = params.agent
513
+ if (!hasSingle || !task || !agentName) {
514
+ return {
515
+ content: [{ type: 'text', text: 'background: true requires single mode (agent + task).' }],
516
+ details: makeDetails('single')([]),
517
+ }
518
+ }
519
+ const agent = agents.find((a) => a.name === agentName)
520
+ if (!agent) {
521
+ const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
522
+ return {
523
+ content: [{ type: 'text', text: `Unknown agent: "${agentName}". Available agents: ${available}.` }],
524
+ details: makeDetails('single')([]),
525
+ }
526
+ }
527
+ const args: string[] = ['--mode', 'json', '-p', '--no-session']
528
+ if (agent.model) args.push('--model', agent.model)
529
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
530
+ let tmpPrompt: { dir: string; filePath: string } | undefined
531
+ if (agent.systemPrompt.trim()) {
532
+ tmpPrompt = await writePromptToTempFile(agent.name, agent.systemPrompt)
533
+ args.push('--append-system-prompt', tmpPrompt.filePath)
534
+ }
535
+ args.push(`Task: ${task}`)
536
+ const invocation = getPiInvocation(args)
537
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? ctx.cwd }, (run) => {
538
+ if (tmpPrompt) {
539
+ try {
540
+ fs.unlinkSync(tmpPrompt.filePath)
541
+ } catch {
542
+ /* ignore */
543
+ }
544
+ try {
545
+ fs.rmdirSync(tmpPrompt.dir)
546
+ } catch {
547
+ /* ignore */
548
+ }
549
+ }
550
+ pi.sendMessage(
551
+ {
552
+ customType: 'subagent-background',
553
+ content: `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${run.output || '(no output)'}`,
554
+ display: true,
555
+ },
556
+ { triggerTurn: true },
557
+ )
558
+ })
559
+ return {
560
+ content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
561
+ details: makeDetails('single')([]),
562
+ }
563
+ }
564
+
565
+ if (params.chain && params.chain.length > 0) {
566
+ const results: SingleResult[] = []
567
+ let previousOutput = ''
568
+
569
+ for (let i = 0; i < params.chain.length; i++) {
570
+ const step = params.chain[i]
571
+ const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput)
572
+
573
+ // Create update callback that includes all previous results
574
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
575
+ ? (partial) => {
576
+ // Combine completed results with current streaming result
577
+ const currentResult = partial.details?.results[0]
578
+ if (currentResult) {
579
+ const allResults = [...results, currentResult]
580
+ onUpdate({
581
+ content: partial.content,
582
+ details: makeDetails('chain')(allResults),
583
+ })
584
+ }
585
+ }
586
+ : undefined
587
+
588
+ const result = await runSingleAgent(ctx.cwd, agents, step.agent, taskWithContext, step.cwd, i + 1, signal, chainUpdate, makeDetails('chain'))
589
+ results.push(result)
590
+
591
+ const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
592
+ if (isError) {
593
+ const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
594
+ return {
595
+ content: [{ type: 'text', text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
596
+ details: makeDetails('chain')(results),
597
+ isError: true,
598
+ }
599
+ }
600
+ previousOutput = getFinalOutput(result.messages)
601
+ }
602
+ return {
603
+ content: [{ type: 'text', text: getFinalOutput(results[results.length - 1].messages) || '(no output)' }],
604
+ details: makeDetails('chain')(results),
605
+ }
606
+ }
607
+
608
+ if (params.tasks && params.tasks.length > 0) {
609
+ if (params.tasks.length > MAX_PARALLEL_TASKS)
610
+ return {
611
+ content: [
612
+ {
613
+ type: 'text',
614
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
615
+ },
616
+ ],
617
+ details: makeDetails('parallel')([]),
618
+ }
619
+
620
+ // Track all results for streaming updates
621
+ const allResults: SingleResult[] = new Array(params.tasks.length)
622
+
623
+ // Initialize placeholder results
624
+ for (let i = 0; i < params.tasks.length; i++) {
625
+ allResults[i] = {
626
+ agent: params.tasks[i].agent,
627
+ agentSource: 'unknown',
628
+ task: params.tasks[i].task,
629
+ exitCode: -1, // -1 = still running
630
+ messages: [],
631
+ stderr: '',
632
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
633
+ }
634
+ }
635
+
636
+ const emitParallelUpdate = () => {
637
+ if (onUpdate) {
638
+ const running = allResults.filter((r) => r.exitCode === -1).length
639
+ const done = allResults.filter((r) => r.exitCode !== -1).length
640
+ onUpdate({
641
+ content: [{ type: 'text', text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
642
+ details: makeDetails('parallel')([...allResults]),
643
+ })
644
+ }
645
+ }
646
+
647
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
648
+ const result = await runSingleAgent(
649
+ ctx.cwd,
650
+ agents,
651
+ t.agent,
652
+ t.task,
653
+ t.cwd,
654
+ undefined,
655
+ signal,
656
+ // Per-task update callback
657
+ (partial) => {
658
+ if (partial.details?.results[0]) {
659
+ allResults[index] = partial.details.results[0]
660
+ emitParallelUpdate()
661
+ }
662
+ },
663
+ makeDetails('parallel'),
664
+ )
665
+ allResults[index] = result
666
+ emitParallelUpdate()
667
+ return result
668
+ })
669
+
670
+ const successCount = results.filter((r) => r.exitCode === 0).length
671
+ const summaries = results.map((r) => {
672
+ const output = getFinalOutput(r.messages)
673
+ const preview = output.slice(0, 100) + (output.length > 100 ? '...' : '')
674
+ return `[${r.agent}] ${r.exitCode === 0 ? 'completed' : 'failed'}: ${preview || '(no output)'}`
675
+ })
676
+ return {
677
+ content: [
678
+ {
679
+ type: 'text',
680
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`,
681
+ },
682
+ ],
683
+ details: makeDetails('parallel')(results),
684
+ }
685
+ }
686
+
687
+ if (params.agent && params.task) {
688
+ const result = await runSingleAgent(ctx.cwd, agents, params.agent, params.task, params.cwd, undefined, signal, onUpdate, makeDetails('single'))
689
+ const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
690
+ if (isError) {
691
+ const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
692
+ return {
693
+ content: [{ type: 'text', text: `Agent ${result.stopReason || 'failed'}: ${errorMsg}` }],
694
+ details: makeDetails('single')([result]),
695
+ isError: true,
696
+ }
697
+ }
698
+ return {
699
+ content: [{ type: 'text', text: getFinalOutput(result.messages) || '(no output)' }],
700
+ details: makeDetails('single')([result]),
701
+ }
702
+ }
703
+
704
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(', ') || 'none'
705
+ return {
706
+ content: [{ type: 'text', text: `Invalid parameters. Available agents: ${available}` }],
707
+ details: makeDetails('single')([]),
708
+ }
709
+ },
710
+
711
+ renderCall(args, theme, _context) {
712
+ const scope: AgentScope = args.agentScope ?? 'user'
713
+ if (args.chain && args.chain.length > 0) {
714
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${args.chain.length} steps)`) + theme.fg('muted', ` [${scope}]`)
715
+ for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
716
+ const step = args.chain[i]
717
+ // Clean up {previous} placeholder for display
718
+ const cleanTask = step.task.replace(/\{previous\}/g, '').trim()
719
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask
720
+ text += `\n ${theme.fg('muted', `${i + 1}.`)} ${theme.fg('accent', step.agent)}${theme.fg('dim', ` ${preview}`)}`
721
+ }
722
+ if (args.chain.length > 3) text += `\n ${theme.fg('muted', `... +${args.chain.length - 3} more`)}`
723
+ return new Text(text, 0, 0)
724
+ }
725
+ if (args.tasks && args.tasks.length > 0) {
726
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${args.tasks.length} tasks)`) + theme.fg('muted', ` [${scope}]`)
727
+ for (const t of args.tasks.slice(0, 3)) {
728
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
729
+ text += `\n ${theme.fg('accent', t.agent)}${theme.fg('dim', ` ${preview}`)}`
730
+ }
731
+ if (args.tasks.length > 3) text += `\n ${theme.fg('muted', `... +${args.tasks.length - 3} more`)}`
732
+ return new Text(text, 0, 0)
733
+ }
734
+ const agentName = args.agent || '...'
735
+ const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : '...'
736
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + theme.fg('muted', ` [${scope}]`)
737
+ text += `\n ${theme.fg('dim', preview)}`
738
+ return new Text(text, 0, 0)
739
+ },
740
+
741
+ renderResult(result, { expanded }, theme, _context) {
742
+ const details = result.details as SubagentDetails | undefined
743
+ if (!details || details.results.length === 0) {
744
+ const text = result.content[0]
745
+ return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
746
+ }
747
+
748
+ const mdTheme = getMarkdownTheme()
749
+
750
+ const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
751
+ const toShow = limit ? items.slice(-limit) : items
752
+ const skipped = limit && items.length > limit ? items.length - limit : 0
753
+ let text = ''
754
+ if (skipped > 0) text += theme.fg('muted', `... ${skipped} earlier items\n`)
755
+ for (const item of toShow) {
756
+ if (item.type === 'text') {
757
+ const preview = expanded ? item.text : item.text.split('\n').slice(0, 3).join('\n')
758
+ text += `${theme.fg('toolOutput', preview)}\n`
759
+ } else {
760
+ text += `${theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`
761
+ }
762
+ }
763
+ return text.trimEnd()
764
+ }
765
+
766
+ if (details.mode === 'single' && details.results.length === 1) {
767
+ const r = details.results[0]
768
+ const isError = r.exitCode !== 0 || r.stopReason === 'error' || r.stopReason === 'aborted'
769
+ const icon = isError ? theme.fg('error', '✗') : theme.fg('success', '✓')
770
+ const displayItems = getDisplayItems(r.messages)
771
+ const finalOutput = getFinalOutput(r.messages)
772
+
773
+ if (expanded) {
774
+ const container = new Container()
775
+ let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${theme.fg('muted', ` (${r.agentSource})`)}`
776
+ if (isError && r.stopReason) header += ` ${theme.fg('error', `[${r.stopReason}]`)}`
777
+ container.addChild(new Text(header, 0, 0))
778
+ if (isError && r.errorMessage) container.addChild(new Text(theme.fg('error', `Error: ${r.errorMessage}`), 0, 0))
779
+ container.addChild(new Spacer(1))
780
+ container.addChild(new Text(theme.fg('muted', '─── Task ───'), 0, 0))
781
+ container.addChild(new Text(theme.fg('dim', r.task), 0, 0))
782
+ container.addChild(new Spacer(1))
783
+ container.addChild(new Text(theme.fg('muted', '─── Output ───'), 0, 0))
784
+ if (displayItems.length === 0 && !finalOutput) {
785
+ container.addChild(new Text(theme.fg('muted', '(no output)'), 0, 0))
786
+ } else {
787
+ for (const item of displayItems) {
788
+ if (item.type === 'toolCall') container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
789
+ }
790
+ if (finalOutput) {
791
+ container.addChild(new Spacer(1))
792
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
793
+ }
794
+ }
795
+ const usageStr = formatUsageStats(r.usage, r.model)
796
+ if (usageStr) {
797
+ container.addChild(new Spacer(1))
798
+ container.addChild(new Text(theme.fg('dim', usageStr), 0, 0))
799
+ }
800
+ return container
801
+ }
802
+
803
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${theme.fg('muted', ` (${r.agentSource})`)}`
804
+ if (isError && r.stopReason) text += ` ${theme.fg('error', `[${r.stopReason}]`)}`
805
+ if (isError && r.errorMessage) text += `\n${theme.fg('error', `Error: ${r.errorMessage}`)}`
806
+ else if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
807
+ else {
808
+ text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`
809
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
810
+ }
811
+ const usageStr = formatUsageStats(r.usage, r.model)
812
+ if (usageStr) text += `\n${theme.fg('dim', usageStr)}`
813
+ return new Text(text, 0, 0)
814
+ }
815
+
816
+ const aggregateUsage = (results: SingleResult[]) => {
817
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }
818
+ for (const r of results) {
819
+ total.input += r.usage.input
820
+ total.output += r.usage.output
821
+ total.cacheRead += r.usage.cacheRead
822
+ total.cacheWrite += r.usage.cacheWrite
823
+ total.cost += r.usage.cost
824
+ total.turns += r.usage.turns
825
+ }
826
+ return total
827
+ }
828
+
829
+ if (details.mode === 'chain') {
830
+ const successCount = details.results.filter((r) => r.exitCode === 0).length
831
+ const icon = successCount === details.results.length ? theme.fg('success', '✓') : theme.fg('error', '✗')
832
+
833
+ if (expanded) {
834
+ const container = new Container()
835
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${theme.fg('accent', `${successCount}/${details.results.length} steps`)}`, 0, 0))
836
+
837
+ for (const r of details.results) {
838
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
839
+ const displayItems = getDisplayItems(r.messages)
840
+ const finalOutput = getFinalOutput(r.messages)
841
+
842
+ container.addChild(new Spacer(1))
843
+ container.addChild(new Text(`${theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)} ${rIcon}`, 0, 0))
844
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
845
+
846
+ // Show tool calls
847
+ for (const item of displayItems) {
848
+ if (item.type === 'toolCall') {
849
+ container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
850
+ }
851
+ }
852
+
853
+ // Show final output as markdown
854
+ if (finalOutput) {
855
+ container.addChild(new Spacer(1))
856
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
857
+ }
858
+
859
+ const stepUsage = formatUsageStats(r.usage, r.model)
860
+ if (stepUsage) container.addChild(new Text(theme.fg('dim', stepUsage), 0, 0))
861
+ }
862
+
863
+ const usageStr = formatUsageStats(aggregateUsage(details.results))
864
+ if (usageStr) {
865
+ container.addChild(new Spacer(1))
866
+ container.addChild(new Text(theme.fg('dim', `Total: ${usageStr}`), 0, 0))
867
+ }
868
+ return container
869
+ }
870
+
871
+ // Collapsed view
872
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${theme.fg('accent', `${successCount}/${details.results.length} steps`)}`
873
+ for (const r of details.results) {
874
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
875
+ const displayItems = getDisplayItems(r.messages)
876
+ text += `\n\n${theme.fg('muted', `─── Step ${r.step}: `)}${theme.fg('accent', r.agent)} ${rIcon}`
877
+ if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
878
+ else text += `\n${renderDisplayItems(displayItems, 5)}`
879
+ }
880
+ const usageStr = formatUsageStats(aggregateUsage(details.results))
881
+ if (usageStr) text += `\n\n${theme.fg('dim', `Total: ${usageStr}`)}`
882
+ text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
883
+ return new Text(text, 0, 0)
884
+ }
885
+
886
+ if (details.mode === 'parallel') {
887
+ const running = details.results.filter((r) => r.exitCode === -1).length
888
+ const successCount = details.results.filter((r) => r.exitCode === 0).length
889
+ const failCount = details.results.filter((r) => r.exitCode > 0).length
890
+ const isRunning = running > 0
891
+ const icon = isRunning ? theme.fg('warning', '⏳') : failCount > 0 ? theme.fg('warning', '◐') : theme.fg('success', '✓')
892
+ const status = isRunning ? `${successCount + failCount}/${details.results.length} done, ${running} running` : `${successCount}/${details.results.length} tasks`
893
+
894
+ if (expanded && !isRunning) {
895
+ const container = new Container()
896
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${theme.fg('accent', status)}`, 0, 0))
897
+
898
+ for (const r of details.results) {
899
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
900
+ const displayItems = getDisplayItems(r.messages)
901
+ const finalOutput = getFinalOutput(r.messages)
902
+
903
+ container.addChild(new Spacer(1))
904
+ container.addChild(new Text(`${theme.fg('muted', '─── ') + theme.fg('accent', r.agent)} ${rIcon}`, 0, 0))
905
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
906
+
907
+ // Show tool calls
908
+ for (const item of displayItems) {
909
+ if (item.type === 'toolCall') {
910
+ container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
911
+ }
912
+ }
913
+
914
+ // Show final output as markdown
915
+ if (finalOutput) {
916
+ container.addChild(new Spacer(1))
917
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
918
+ }
919
+
920
+ const taskUsage = formatUsageStats(r.usage, r.model)
921
+ if (taskUsage) container.addChild(new Text(theme.fg('dim', taskUsage), 0, 0))
922
+ }
923
+
924
+ const usageStr = formatUsageStats(aggregateUsage(details.results))
925
+ if (usageStr) {
926
+ container.addChild(new Spacer(1))
927
+ container.addChild(new Text(theme.fg('dim', `Total: ${usageStr}`), 0, 0))
928
+ }
929
+ return container
930
+ }
931
+
932
+ // Collapsed view (or still running)
933
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${theme.fg('accent', status)}`
934
+ for (const r of details.results) {
935
+ const rIcon = r.exitCode === -1 ? theme.fg('warning', '⏳') : r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
936
+ const displayItems = getDisplayItems(r.messages)
937
+ text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)} ${rIcon}`
938
+ if (displayItems.length === 0) text += `\n${theme.fg('muted', r.exitCode === -1 ? '(running...)' : '(no output)')}`
939
+ else text += `\n${renderDisplayItems(displayItems, 5)}`
940
+ }
941
+ if (!isRunning) {
942
+ const usageStr = formatUsageStats(aggregateUsage(details.results))
943
+ if (usageStr) text += `\n\n${theme.fg('dim', `Total: ${usageStr}`)}`
944
+ }
945
+ if (!expanded) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
946
+ return new Text(text, 0, 0)
947
+ }
948
+
949
+ const text = result.content[0]
950
+ return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
951
+ },
952
+ })
953
+ }