pi-code 0.1.0 → 0.2.1

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.
@@ -19,9 +19,10 @@ import * as path from 'node:path'
19
19
  import type { AgentToolResult } from '@earendil-works/pi-agent-core'
20
20
  import type { Message } from '@earendil-works/pi-ai'
21
21
  import { StringEnum } from '@earendil-works/pi-ai'
22
- import { type ExtensionAPI, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
22
+ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
23
23
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
24
- import { Type } from 'typebox'
24
+ import { type Static, Type } from 'typebox'
25
+ import { isProjectApproved } from '../project-approval.js'
25
26
  import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
26
27
  import { backgroundStatusText, startBackgroundRun } from './background.js'
27
28
 
@@ -29,14 +30,14 @@ const MAX_PARALLEL_TASKS = 8
29
30
  const MAX_CONCURRENCY = 4
30
31
  const COLLAPSED_ITEM_COUNT = 10
31
32
 
32
- function formatTokens(count: number): string {
33
+ export function formatTokens(count: number): string {
33
34
  if (count < 1000) return count.toString()
34
35
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`
35
36
  if (count < 1000000) return `${Math.round(count / 1000)}k`
36
37
  return `${(count / 1000000).toFixed(1)}M`
37
38
  }
38
39
 
39
- function formatUsageStats(
40
+ export function formatUsageStats(
40
41
  usage: {
41
42
  input: number
42
43
  output: number
@@ -62,7 +63,7 @@ function formatUsageStats(
62
63
  return parts.join(' ')
63
64
  }
64
65
 
65
- function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: Theme['fg']): string {
66
+ export function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg: Theme['fg']): string {
66
67
  const shortenPath = (p: string) => {
67
68
  const home = os.homedir()
68
69
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p
@@ -83,7 +84,8 @@ function formatToolCall(toolName: string, args: Record<string, unknown>, themeFg
83
84
  if (offset !== undefined || limit !== undefined) {
84
85
  const startLine = offset ?? 1
85
86
  const endLine = limit !== undefined ? startLine + limit - 1 : ''
86
- text += themeFg('warning', `:${startLine}${endLine ? `-${endLine}` : ''}`)
87
+ const rangeSuffix = endLine ? `-${endLine}` : ''
88
+ text += themeFg('warning', `:${startLine}${rangeSuffix}`)
87
89
  }
88
90
  return themeFg('muted', 'read ') + text
89
91
  }
@@ -153,7 +155,7 @@ interface SubagentDetails {
153
155
  results: SingleResult[]
154
156
  }
155
157
 
156
- function getFinalOutput(messages: Message[]): string {
158
+ export function getFinalOutput(messages: Message[]): string {
157
159
  for (let i = messages.length - 1; i >= 0; i--) {
158
160
  const msg = messages[i]
159
161
  if (msg.role === 'assistant') {
@@ -167,7 +169,7 @@ function getFinalOutput(messages: Message[]): string {
167
169
 
168
170
  type DisplayItem = { type: 'text'; text: string } | { type: 'toolCall'; name: string; args: Record<string, unknown> }
169
171
 
170
- function getDisplayItems(messages: Message[]): DisplayItem[] {
172
+ export function getDisplayItems(messages: Message[]): DisplayItem[] {
171
173
  const items: DisplayItem[] = []
172
174
  for (const msg of messages) {
173
175
  if (msg.role === 'assistant') {
@@ -180,7 +182,7 @@ function getDisplayItems(messages: Message[]): DisplayItem[] {
180
182
  return items
181
183
  }
182
184
 
183
- async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
185
+ export async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
184
186
  if (items.length === 0) return []
185
187
  const limit = Math.max(1, Math.min(concurrency, items.length))
186
188
  const results: TOut[] = new Array(items.length)
@@ -224,7 +226,38 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
224
226
 
225
227
  type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void
226
228
 
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> {
229
+ type AssistantMessage = Extract<Message, { role: 'assistant' }>
230
+
231
+ function accumulateAssistantMessage(result: SingleResult, msg: AssistantMessage): void {
232
+ result.usage.turns++
233
+ const usage = msg.usage
234
+ if (usage) {
235
+ result.usage.input += usage.input || 0
236
+ result.usage.output += usage.output || 0
237
+ result.usage.cacheRead += usage.cacheRead || 0
238
+ result.usage.cacheWrite += usage.cacheWrite || 0
239
+ result.usage.cost += usage.cost?.total || 0
240
+ result.usage.contextTokens = usage.totalTokens || 0
241
+ }
242
+ if (!result.model && msg.model) result.model = msg.model
243
+ if (msg.stopReason) result.stopReason = msg.stopReason
244
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage
245
+ }
246
+
247
+ interface RunAgentOptions {
248
+ defaultCwd: string
249
+ agents: AgentConfig[]
250
+ agentName: string
251
+ task: string
252
+ cwd?: string
253
+ step?: number
254
+ signal?: AbortSignal
255
+ onUpdate?: OnUpdateCallback
256
+ makeDetails: (results: SingleResult[]) => SubagentDetails
257
+ }
258
+
259
+ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
260
+ const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
228
261
  const agent = agents.find((a) => a.name === agentName)
229
262
 
230
263
  if (!agent) {
@@ -298,29 +331,14 @@ async function runSingleAgent(defaultCwd: string, agents: AgentConfig[], agentNa
298
331
  return
299
332
  }
300
333
 
301
- if (event.type === 'message_end' && event.message) {
334
+ if (!event.message) return
335
+
336
+ if (event.type === 'message_end') {
302
337
  const msg = event.message as Message
303
338
  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
- }
339
+ if (msg.role === 'assistant') accumulateAssistantMessage(currentResult, msg)
320
340
  emitUpdate()
321
- }
322
-
323
- if (event.type === 'tool_result_end' && event.message) {
341
+ } else if (event.type === 'tool_result_end') {
324
342
  currentResult.messages.push(event.message as Message)
325
343
  emitUpdate()
326
344
  }
@@ -437,7 +455,554 @@ export function projectAgentGate(projectAgentCount: number, trusted: boolean, ha
437
455
  return trusted ? 'allow' : 'refuse'
438
456
  }
439
457
 
440
- export default function (pi: ExtensionAPI) {
458
+ /**
459
+ * pi only sets a tool result's error flag when execute() throws; a returned isError is
460
+ * ignored (docs/extensions.md, "Signaling errors"). Throwing here would be worse: the
461
+ * agent loop replaces the result with createErrorToolResult(message), discarding the
462
+ * details renderResult needs to show the failed agent's transcript. The failure is
463
+ * carried in the content text instead, which is what reaches the model.
464
+ */
465
+ type ToolResult = AgentToolResult<SubagentDetails>
466
+ type SubagentMode = 'single' | 'parallel' | 'chain'
467
+ type MakeDetails = (mode: SubagentMode) => (results: SingleResult[]) => SubagentDetails
468
+ type SubagentParamsStatic = Static<typeof SubagentParams>
469
+ type ChainStepParam = Static<typeof ChainItem>
470
+ type TaskItemParam = Static<typeof TaskItem>
471
+
472
+ /** Everything a mode handler needs from the surrounding execute() call. */
473
+ interface ModeContext {
474
+ agents: AgentConfig[]
475
+ defaultCwd: string
476
+ signal: AbortSignal | undefined
477
+ onUpdate: OnUpdateCallback | undefined
478
+ makeDetails: MakeDetails
479
+ }
480
+
481
+ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
482
+ const requestedAgentNames = new Set<string>()
483
+ if (params.agent) requestedAgentNames.add(params.agent)
484
+ for (const step of params.chain ?? []) requestedAgentNames.add(step.agent)
485
+ for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
486
+ const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
487
+
488
+ // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
489
+ const approved = await isProjectApproved(ctx)
490
+ const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
491
+ const names = requestedProjectAgents.map((a) => a.name).join(', ')
492
+ if (gate === 'refuse') {
493
+ return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
494
+ }
495
+ if (gate === 'confirm') {
496
+ const dir = projectAgentsDir ?? '(unknown)'
497
+ 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.`)
498
+ if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
499
+ }
500
+ return null
501
+ }
502
+
503
+ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails): Promise<ToolResult> {
504
+ const task = params.task
505
+ const agentName = params.agent
506
+ if (!task || !agentName) {
507
+ return {
508
+ content: [{ type: 'text', text: 'background: true requires single mode (agent + task).' }],
509
+ details: makeDetails('single')([]),
510
+ }
511
+ }
512
+ const agent = agents.find((a) => a.name === agentName)
513
+ if (!agent) {
514
+ const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
515
+ return {
516
+ content: [{ type: 'text', text: `Unknown agent: "${agentName}". Available agents: ${available}.` }],
517
+ details: makeDetails('single')([]),
518
+ }
519
+ }
520
+ const args: string[] = ['--mode', 'json', '-p', '--no-session']
521
+ if (agent.model) args.push('--model', agent.model)
522
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
523
+ let tmpPrompt: { dir: string; filePath: string } | undefined
524
+ if (agent.systemPrompt.trim()) {
525
+ tmpPrompt = await writePromptToTempFile(agent.name, agent.systemPrompt)
526
+ args.push('--append-system-prompt', tmpPrompt.filePath)
527
+ }
528
+ args.push(`Task: ${task}`)
529
+ const invocation = getPiInvocation(args)
530
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
531
+ if (tmpPrompt) {
532
+ try {
533
+ fs.unlinkSync(tmpPrompt.filePath)
534
+ } catch {
535
+ /* ignore */
536
+ }
537
+ try {
538
+ fs.rmdirSync(tmpPrompt.dir)
539
+ } catch {
540
+ /* ignore */
541
+ }
542
+ }
543
+ const output = run.output || '(no output)'
544
+ pi.sendMessage(
545
+ {
546
+ customType: 'subagent-background',
547
+ content: `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}`,
548
+ display: true,
549
+ },
550
+ { triggerTurn: true },
551
+ )
552
+ })
553
+ return {
554
+ content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
555
+ details: makeDetails('single')([]),
556
+ }
557
+ }
558
+
559
+ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise<ToolResult> {
560
+ const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
561
+ const results: SingleResult[] = []
562
+ let previousOutput = ''
563
+
564
+ for (let i = 0; i < chain.length; i++) {
565
+ const step = chain[i]
566
+ const taskWithContext = step.task.replaceAll('{previous}', previousOutput)
567
+
568
+ // Create update callback that includes all previous results
569
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
570
+ ? (partial) => {
571
+ // Combine completed results with current streaming result
572
+ const currentResult = partial.details?.results[0]
573
+ if (currentResult) {
574
+ const allResults = [...results, currentResult]
575
+ onUpdate({
576
+ content: partial.content,
577
+ details: makeDetails('chain')(allResults),
578
+ })
579
+ }
580
+ }
581
+ : undefined
582
+
583
+ const result = await runSingleAgent({
584
+ defaultCwd,
585
+ agents,
586
+ agentName: step.agent,
587
+ task: taskWithContext,
588
+ cwd: step.cwd,
589
+ step: i + 1,
590
+ signal,
591
+ onUpdate: chainUpdate,
592
+ makeDetails: makeDetails('chain'),
593
+ })
594
+ results.push(result)
595
+
596
+ const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
597
+ if (isError) {
598
+ const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
599
+ return {
600
+ content: [{ type: 'text', text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
601
+ details: makeDetails('chain')(results),
602
+ }
603
+ }
604
+ previousOutput = getFinalOutput(result.messages)
605
+ }
606
+ return {
607
+ content: [{ type: 'text', text: getFinalOutput(results.at(-1)?.messages ?? []) || '(no output)' }],
608
+ details: makeDetails('chain')(results),
609
+ }
610
+ }
611
+
612
+ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promise<ToolResult> {
613
+ const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
614
+ if (tasks.length > MAX_PARALLEL_TASKS)
615
+ return {
616
+ content: [
617
+ {
618
+ type: 'text',
619
+ text: `Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
620
+ },
621
+ ],
622
+ details: makeDetails('parallel')([]),
623
+ }
624
+
625
+ // Track all results for streaming updates
626
+ const allResults: SingleResult[] = new Array(tasks.length)
627
+
628
+ // Initialize placeholder results
629
+ for (let i = 0; i < tasks.length; i++) {
630
+ allResults[i] = {
631
+ agent: tasks[i].agent,
632
+ agentSource: 'unknown',
633
+ task: tasks[i].task,
634
+ exitCode: -1, // -1 = still running
635
+ messages: [],
636
+ stderr: '',
637
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
638
+ }
639
+ }
640
+
641
+ const emitParallelUpdate = () => {
642
+ if (onUpdate) {
643
+ const running = allResults.filter((r) => r.exitCode === -1).length
644
+ const done = allResults.filter((r) => r.exitCode !== -1).length
645
+ onUpdate({
646
+ content: [{ type: 'text', text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
647
+ details: makeDetails('parallel')([...allResults]),
648
+ })
649
+ }
650
+ }
651
+
652
+ const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
653
+ const result = await runSingleAgent({
654
+ defaultCwd,
655
+ agents,
656
+ agentName: t.agent,
657
+ task: t.task,
658
+ cwd: t.cwd,
659
+ signal,
660
+ // Per-task update callback
661
+ onUpdate: (partial) => {
662
+ if (partial.details?.results[0]) {
663
+ allResults[index] = partial.details.results[0]
664
+ emitParallelUpdate()
665
+ }
666
+ },
667
+ makeDetails: makeDetails('parallel'),
668
+ })
669
+ allResults[index] = result
670
+ emitParallelUpdate()
671
+ return result
672
+ })
673
+
674
+ const successCount = results.filter((r) => r.exitCode === 0).length
675
+ const summaries = results.map((r) => {
676
+ const output = getFinalOutput(r.messages)
677
+ const preview = output.slice(0, 100) + (output.length > 100 ? '...' : '')
678
+ const status = r.exitCode === 0 ? 'completed' : 'failed'
679
+ return `[${r.agent}] ${status}: ${preview || '(no output)'}`
680
+ })
681
+ return {
682
+ content: [
683
+ {
684
+ type: 'text',
685
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`,
686
+ },
687
+ ],
688
+ details: makeDetails('parallel')(results),
689
+ }
690
+ }
691
+
692
+ async function runSingleMode(agentName: string, task: string, cwd: string | undefined, mode: ModeContext): Promise<ToolResult> {
693
+ const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
694
+ const result = await runSingleAgent({
695
+ defaultCwd,
696
+ agents,
697
+ agentName,
698
+ task,
699
+ cwd,
700
+ signal,
701
+ onUpdate,
702
+ makeDetails: makeDetails('single'),
703
+ })
704
+ const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
705
+ if (isError) {
706
+ const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
707
+ return {
708
+ content: [{ type: 'text', text: `Agent ${result.stopReason || 'failed'}: ${errorMsg}` }],
709
+ details: makeDetails('single')([result]),
710
+ }
711
+ }
712
+ return {
713
+ content: [{ type: 'text', text: getFinalOutput(result.messages) || '(no output)' }],
714
+ details: makeDetails('single')([result]),
715
+ }
716
+ }
717
+
718
+ interface CallItem {
719
+ agent: string
720
+ task: string
721
+ }
722
+
723
+ function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Text {
724
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + theme.fg('muted', ` [${scope}]`)
725
+ for (let i = 0; i < Math.min(chain.length, 3); i++) {
726
+ const step = chain[i]
727
+ // Clean up {previous} placeholder for display
728
+ const cleanTask = step.task.replaceAll('{previous}', '').trim()
729
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask
730
+ const stepNumber = theme.fg('muted', `${i + 1}.`)
731
+ const stepLabel = theme.fg('accent', step.agent) + theme.fg('dim', ` ${preview}`)
732
+ text += `\n ${stepNumber} ${stepLabel}`
733
+ }
734
+ if (chain.length > 3) {
735
+ const more = theme.fg('muted', `... +${chain.length - 3} more`)
736
+ text += `\n ${more}`
737
+ }
738
+ return new Text(text, 0, 0)
739
+ }
740
+
741
+ function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme): Text {
742
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + theme.fg('muted', ` [${scope}]`)
743
+ for (const t of tasks.slice(0, 3)) {
744
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
745
+ const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
746
+ text += `\n ${taskLabel}`
747
+ }
748
+ if (tasks.length > 3) {
749
+ const more = theme.fg('muted', `... +${tasks.length - 3} more`)
750
+ text += `\n ${more}`
751
+ }
752
+ return new Text(text, 0, 0)
753
+ }
754
+
755
+ function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope, theme: Theme): Text {
756
+ const agentName = agent || '...'
757
+ let preview = '...'
758
+ if (task) preview = task.length > 60 ? `${task.slice(0, 60)}...` : task
759
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + theme.fg('muted', ` [${scope}]`)
760
+ text += `\n ${theme.fg('dim', preview)}`
761
+ return new Text(text, 0, 0)
762
+ }
763
+
764
+ type MarkdownTheme = ReturnType<typeof getMarkdownTheme>
765
+
766
+ function aggregateUsage(results: SingleResult[]) {
767
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }
768
+ for (const r of results) {
769
+ total.input += r.usage.input
770
+ total.output += r.usage.output
771
+ total.cacheRead += r.usage.cacheRead
772
+ total.cacheWrite += r.usage.cacheWrite
773
+ total.cost += r.usage.cost
774
+ total.turns += r.usage.turns
775
+ }
776
+ return total
777
+ }
778
+
779
+ function renderDisplayItems(items: DisplayItem[], expanded: boolean, theme: Theme, limit?: number): string {
780
+ const toShow = limit ? items.slice(-limit) : items
781
+ const skipped = limit && items.length > limit ? items.length - limit : 0
782
+ let text = ''
783
+ if (skipped > 0) text += theme.fg('muted', `... ${skipped} earlier items\n`)
784
+ for (const item of toShow) {
785
+ if (item.type === 'text') {
786
+ const preview = expanded ? item.text : item.text.split('\n').slice(0, 3).join('\n')
787
+ text += `${theme.fg('toolOutput', preview)}\n`
788
+ } else {
789
+ text += `${theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`
790
+ }
791
+ }
792
+ return text.trimEnd()
793
+ }
794
+
795
+ function addToolCallNodes(container: Container, items: DisplayItem[], theme: Theme): void {
796
+ for (const item of items) {
797
+ if (item.type === 'toolCall') {
798
+ container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
799
+ }
800
+ }
801
+ }
802
+
803
+ function addTotalUsage(container: Container, results: SingleResult[], theme: Theme): void {
804
+ const usageStr = formatUsageStats(aggregateUsage(results))
805
+ if (usageStr) {
806
+ container.addChild(new Spacer(1))
807
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
808
+ container.addChild(new Text(totalLine, 0, 0))
809
+ }
810
+ }
811
+
812
+ function renderSingleExpanded(r: SingleResult, isError: boolean, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
813
+ const container = new Container()
814
+ const source = theme.fg('muted', ` (${r.agentSource})`)
815
+ let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
816
+ if (isError && r.stopReason) {
817
+ const reason = theme.fg('error', `[${r.stopReason}]`)
818
+ header += ` ${reason}`
819
+ }
820
+ container.addChild(new Text(header, 0, 0))
821
+ if (isError && r.errorMessage) container.addChild(new Text(theme.fg('error', `Error: ${r.errorMessage}`), 0, 0))
822
+ container.addChild(new Spacer(1))
823
+ container.addChild(new Text(theme.fg('muted', '─── Task ───'), 0, 0))
824
+ container.addChild(new Text(theme.fg('dim', r.task), 0, 0))
825
+ container.addChild(new Spacer(1))
826
+ container.addChild(new Text(theme.fg('muted', '─── Output ───'), 0, 0))
827
+
828
+ const displayItems = getDisplayItems(r.messages)
829
+ const finalOutput = getFinalOutput(r.messages)
830
+ if (displayItems.length === 0 && !finalOutput) {
831
+ container.addChild(new Text(theme.fg('muted', '(no output)'), 0, 0))
832
+ } else {
833
+ addToolCallNodes(container, displayItems, theme)
834
+ if (finalOutput) {
835
+ container.addChild(new Spacer(1))
836
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
837
+ }
838
+ }
839
+
840
+ const usageStr = formatUsageStats(r.usage, r.model)
841
+ if (usageStr) {
842
+ container.addChild(new Spacer(1))
843
+ container.addChild(new Text(theme.fg('dim', usageStr), 0, 0))
844
+ }
845
+ return container
846
+ }
847
+
848
+ function renderSingleCollapsed(r: SingleResult, isError: boolean, icon: string, theme: Theme, expanded: boolean): Text {
849
+ const displayItems = getDisplayItems(r.messages)
850
+ const source = theme.fg('muted', ` (${r.agentSource})`)
851
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
852
+ if (isError && r.stopReason) {
853
+ const reason = theme.fg('error', `[${r.stopReason}]`)
854
+ text += ` ${reason}`
855
+ }
856
+ if (isError && r.errorMessage) {
857
+ const errorLine = theme.fg('error', `Error: ${r.errorMessage}`)
858
+ text += `\n${errorLine}`
859
+ } else if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
860
+ else {
861
+ text += `\n${renderDisplayItems(displayItems, expanded, theme, COLLAPSED_ITEM_COUNT)}`
862
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
863
+ }
864
+ const usageStr = formatUsageStats(r.usage, r.model)
865
+ if (usageStr) text += `\n${theme.fg('dim', usageStr)}`
866
+ return new Text(text, 0, 0)
867
+ }
868
+
869
+ function renderSingleResult(r: SingleResult, expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
870
+ const isError = r.exitCode !== 0 || r.stopReason === 'error' || r.stopReason === 'aborted'
871
+ const icon = isError ? theme.fg('error', '✗') : theme.fg('success', '✓')
872
+ if (expanded) return renderSingleExpanded(r, isError, icon, theme, mdTheme)
873
+ return renderSingleCollapsed(r, isError, icon, theme, expanded)
874
+ }
875
+
876
+ function renderChainExpanded(results: SingleResult[], successCount: number, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
877
+ const container = new Container()
878
+ const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
879
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`, 0, 0))
880
+
881
+ for (const r of results) {
882
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
883
+ const displayItems = getDisplayItems(r.messages)
884
+ const finalOutput = getFinalOutput(r.messages)
885
+
886
+ container.addChild(new Spacer(1))
887
+ const stepLabel = theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)
888
+ container.addChild(new Text(`${stepLabel} ${rIcon}`, 0, 0))
889
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
890
+
891
+ addToolCallNodes(container, displayItems, theme)
892
+
893
+ if (finalOutput) {
894
+ container.addChild(new Spacer(1))
895
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
896
+ }
897
+
898
+ const stepUsage = formatUsageStats(r.usage, r.model)
899
+ if (stepUsage) container.addChild(new Text(theme.fg('dim', stepUsage), 0, 0))
900
+ }
901
+
902
+ addTotalUsage(container, results, theme)
903
+ return container
904
+ }
905
+
906
+ function renderChainCollapsed(results: SingleResult[], successCount: number, icon: string, theme: Theme, expanded: boolean): Text {
907
+ const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
908
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`
909
+ for (const r of results) {
910
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
911
+ const displayItems = getDisplayItems(r.messages)
912
+ const stepLabel = theme.fg('muted', `─── Step ${r.step}: `)
913
+ text += `\n\n${stepLabel}${theme.fg('accent', r.agent)} ${rIcon}`
914
+ if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
915
+ else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
916
+ }
917
+ const usageStr = formatUsageStats(aggregateUsage(results))
918
+ if (usageStr) {
919
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
920
+ text += `\n\n${totalLine}`
921
+ }
922
+ text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
923
+ return new Text(text, 0, 0)
924
+ }
925
+
926
+ function renderChainResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
927
+ const successCount = results.filter((r) => r.exitCode === 0).length
928
+ const icon = successCount === results.length ? theme.fg('success', '✓') : theme.fg('error', '✗')
929
+ if (expanded) return renderChainExpanded(results, successCount, icon, theme, mdTheme)
930
+ return renderChainCollapsed(results, successCount, icon, theme, expanded)
931
+ }
932
+
933
+ function renderParallelExpanded(results: SingleResult[], icon: string, status: string, theme: Theme, mdTheme: MarkdownTheme): Container {
934
+ const container = new Container()
935
+ const summary = theme.fg('accent', status)
936
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`, 0, 0))
937
+
938
+ for (const r of results) {
939
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
940
+ const displayItems = getDisplayItems(r.messages)
941
+ const finalOutput = getFinalOutput(r.messages)
942
+
943
+ container.addChild(new Spacer(1))
944
+ const agentLabel = theme.fg('muted', '─── ') + theme.fg('accent', r.agent)
945
+ container.addChild(new Text(`${agentLabel} ${rIcon}`, 0, 0))
946
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
947
+
948
+ addToolCallNodes(container, displayItems, theme)
949
+
950
+ if (finalOutput) {
951
+ container.addChild(new Spacer(1))
952
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
953
+ }
954
+
955
+ const taskUsage = formatUsageStats(r.usage, r.model)
956
+ if (taskUsage) container.addChild(new Text(theme.fg('dim', taskUsage), 0, 0))
957
+ }
958
+
959
+ addTotalUsage(container, results, theme)
960
+ return container
961
+ }
962
+
963
+ function renderParallelCollapsed(results: SingleResult[], icon: string, status: string, theme: Theme, expanded: boolean, isRunning: boolean): Text {
964
+ const summary = theme.fg('accent', status)
965
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`
966
+ for (const r of results) {
967
+ let rIcon = theme.fg('error', '✗')
968
+ if (r.exitCode === -1) rIcon = theme.fg('warning', '⏳')
969
+ else if (r.exitCode === 0) rIcon = theme.fg('success', '✓')
970
+ const displayItems = getDisplayItems(r.messages)
971
+ text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)} ${rIcon}`
972
+ if (displayItems.length === 0) {
973
+ const placeholder = r.exitCode === -1 ? '(running...)' : '(no output)'
974
+ text += `\n${theme.fg('muted', placeholder)}`
975
+ } else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
976
+ }
977
+ if (!isRunning) {
978
+ const usageStr = formatUsageStats(aggregateUsage(results))
979
+ if (usageStr) {
980
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
981
+ text += `\n\n${totalLine}`
982
+ }
983
+ }
984
+ if (!expanded) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
985
+ return new Text(text, 0, 0)
986
+ }
987
+
988
+ function renderParallelResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
989
+ const running = results.filter((r) => r.exitCode === -1).length
990
+ const successCount = results.filter((r) => r.exitCode === 0).length
991
+ const failCount = results.filter((r) => r.exitCode > 0).length
992
+ const isRunning = running > 0
993
+
994
+ let icon = theme.fg('success', '✓')
995
+ if (isRunning) icon = theme.fg('warning', '⏳')
996
+ else if (failCount > 0) icon = theme.fg('warning', '◐')
997
+
998
+ let status = `${successCount}/${results.length} tasks`
999
+ if (isRunning) status = `${successCount + failCount}/${results.length} done, ${running} running`
1000
+
1001
+ if (expanded && !isRunning) return renderParallelExpanded(results, icon, status, theme, mdTheme)
1002
+ return renderParallelCollapsed(results, icon, status, theme, expanded, isRunning)
1003
+ }
1004
+
1005
+ export default function subagentExtension(pi: ExtensionAPI) {
441
1006
  pi.registerTool({
442
1007
  name: 'subagent',
443
1008
  label: 'Subagent',
@@ -454,7 +1019,6 @@ export default function (pi: ExtensionAPI) {
454
1019
  const agentScope: AgentScope = params.agentScope ?? 'user'
455
1020
  const discovery = discoverAgents(ctx.cwd, agentScope)
456
1021
  const agents = discovery.agents
457
- const confirmProjectAgents = params.confirmProjectAgents ?? true
458
1022
 
459
1023
  const hasChain = (params.chain?.length ?? 0) > 0
460
1024
  const hasTasks = (params.tasks?.length ?? 0) > 0
@@ -488,218 +1052,19 @@ export default function (pi: ExtensionAPI) {
488
1052
  }
489
1053
 
490
1054
  // 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
- }
1055
+ let gateMode: SubagentMode = 'single'
1056
+ if (hasChain) gateMode = 'chain'
1057
+ else if (hasTasks) gateMode = 'parallel'
1058
+ const gateResult = await checkProjectAgentGate(params, agents, ctx, discovery.projectAgentsDir, gateMode, makeDetails)
1059
+ if (gateResult) return gateResult
509
1060
 
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
- }
1061
+ if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails)
564
1062
 
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
- }
1063
+ const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails }
607
1064
 
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
- }
1065
+ if (params.chain?.length) return runChainMode(params.chain, mode)
1066
+ if (params.tasks?.length) return runParallelMode(params.tasks, mode)
1067
+ if (params.agent && params.task) return runSingleMode(params.agent, params.task, params.cwd, mode)
703
1068
 
704
1069
  const available = agents.map((a) => `${a.name} (${a.source})`).join(', ') || 'none'
705
1070
  return {
@@ -710,32 +1075,9 @@ export default function (pi: ExtensionAPI) {
710
1075
 
711
1076
  renderCall(args, theme, _context) {
712
1077
  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)
1078
+ if (args.chain && args.chain.length > 0) return renderChainCall(args.chain, scope, theme)
1079
+ if (args.tasks && args.tasks.length > 0) return renderParallelCall(args.tasks, scope, theme)
1080
+ return renderSingleCall(args.agent, args.task, scope, theme)
739
1081
  },
740
1082
 
741
1083
  renderResult(result, { expanded }, theme, _context) {
@@ -747,204 +1089,9 @@ export default function (pi: ExtensionAPI) {
747
1089
 
748
1090
  const mdTheme = getMarkdownTheme()
749
1091
 
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
- }
1092
+ if (details.mode === 'single' && details.results.length === 1) return renderSingleResult(details.results[0], expanded, theme, mdTheme)
1093
+ if (details.mode === 'chain') return renderChainResult(details.results, expanded, theme, mdTheme)
1094
+ if (details.mode === 'parallel') return renderParallelResult(details.results, expanded, theme, mdTheme)
948
1095
 
949
1096
  const text = result.content[0]
950
1097
  return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)