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