@leing2021/super-pi 0.23.9 → 0.23.10

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,10 +19,12 @@ import { createContextHandoffTool } from "./tools/context-handoff"
19
19
  import { filterBashOutput } from "./tools/bash-output-filter"
20
20
  import { filterReadOutput } from "./tools/read-output-filter"
21
21
  import { COMPACTION_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
22
- import { AsyncMutex } from "./tools/async-mutex"
23
- import type { SubagentExecOptions, SubagentRunner } from "./tools/subagent"
22
+ import { createJsonRunner, type JsonRunConfig } from "./tools/subagent-json-runner"
23
+ import { getFinalOutput, isFailedResult, type SingleResult } from "./tools/subagent-events"
24
+ import { renderSubagentCall, renderSubagentResult } from "./tools/subagent-renderer"
25
+ import type { SubagentLiveRunner, SubagentLiveExecOptions, SubagentLiveDetails } from "./tools/subagent"
26
+ import type { ParallelSubagentLiveDetails } from "./tools/parallel-subagent"
24
27
 
25
- const _subagentEnvMutex = new AsyncMutex()
26
28
  const PIPELINE_STAGE_KEYS = new Set([
27
29
  "01-brainstorm",
28
30
  "02-plan",
@@ -115,43 +117,30 @@ function parseModelRef(
115
117
  }
116
118
 
117
119
  /**
118
- * Create a subagent runner that handles env injection with mutex protection
119
- * for concurrency safety when multiple parallel subagents share the process.
120
+ * Create a spawn-based subagent runner that uses pi --mode json
121
+ * with per-process env (no global process.env mutation).
120
122
  */
121
123
  function createSubagentRunner(
122
124
  pi: ExtensionAPI,
123
125
  signal?: AbortSignal,
124
- ): SubagentRunner {
125
- return async (prompt: string, options?: SubagentExecOptions) => {
126
- const args = ["--no-session", ...(options?.extraFlags ?? []), "-p", prompt]
127
- const release = await _subagentEnvMutex.acquire()
128
- const savedEnv: Record<string, string | undefined> = {}
129
- const extraEnv = options?.extraEnv ?? {}
130
- for (const [key, value] of Object.entries(extraEnv)) {
131
- savedEnv[key] = process.env[key]
132
- process.env[key] = value
126
+ ): SubagentLiveRunner {
127
+ const jsonRunner = createJsonRunner()
128
+ return async (prompt: string, options?: SubagentLiveExecOptions) => {
129
+ const config: JsonRunConfig = {
130
+ prompt,
131
+ agent: "",
132
+ task: "",
133
+ cwd: pi.cwd ?? process.cwd(),
134
+ extraFlags: options?.extraFlags,
135
+ extraEnv: options?.extraEnv,
136
+ signal,
137
+ onUpdate: options?.onUpdate,
133
138
  }
134
- try {
135
- const execResult = await pi.exec("pi", args, {
136
- signal,
137
- timeout: 10 * 60 * 1000,
138
- })
139
-
140
- if (execResult.code !== 0) {
141
- throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
142
- }
143
-
144
- return (execResult.stdout || "").trim()
145
- } finally {
146
- for (const [key, oldValue] of Object.entries(savedEnv)) {
147
- if (oldValue === undefined) {
148
- delete process.env[key]
149
- } else {
150
- process.env[key] = oldValue
151
- }
152
- }
153
- release()
139
+ const result = await jsonRunner.run(config)
140
+ if (isFailedResult(result) || result.exitCode !== 0) {
141
+ throw new Error(result.errorMessage || result.stderr || `Subagent failed for prompt: ${prompt}`)
154
142
  }
143
+ return result
155
144
  }
156
145
  }
157
146
 
@@ -505,7 +494,7 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
505
494
  label: "CE Subagent",
506
495
  description: "Run a single CE skill-based subagent or a serial chain in an isolated Pi process.",
507
496
  parameters: subagentParams,
508
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
497
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
509
498
  const result = await subagent.execute(
510
499
  {
511
500
  agent: params.agent,
@@ -514,13 +503,26 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
514
503
  inheritSkills: params.inheritSkills,
515
504
  },
516
505
  createSubagentRunner(pi, signal),
506
+ { onUpdate },
517
507
  )
518
508
 
509
+ // Build final content from results
510
+ const lastResult = result.results[result.results.length - 1]
511
+ const finalText = lastResult ? getFinalOutput(lastResult.messages) : ""
512
+ const isError = lastResult ? isFailedResult(lastResult) : false
513
+
519
514
  return {
520
- content: [{ type: "text", text: result.outputs[result.outputs.length - 1] ?? "" }],
515
+ content: [{ type: "text", text: finalText || "(no output)" }],
521
516
  details: result,
517
+ isError: isError || undefined,
522
518
  }
523
519
  },
520
+ renderCall(args, theme, _context) {
521
+ return renderSubagentCall(args, theme)
522
+ },
523
+ renderResult(result, renderContext, theme, _context) {
524
+ return renderSubagentResult(result.details as SubagentLiveDetails, renderContext, theme)
525
+ },
524
526
  })
525
527
 
526
528
  pi.registerTool({
@@ -599,7 +601,7 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
599
601
  label: "CE Parallel Subagent",
600
602
  description: "Run multiple CE skill-based subagent tasks concurrently. IMPORTANT: Provide 'tasks' as a clean JSON array object. If the environment forces a string, provide a valid JSON array string.",
601
603
  parameters: parallelSubagentParams,
602
- async execute(_toolCallId, params, signal) {
604
+ async execute(_toolCallId, params, signal, onUpdate) {
603
605
  let tasks: any[]
604
606
  if (typeof params.tasks === "string") {
605
607
  try {
@@ -618,13 +620,32 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
618
620
  inheritSkills: params.inheritSkills,
619
621
  },
620
622
  createSubagentRunner(pi, signal),
623
+ { onUpdate },
621
624
  )
622
625
 
626
+ // Build summary content
627
+ const successCount = result.results.filter(r => r.exitCode === 0).length
628
+ const failCount = result.results.filter(r => isFailedResult(r)).length
629
+ const summaries = result.results.map(r => {
630
+ const output = getFinalOutput(r.messages) || r.errorMessage || r.stderr || "(no output)"
631
+ const status = isFailedResult(r) ? "failed" : "completed"
632
+ return `### [${r.agent}] ${status}\n\n${output}`
633
+ })
634
+
623
635
  return {
624
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
636
+ content: [{
637
+ type: "text",
638
+ text: `Parallel: ${successCount}/${result.results.length} succeeded${failCount > 0 ? `, ${failCount} failed` : ""}\n\n${summaries.join("\n\n---\n\n")}`,
639
+ }],
625
640
  details: result,
626
641
  }
627
642
  },
643
+ renderCall(args, theme, _context) {
644
+ return renderSubagentCall(args, theme)
645
+ },
646
+ renderResult(result, renderContext, theme, _context) {
647
+ return renderSubagentResult(result.details as SubagentLiveDetails, renderContext, theme)
648
+ },
628
649
  })
629
650
 
630
651
  pi.registerTool({
@@ -873,6 +894,24 @@ export { createAskUserQuestionTool } from "./tools/ask-user-question"
873
894
  export { createSubagentTool } from "./tools/subagent"
874
895
  export { checkSubagentDepth, getChildDepthEnv, DEFAULT_MAX_SUBAGENT_DEPTH } from "./tools/subagent-depth-guard"
875
896
  export { AsyncMutex } from "./tools/async-mutex"
897
+ export { createJsonRunner } from "./tools/subagent-json-runner"
898
+ export {
899
+ parseJsonEvent,
900
+ applyEventToResult,
901
+ type SingleResult as SingleResultType,
902
+ type UsageStats,
903
+ type ParsedEvent,
904
+ isFailedResult as isFailedSingleResult,
905
+ formatUsageStats,
906
+ makeInitialResult,
907
+ getFinalOutput as getFinalOutputFromMessages,
908
+ getDisplayItems,
909
+ } from "./tools/subagent-events"
910
+ export {
911
+ formatToolCall,
912
+ renderSubagentCall,
913
+ renderSubagentResult,
914
+ } from "./tools/subagent-renderer"
876
915
  export { createWorkflowStateTool } from "./tools/workflow-state"
877
916
  export { createWorktreeManagerTool } from "./tools/worktree-manager"
878
917
  export { createReviewRouterTool } from "./tools/review-router"
@@ -4,13 +4,63 @@
4
4
  * Features:
5
5
  * - Recursion depth guard (PI_SUBAGENT_DEPTH / PI_SUBAGENT_MAX_DEPTH)
6
6
  * - Optional context slimming via --no-skills flag
7
+ * - Concurrency limit via mapWithConcurrencyLimit (from pi official example)
8
+ * - Live TUI updates with running placeholders and per-task status
9
+ * - Promise.allSettled semantics: one failure does NOT cancel others
7
10
  */
8
11
 
9
12
  import {
10
13
  checkSubagentDepth,
11
14
  getChildDepthEnv,
12
15
  } from "./subagent-depth-guard"
13
- import { assertNonPipelineStageSkill, type SubagentExecOptions, type SubagentRunner } from "./subagent"
16
+ import {
17
+ type SingleResult,
18
+ makeInitialResult,
19
+ isFailedResult,
20
+ getFinalOutput,
21
+ makeFailedResult,
22
+ invokeRunner,
23
+ } from "./subagent-events"
24
+ import {
25
+ assertNonPipelineStageSkill,
26
+ type SubagentLiveRunner,
27
+ type SubagentLiveExecOptions,
28
+ } from "./subagent"
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants (from pi official example)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const MAX_PARALLEL_TASKS = 8
35
+ const MAX_CONCURRENCY = 4
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // mapWithConcurrencyLimit (directly from pi official subagent example)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ async function mapWithConcurrencyLimit<TIn, TOut>(
42
+ items: TIn[],
43
+ concurrency: number,
44
+ fn: (item: TIn, index: number) => Promise<TOut>,
45
+ ): Promise<TOut[]> {
46
+ if (items.length === 0) return []
47
+ const limit = Math.max(1, Math.min(concurrency, items.length))
48
+ const results: TOut[] = new Array(items.length)
49
+ let nextIndex = 0
50
+ const workers = new Array(limit).fill(null).map(async () => {
51
+ while (true) {
52
+ const current = nextIndex++
53
+ if (current >= items.length) return
54
+ results[current] = await fn(items[current], current)
55
+ }
56
+ })
57
+ await Promise.all(workers)
58
+ return results
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public types
63
+ // ---------------------------------------------------------------------------
14
64
 
15
65
  export interface ParallelSubagentTask {
16
66
  agent: string
@@ -23,25 +73,24 @@ export interface ParallelSubagentInput {
23
73
  inheritSkills?: boolean
24
74
  }
25
75
 
26
- export interface ParallelResultItem {
27
- status: "fulfilled" | "rejected"
28
- value?: string
29
- reason?: string
30
- }
31
-
32
- export interface ParallelSubagentResult {
76
+ export interface ParallelSubagentLiveDetails {
33
77
  mode: "parallel"
34
- outputs: ParallelResultItem[]
78
+ results: SingleResult[]
35
79
  }
36
80
 
37
- function buildPrompt(agent: string, task: string): string {
38
- return `/skill:${agent} ${task}`
39
- }
81
+ // ---------------------------------------------------------------------------
82
+ // Tool factory
83
+ // ---------------------------------------------------------------------------
40
84
 
41
85
  export function createParallelSubagentTool() {
42
86
  return {
43
- name: "ce_parallel_subagent",
44
- async execute(input: ParallelSubagentInput, runner: SubagentRunner): Promise<ParallelSubagentResult> {
87
+ name: "ce_parallel_subagent" as const,
88
+
89
+ async execute(
90
+ input: ParallelSubagentInput,
91
+ runner: SubagentLiveRunner,
92
+ toolCtx?: { onUpdate?: (details: ParallelSubagentLiveDetails) => void },
93
+ ): Promise<ParallelSubagentLiveDetails> {
45
94
  // Recursion depth guard
46
95
  const depthCheck = checkSubagentDepth()
47
96
  if (!depthCheck.allowed) {
@@ -52,49 +101,73 @@ export function createParallelSubagentTool() {
52
101
  throw new Error("ce_parallel_subagent requires at least one task")
53
102
  }
54
103
 
104
+ if (input.tasks.length > MAX_PARALLEL_TASKS) {
105
+ throw new Error(
106
+ `Too many parallel tasks (${input.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
107
+ )
108
+ }
109
+
55
110
  for (const task of input.tasks) {
56
111
  assertNonPipelineStageSkill(task.agent, "ce_parallel_subagent")
57
112
  }
58
113
 
59
- // Default: parallel workers get a slim context (no inherited skills)
60
- const inheritSkills = input.inheritSkills ?? false
61
- const execOptions = buildParallelExecOptions(inheritSkills)
114
+ const execOptions = buildParallelExecOptions(input.inheritSkills)
62
115
 
63
- const promises = input.tasks.map(({ agent, task }) =>
64
- runner(buildPrompt(agent, task), execOptions),
116
+ // Initialize all results as running placeholders
117
+ const allResults: SingleResult[] = input.tasks.map(({ agent, task }) =>
118
+ makeInitialResult(agent, task),
65
119
  )
66
120
 
67
- const settled = await Promise.allSettled(promises)
121
+ // Emit initial state (all running)
122
+ emitParallelUpdate(allResults, toolCtx)
123
+
124
+ // Run tasks with concurrency limit
125
+ await mapWithConcurrencyLimit(input.tasks, MAX_CONCURRENCY, async (t, index) => {
126
+ const liveOptions: SubagentLiveExecOptions = {
127
+ ...execOptions,
128
+ onUpdate: (partial: SingleResult) => {
129
+ allResults[index] = partial
130
+ emitParallelUpdate(allResults, toolCtx)
131
+ },
132
+ }
68
133
 
69
- const outputs: ParallelResultItem[] = settled.map((result) => {
70
- if (result.status === "fulfilled") {
71
- return { status: "fulfilled", value: result.value }
134
+ try {
135
+ const result = await invokeRunner(runner, `/skill:${t.agent} ${t.task}`, liveOptions)
136
+ allResults[index] = result
137
+ } catch (e) {
138
+ // Preserve allSettled semantics: one failure does not cancel others
139
+ allResults[index] = makeFailedResult(t.agent, t.task, e instanceof Error ? e.message : String(e))
72
140
  }
73
- return { status: "rejected", reason: result.reason?.message || String(result.reason) }
141
+ emitParallelUpdate(allResults, toolCtx)
142
+ return allResults[index]
74
143
  })
75
144
 
76
- return {
77
- mode: "parallel",
78
- outputs,
79
- }
145
+ return { mode: "parallel", results: allResults }
80
146
  },
81
147
  }
82
148
  }
83
149
 
84
- /**
85
- * Build exec options for parallel subagent workers.
86
- * Defaults to slim context (no inherited skills).
87
- */
88
- function buildParallelExecOptions(inheritSkills: boolean): SubagentExecOptions {
150
+ // ---------------------------------------------------------------------------
151
+ // Helpers
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function emitParallelUpdate(
155
+ results: SingleResult[],
156
+ toolCtx?: { onUpdate?: (details: ParallelSubagentLiveDetails) => void },
157
+ ) {
158
+ if (toolCtx?.onUpdate) {
159
+ toolCtx.onUpdate({ mode: "parallel", results: [...results] })
160
+ }
161
+ }
162
+
163
+ function buildParallelExecOptions(inheritSkills?: boolean): { extraFlags?: string[]; extraEnv: Record<string, string> } {
89
164
  const flags: string[] = []
90
165
  const env: Record<string, string> = {}
91
166
 
92
- // Context slimming
93
- if (!inheritSkills) {
167
+ if (inheritSkills !== true) {
94
168
  flags.push("--no-skills")
95
169
  }
96
170
 
97
- // Recursion depth
98
171
  Object.assign(env, getChildDepthEnv())
99
172
 
100
173
  return {
@@ -102,3 +175,20 @@ function buildParallelExecOptions(inheritSkills: boolean): SubagentExecOptions {
102
175
  extraEnv: env,
103
176
  }
104
177
  }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Legacy backward-compat types and exports
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** @deprecated Use ParallelSubagentLiveDetails instead */
184
+ export interface ParallelResultItem {
185
+ status: "fulfilled" | "rejected"
186
+ value?: string
187
+ reason?: string
188
+ }
189
+
190
+ /** @deprecated Use ParallelSubagentLiveDetails instead */
191
+ export interface ParallelSubagentResult {
192
+ mode: "parallel"
193
+ outputs: ParallelResultItem[]
194
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Subagent event model and JSON event parser.
3
+ *
4
+ * Provides the data types and parsing logic for pi --mode json event streams,
5
+ * used by both ce_subagent and ce_parallel_subagent for live TUI updates.
6
+ *
7
+ * Adapted from pi official subagent example patterns.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface UsageStats {
15
+ input: number
16
+ output: number
17
+ cacheRead: number
18
+ cacheWrite: number
19
+ cost: number
20
+ contextTokens: number
21
+ turns: number
22
+ }
23
+
24
+ export interface SingleResult {
25
+ agent: string
26
+ task: string
27
+ exitCode: number
28
+ messages: unknown[]
29
+ stderr: string
30
+ usage: UsageStats
31
+ model?: string
32
+ stopReason?: string
33
+ errorMessage?: string
34
+ step?: number
35
+ }
36
+
37
+ export type DisplayItem =
38
+ | { type: "text"; text: string }
39
+ | { type: "toolCall"; name: string; args: Record<string, unknown> }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Parsed event types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export interface MessageEndEvent {
46
+ type: "message_end"
47
+ message: unknown
48
+ usage?: {
49
+ input?: number
50
+ output?: number
51
+ cacheRead?: number
52
+ cacheWrite?: number
53
+ cost?: { total?: number }
54
+ totalTokens?: number
55
+ }
56
+ stopReason?: string
57
+ model?: string
58
+ }
59
+
60
+ export interface ToolResultEndEvent {
61
+ type: "tool_result_end"
62
+ message: unknown
63
+ }
64
+
65
+ export type ParsedEvent = MessageEndEvent | ToolResultEndEvent
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Result helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function makeInitialResult(agent: string, task: string, step?: number): SingleResult {
72
+ return {
73
+ agent,
74
+ task,
75
+ exitCode: -1,
76
+ messages: [],
77
+ stderr: "",
78
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
79
+ step,
80
+ }
81
+ }
82
+
83
+ export function isFailedResult(result: SingleResult): boolean {
84
+ return (
85
+ (result.exitCode !== -1 && result.exitCode !== 0) ||
86
+ result.stopReason === "error" ||
87
+ result.stopReason === "aborted"
88
+ )
89
+ }
90
+
91
+ export function getFinalOutput(messages: unknown[]): string {
92
+ for (let i = messages.length - 1; i >= 0; i--) {
93
+ const msg = messages[i] as { role?: string; content?: unknown[] } | undefined
94
+ if (!msg || msg.role !== "assistant") continue
95
+ const parts = msg.content
96
+ if (!Array.isArray(parts)) continue
97
+ for (const part of parts) {
98
+ const p = part as { type?: string; text?: string } | undefined
99
+ if (p?.type === "text" && typeof p.text === "string") return p.text
100
+ }
101
+ }
102
+ return ""
103
+ }
104
+
105
+ export function getDisplayItems(messages: unknown[]): DisplayItem[] {
106
+ const items: DisplayItem[] = []
107
+ for (const msg of messages) {
108
+ const m = msg as { role?: string; content?: unknown[] } | undefined
109
+ if (!m || m.role !== "assistant") continue
110
+ const parts = m.content
111
+ if (!Array.isArray(parts)) continue
112
+ for (const part of parts) {
113
+ const p = part as { type?: string; text?: string; name?: string; arguments?: unknown } | undefined
114
+ if (!p) continue
115
+ if (p.type === "text" && typeof p.text === "string") {
116
+ items.push({ type: "text", text: p.text })
117
+ } else if (p.type === "toolCall" && typeof p.name === "string") {
118
+ items.push({ type: "toolCall", name: p.name, args: (p.arguments ?? {}) as Record<string, unknown> })
119
+ }
120
+ }
121
+ }
122
+ return items
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // JSON event parser
127
+ // ---------------------------------------------------------------------------
128
+
129
+ export function parseJsonEvent(line: string): ParsedEvent | null {
130
+ const trimmed = line.trim()
131
+ if (!trimmed) return null
132
+
133
+ let raw: unknown
134
+ try {
135
+ raw = JSON.parse(trimmed)
136
+ } catch {
137
+ return null
138
+ }
139
+
140
+ if (typeof raw !== "object" || raw === null) return null
141
+ const obj = raw as Record<string, unknown>
142
+
143
+ if (obj.type === "message_end" && obj.message) {
144
+ // pi JSON mode puts usage/stopReason/model inside message object
145
+ const msgObj = typeof obj.message === "object" && obj.message !== null
146
+ ? obj.message as Record<string, unknown>
147
+ : undefined
148
+ const rawUsage = obj.usage ?? msgObj?.usage
149
+ const rawStopReason = typeof obj.stopReason === "string"
150
+ ? obj.stopReason
151
+ : typeof msgObj?.stopReason === "string" ? (msgObj.stopReason as string) : undefined
152
+ const rawModel = typeof obj.model === "string"
153
+ ? obj.model
154
+ : typeof msgObj?.model === "string" ? (msgObj.model as string) : undefined
155
+ return {
156
+ type: "message_end",
157
+ message: obj.message,
158
+ usage: typeof rawUsage === "object" && rawUsage !== null
159
+ ? rawUsage as MessageEndEvent["usage"]
160
+ : undefined,
161
+ stopReason: rawStopReason,
162
+ model: rawModel,
163
+ }
164
+ }
165
+
166
+ if (obj.type === "tool_result_end" && obj.message) {
167
+ return {
168
+ type: "tool_result_end",
169
+ message: obj.message,
170
+ }
171
+ }
172
+
173
+ return null
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Event application
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export function applyEventToResult(result: SingleResult, event: ParsedEvent): void {
181
+ if (event.type === "message_end") {
182
+ result.messages.push(event.message)
183
+ const msg = event.message as { role?: string; errorMessage?: string } | undefined
184
+ if (msg?.role === "assistant") {
185
+ result.usage.turns++
186
+ if (event.usage) {
187
+ result.usage.input += event.usage.input ?? 0
188
+ result.usage.output += event.usage.output ?? 0
189
+ result.usage.cacheRead += event.usage.cacheRead ?? 0
190
+ result.usage.cacheWrite += event.usage.cacheWrite ?? 0
191
+ result.usage.cost += event.usage.cost?.total ?? 0
192
+ result.usage.contextTokens = event.usage.totalTokens ?? result.usage.contextTokens
193
+ }
194
+ if (event.stopReason) result.stopReason = event.stopReason
195
+ if (event.model && !result.model) result.model = event.model
196
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage
197
+ }
198
+ } else if (event.type === "tool_result_end") {
199
+ result.messages.push(event.message)
200
+ }
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Formatting helpers
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function formatTokens(count: number): string {
208
+ if (count < 1000) return count.toString()
209
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`
210
+ if (count < 1000000) return `${Math.round(count / 1000)}k`
211
+ return `${(count / 1000000).toFixed(1)}M`
212
+ }
213
+
214
+ export function formatUsageStats(
215
+ usage: UsageStats,
216
+ model?: string,
217
+ ): string {
218
+ const parts: string[] = []
219
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`)
220
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`)
221
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`)
222
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`)
223
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`)
224
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`)
225
+ if (usage.contextTokens && usage.contextTokens > 0) {
226
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`)
227
+ }
228
+ if (model) parts.push(model)
229
+ return parts.join(" ")
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Shared result helpers (used by subagent.ts and parallel-subagent.ts)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export function isSingleResult(val: unknown): val is SingleResult {
237
+ return (
238
+ typeof val === "object" &&
239
+ val !== null &&
240
+ "agent" in val &&
241
+ "exitCode" in val &&
242
+ "messages" in val &&
243
+ "usage" in val
244
+ )
245
+ }
246
+
247
+ export function makeFailedResult(agent: string, task: string, error: string): SingleResult {
248
+ return {
249
+ agent,
250
+ task,
251
+ exitCode: 1,
252
+ messages: [],
253
+ stderr: error,
254
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
255
+ errorMessage: error,
256
+ stopReason: "error",
257
+ }
258
+ }
259
+
260
+ export type AnyRunner =
261
+ | ((prompt: string, options?: any) => Promise<SingleResult>)
262
+ | ((prompt: string, options?: any) => Promise<string>)
263
+
264
+ export async function invokeRunner(
265
+ runner: AnyRunner,
266
+ prompt: string,
267
+ options: any,
268
+ ): Promise<SingleResult> {
269
+ const result = await runner(prompt, options)
270
+
271
+ if (typeof result === "string") {
272
+ return {
273
+ agent: "",
274
+ task: "",
275
+ exitCode: 0,
276
+ messages: [{ role: "assistant", content: [{ type: "text", text: result }] }],
277
+ stderr: "",
278
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
279
+ }
280
+ }
281
+
282
+ if (isSingleResult(result)) {
283
+ return result
284
+ }
285
+
286
+ return result as unknown as SingleResult
287
+ }