pi-code 1.0.55 → 1.0.57

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.
Files changed (42) hide show
  1. package/README.md +2 -2
  2. package/extensions/commands.ts +6 -12
  3. package/extensions/context-imports.ts +2 -10
  4. package/extensions/env-settings.ts +1 -5
  5. package/extensions/git-checkpoint.ts +4 -12
  6. package/extensions/goal.ts +2 -2
  7. package/extensions/hooks/config.ts +6 -21
  8. package/extensions/hooks/decisions.ts +3 -2
  9. package/extensions/hooks/index.ts +4 -16
  10. package/extensions/hooks/matcher.ts +2 -1
  11. package/extensions/hooks/runners.ts +10 -4
  12. package/extensions/internal/command-file.ts +7 -241
  13. package/extensions/internal/command-spans.ts +246 -0
  14. package/extensions/internal/managed-settings.ts +3 -5
  15. package/extensions/internal/plugins.ts +2 -2
  16. package/extensions/internal/settings-chain.ts +19 -0
  17. package/extensions/internal/values.ts +38 -0
  18. package/extensions/mcp/index.ts +6 -5
  19. package/extensions/mcp/listing.ts +2 -1
  20. package/extensions/mcp/oauth-flow.ts +2 -1
  21. package/extensions/mcp/policy.ts +9 -2
  22. package/extensions/memory.ts +10 -15
  23. package/extensions/output-styles.ts +4 -17
  24. package/extensions/plan-mode/index.ts +9 -9
  25. package/extensions/plan-mode/utils.ts +31 -0
  26. package/extensions/session-title.ts +2 -12
  27. package/extensions/skills.ts +6 -18
  28. package/extensions/status-line.ts +2 -8
  29. package/extensions/subagent/README.md +15 -5
  30. package/extensions/subagent/agents.ts +2 -2
  31. package/extensions/subagent/background.ts +2 -1
  32. package/extensions/subagent/child.ts +197 -0
  33. package/extensions/subagent/concurrency.ts +23 -0
  34. package/extensions/subagent/index.ts +36 -1426
  35. package/extensions/subagent/modes.ts +405 -0
  36. package/extensions/subagent/params.ts +56 -0
  37. package/extensions/subagent/registry-text.ts +105 -0
  38. package/extensions/subagent/render-result.ts +306 -0
  39. package/extensions/subagent/run.ts +375 -0
  40. package/extensions/subagent/types.ts +41 -0
  41. package/extensions/subagent/worktree.ts +2 -1
  42. package/package.json +1 -1
@@ -0,0 +1,306 @@
1
+ /**
2
+ * How a subagent call and its results are drawn in the transcript: the collapsed and
3
+ * expanded forms for a single run, a chain and a parallel batch.
4
+ *
5
+ * Split from the extension body, which was the only place these lived, so the factory
6
+ * keeps the schema, the dispatch and the session hooks; nothing here touches process
7
+ * state, and these are the only users of the tui container and markdown widgets.
8
+ */
9
+
10
+ import type { getMarkdownTheme, Theme } from '@earendil-works/pi-coding-agent'
11
+ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
12
+
13
+ import type { AgentScope } from './agents.js'
14
+ import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
15
+ import type { SingleResult } from './types.js'
16
+
17
+ const COLLAPSED_ITEM_COUNT = 10
18
+
19
+ interface CallItem {
20
+ agent: string
21
+ task: string
22
+ }
23
+
24
+ export function renderChainCall(chain: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
25
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + scopeTag(scope, theme)
26
+ for (let i = 0; i < Math.min(chain.length, 3); i++) {
27
+ const step = chain[i]
28
+ // Clean up {previous} placeholder for display
29
+ const cleanTask = step.task.replaceAll('{previous}', '').trim()
30
+ const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask
31
+ const stepNumber = theme.fg('muted', `${i + 1}.`)
32
+ const stepLabel = theme.fg('accent', step.agent) + theme.fg('dim', ` ${preview}`)
33
+ text += `\n ${stepNumber} ${stepLabel}`
34
+ }
35
+ if (chain.length > 3) {
36
+ const more = theme.fg('muted', `... +${chain.length - 3} more`)
37
+ text += `\n ${more}`
38
+ }
39
+ return new Text(text, 0, 0)
40
+ }
41
+
42
+ export function renderParallelCall(tasks: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
43
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + scopeTag(scope, theme)
44
+ for (const t of tasks.slice(0, 3)) {
45
+ const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
46
+ const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
47
+ text += `\n ${taskLabel}`
48
+ }
49
+ if (tasks.length > 3) {
50
+ const more = theme.fg('muted', `... +${tasks.length - 3} more`)
51
+ text += `\n ${more}`
52
+ }
53
+ return new Text(text, 0, 0)
54
+ }
55
+
56
+ const scopeTag = (scope: AgentScope | undefined, theme: Theme): string => (scope ? theme.fg('muted', ` [${scope}]`) : '')
57
+
58
+ export function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope | undefined, theme: Theme): Text {
59
+ const agentName = agent || '...'
60
+ let preview = '...'
61
+ if (task) preview = task.length > 60 ? `${task.slice(0, 60)}...` : task
62
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + scopeTag(scope, theme)
63
+ text += `\n ${theme.fg('dim', preview)}`
64
+ return new Text(text, 0, 0)
65
+ }
66
+
67
+ type MarkdownTheme = ReturnType<typeof getMarkdownTheme>
68
+
69
+ function aggregateUsage(results: SingleResult[]) {
70
+ const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }
71
+ for (const r of results) {
72
+ total.input += r.usage.input
73
+ total.output += r.usage.output
74
+ total.cacheRead += r.usage.cacheRead
75
+ total.cacheWrite += r.usage.cacheWrite
76
+ total.cost += r.usage.cost
77
+ total.turns += r.usage.turns
78
+ }
79
+ return total
80
+ }
81
+
82
+ function renderDisplayItems(items: DisplayItem[], expanded: boolean, theme: Theme, limit?: number): string {
83
+ const toShow = limit ? items.slice(-limit) : items
84
+ const skipped = limit && items.length > limit ? items.length - limit : 0
85
+ let text = ''
86
+ if (skipped > 0) text += theme.fg('muted', `... ${skipped} earlier items\n`)
87
+ for (const item of toShow) {
88
+ if (item.type === 'text') {
89
+ const preview = expanded ? item.text : item.text.split('\n').slice(0, 3).join('\n')
90
+ text += `${theme.fg('toolOutput', preview)}\n`
91
+ } else {
92
+ text += `${theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`
93
+ }
94
+ }
95
+ return text.trimEnd()
96
+ }
97
+
98
+ function addToolCallNodes(container: Container, items: DisplayItem[], theme: Theme): void {
99
+ for (const item of items) {
100
+ if (item.type === 'toolCall') {
101
+ container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
102
+ }
103
+ }
104
+ }
105
+
106
+ function addTotalUsage(container: Container, results: SingleResult[], theme: Theme): void {
107
+ const usageStr = formatUsageStats(aggregateUsage(results))
108
+ if (usageStr) {
109
+ container.addChild(new Spacer(1))
110
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
111
+ container.addChild(new Text(totalLine, 0, 0))
112
+ }
113
+ }
114
+
115
+ function renderSingleExpanded(r: SingleResult, isError: boolean, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
116
+ const container = new Container()
117
+ const source = theme.fg('muted', ` (${r.agentSource})`)
118
+ let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
119
+ if (isError && r.stopReason) {
120
+ const reason = theme.fg('error', `[${r.stopReason}]`)
121
+ header += ` ${reason}`
122
+ }
123
+ container.addChild(new Text(header, 0, 0))
124
+ if (isError && r.errorMessage) container.addChild(new Text(theme.fg('error', `Error: ${r.errorMessage}`), 0, 0))
125
+ container.addChild(new Spacer(1))
126
+ container.addChild(new Text(theme.fg('muted', '─── Task ───'), 0, 0))
127
+ container.addChild(new Text(theme.fg('dim', r.task), 0, 0))
128
+ container.addChild(new Spacer(1))
129
+ container.addChild(new Text(theme.fg('muted', '─── Output ───'), 0, 0))
130
+
131
+ const displayItems = getDisplayItems(r.messages)
132
+ const finalOutput = getFinalOutput(r.messages)
133
+ if (displayItems.length === 0 && !finalOutput) {
134
+ container.addChild(new Text(theme.fg('muted', '(no output)'), 0, 0))
135
+ } else {
136
+ addToolCallNodes(container, displayItems, theme)
137
+ if (finalOutput) {
138
+ container.addChild(new Spacer(1))
139
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
140
+ }
141
+ }
142
+
143
+ const usageStr = formatUsageStats(r.usage, r.model)
144
+ if (usageStr) {
145
+ container.addChild(new Spacer(1))
146
+ container.addChild(new Text(theme.fg('dim', usageStr), 0, 0))
147
+ }
148
+ return container
149
+ }
150
+
151
+ function renderSingleCollapsed(r: SingleResult, isError: boolean, icon: string, theme: Theme, expanded: boolean): Text {
152
+ const displayItems = getDisplayItems(r.messages)
153
+ const source = theme.fg('muted', ` (${r.agentSource})`)
154
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
155
+ if (isError && r.stopReason) {
156
+ const reason = theme.fg('error', `[${r.stopReason}]`)
157
+ text += ` ${reason}`
158
+ }
159
+ if (isError && r.errorMessage) {
160
+ const errorLine = theme.fg('error', `Error: ${r.errorMessage}`)
161
+ text += `\n${errorLine}`
162
+ } else if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
163
+ else {
164
+ text += `\n${renderDisplayItems(displayItems, expanded, theme, COLLAPSED_ITEM_COUNT)}`
165
+ if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
166
+ }
167
+ const usageStr = formatUsageStats(r.usage, r.model)
168
+ if (usageStr) text += `\n${theme.fg('dim', usageStr)}`
169
+ return new Text(text, 0, 0)
170
+ }
171
+
172
+ export function renderSingleResult(r: SingleResult, expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
173
+ const isError = r.exitCode !== 0 || r.stopReason === 'error' || r.stopReason === 'aborted'
174
+ const icon = isError ? theme.fg('error', '✗') : theme.fg('success', '✓')
175
+ if (expanded) return renderSingleExpanded(r, isError, icon, theme, mdTheme)
176
+ return renderSingleCollapsed(r, isError, icon, theme, expanded)
177
+ }
178
+
179
+ function renderChainExpanded(results: SingleResult[], successCount: number, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
180
+ const container = new Container()
181
+ const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
182
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`, 0, 0))
183
+
184
+ for (const r of results) {
185
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
186
+ const displayItems = getDisplayItems(r.messages)
187
+ const finalOutput = getFinalOutput(r.messages)
188
+
189
+ container.addChild(new Spacer(1))
190
+ const stepLabel = theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)
191
+ container.addChild(new Text(`${stepLabel} ${rIcon}`, 0, 0))
192
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
193
+
194
+ addToolCallNodes(container, displayItems, theme)
195
+
196
+ if (finalOutput) {
197
+ container.addChild(new Spacer(1))
198
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
199
+ }
200
+
201
+ const stepUsage = formatUsageStats(r.usage, r.model)
202
+ if (stepUsage) container.addChild(new Text(theme.fg('dim', stepUsage), 0, 0))
203
+ }
204
+
205
+ addTotalUsage(container, results, theme)
206
+ return container
207
+ }
208
+
209
+ function renderChainCollapsed(results: SingleResult[], successCount: number, icon: string, theme: Theme, expanded: boolean): Text {
210
+ const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
211
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`
212
+ for (const r of results) {
213
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
214
+ const displayItems = getDisplayItems(r.messages)
215
+ const stepLabel = theme.fg('muted', `─── Step ${r.step}: `)
216
+ text += `\n\n${stepLabel}${theme.fg('accent', r.agent)} ${rIcon}`
217
+ if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
218
+ else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
219
+ }
220
+ const usageStr = formatUsageStats(aggregateUsage(results))
221
+ if (usageStr) {
222
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
223
+ text += `\n\n${totalLine}`
224
+ }
225
+ text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
226
+ return new Text(text, 0, 0)
227
+ }
228
+
229
+ export function renderChainResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
230
+ const successCount = results.filter((r) => r.exitCode === 0).length
231
+ const icon = successCount === results.length ? theme.fg('success', '✓') : theme.fg('error', '✗')
232
+ if (expanded) return renderChainExpanded(results, successCount, icon, theme, mdTheme)
233
+ return renderChainCollapsed(results, successCount, icon, theme, expanded)
234
+ }
235
+
236
+ function renderParallelExpanded(results: SingleResult[], icon: string, status: string, theme: Theme, mdTheme: MarkdownTheme): Container {
237
+ const container = new Container()
238
+ const summary = theme.fg('accent', status)
239
+ container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`, 0, 0))
240
+
241
+ for (const r of results) {
242
+ const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
243
+ const displayItems = getDisplayItems(r.messages)
244
+ const finalOutput = getFinalOutput(r.messages)
245
+
246
+ container.addChild(new Spacer(1))
247
+ const agentLabel = theme.fg('muted', '─── ') + theme.fg('accent', r.agent)
248
+ container.addChild(new Text(`${agentLabel} ${rIcon}`, 0, 0))
249
+ container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
250
+
251
+ addToolCallNodes(container, displayItems, theme)
252
+
253
+ if (finalOutput) {
254
+ container.addChild(new Spacer(1))
255
+ container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
256
+ }
257
+
258
+ const taskUsage = formatUsageStats(r.usage, r.model)
259
+ if (taskUsage) container.addChild(new Text(theme.fg('dim', taskUsage), 0, 0))
260
+ }
261
+
262
+ addTotalUsage(container, results, theme)
263
+ return container
264
+ }
265
+
266
+ function renderParallelCollapsed(results: SingleResult[], icon: string, status: string, theme: Theme, expanded: boolean, isRunning: boolean): Text {
267
+ const summary = theme.fg('accent', status)
268
+ let text = `${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`
269
+ for (const r of results) {
270
+ let rIcon = theme.fg('error', '✗')
271
+ if (r.exitCode === -1) rIcon = theme.fg('warning', '⏳')
272
+ else if (r.exitCode === 0) rIcon = theme.fg('success', '✓')
273
+ const displayItems = getDisplayItems(r.messages)
274
+ text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)} ${rIcon}`
275
+ if (displayItems.length === 0) {
276
+ const placeholder = r.exitCode === -1 ? '(running...)' : '(no output)'
277
+ text += `\n${theme.fg('muted', placeholder)}`
278
+ } else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
279
+ }
280
+ if (!isRunning) {
281
+ const usageStr = formatUsageStats(aggregateUsage(results))
282
+ if (usageStr) {
283
+ const totalLine = theme.fg('dim', `Total: ${usageStr}`)
284
+ text += `\n\n${totalLine}`
285
+ }
286
+ }
287
+ if (!expanded) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
288
+ return new Text(text, 0, 0)
289
+ }
290
+
291
+ export function renderParallelResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
292
+ const running = results.filter((r) => r.exitCode === -1).length
293
+ const successCount = results.filter((r) => r.exitCode === 0).length
294
+ const failCount = results.filter((r) => r.exitCode > 0).length
295
+ const isRunning = running > 0
296
+
297
+ let icon = theme.fg('success', '✓')
298
+ if (isRunning) icon = theme.fg('warning', '⏳')
299
+ else if (failCount > 0) icon = theme.fg('warning', '◐')
300
+
301
+ let status = `${successCount}/${results.length} tasks`
302
+ if (isRunning) status = `${successCount + failCount}/${results.length} done, ${running} running`
303
+
304
+ if (expanded && !isRunning) return renderParallelExpanded(results, icon, status, theme, mdTheme)
305
+ return renderParallelCollapsed(results, icon, status, theme, expanded, isRunning)
306
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Running one child agent: spawn a `pi` process, parse its JSON event stream back
3
+ * into messages and usage, and tear the child down on abort or a maxTurns cap.
4
+ *
5
+ * The only place in the subagent extension that owns a process, a temp file or a
6
+ * worktree; everything about how the child was configured lives in child.ts.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import { randomUUID } from 'node:crypto'
11
+ import * as fs from 'node:fs'
12
+ import * as os from 'node:os'
13
+ import * as path from 'node:path'
14
+
15
+ import type { AgentToolResult } from '@earendil-works/pi-agent-core'
16
+ import type { Message } from '@earendil-works/pi-ai'
17
+ import { withFileMutationQueue } from '@earendil-works/pi-coding-agent'
18
+
19
+ import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
20
+ import { type AgentConfig, resolveModelAlias } from './agents.js'
21
+ import { agentHooksEnv, agentInvocationArgs, agentMemoryPromptSection, childPromptBody, taskWithStartContext, unresolvedToolsError, withMemoryTools } from './child.js'
22
+ import { getFinalOutput } from './render.js'
23
+ import type { SingleResult, SubagentDetails } from './types.js'
24
+ import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
25
+ export async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
26
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pi-subagent-'))
27
+ const safeName = agentName.replace(/[^\w.-]+/g, '_')
28
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`)
29
+ await withFileMutationQueue(filePath, async () => {
30
+ await fs.promises.writeFile(filePath, prompt, { encoding: 'utf-8', mode: 0o600 })
31
+ })
32
+ return { dir: tmpDir, filePath }
33
+ }
34
+
35
+ /** Exported as a test seam: the fallbacks only fire in packaged distributions
36
+ * (bun single-file, compiled binary), which no CI run reaches naturally. */
37
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
38
+ const currentScript = process.argv[1]
39
+ const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/')
40
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
41
+ return { command: process.execPath, args: [currentScript, ...args] }
42
+ }
43
+
44
+ const execName = path.basename(process.execPath).toLowerCase()
45
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName)
46
+ if (!isGenericRuntime) {
47
+ return { command: process.execPath, args }
48
+ }
49
+
50
+ return { command: 'pi', args }
51
+ }
52
+
53
+ export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void
54
+
55
+ type AssistantMessage = Extract<Message, { role: 'assistant' }>
56
+
57
+ function accumulateAssistantMessage(result: SingleResult, msg: AssistantMessage): void {
58
+ result.usage.turns++
59
+ const usage = msg.usage
60
+ if (usage) {
61
+ result.usage.input += usage.input || 0
62
+ result.usage.output += usage.output || 0
63
+ result.usage.cacheRead += usage.cacheRead || 0
64
+ result.usage.cacheWrite += usage.cacheWrite || 0
65
+ result.usage.cost += usage.cost?.total || 0
66
+ result.usage.contextTokens = usage.totalTokens || 0
67
+ }
68
+ if (!result.model && msg.model) result.model = msg.model
69
+ if (msg.stopReason) result.stopReason = msg.stopReason
70
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage
71
+ }
72
+
73
+ export interface RunAgentOptions {
74
+ defaultCwd: string
75
+ agents: AgentConfig[]
76
+ agentName: string
77
+ task: string
78
+ cwd?: string
79
+ step?: number
80
+ signal?: AbortSignal
81
+ onUpdate?: OnUpdateCallback
82
+ makeDetails: (results: SingleResult[]) => SubagentDetails
83
+ onPhase?: SubagentPhaseSink
84
+ /** The child's run id, set by the wrapper so the spawn env can carry it. */
85
+ agentId?: string
86
+ /** SubagentStart hook context, injected ahead of the child's first prompt. */
87
+ startContexts?: string[]
88
+ /** Skill directories to preload from, resolved where project trust is known. */
89
+ skillRoots?: string[]
90
+ /** Models this user can actually run, for resolving a tier alias. */
91
+ availableModels?: ReadonlyArray<{ id: string }>
92
+ /** Whether repo-controlled config (a project/local agent memory store) may be read. */
93
+ projectApproved?: boolean
94
+ }
95
+
96
+ /** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop.
97
+ * The stop carries the run's final assistant text, which Claude's SubagentStop
98
+ * delivers as last_assistant_message. */
99
+ export type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string, lastAssistantMessage?: string) => void
100
+
101
+ /** Append a note to the final assistant message so it rides the run's normal
102
+ * output; stderr when there is none. */
103
+ function appendResultNote(result: SingleResult, note: string): void {
104
+ for (let i = result.messages.length - 1; i >= 0; i--) {
105
+ const msg = result.messages[i]
106
+ if (msg.role === 'assistant') {
107
+ msg.content.push({ type: 'text', text: note })
108
+ return
109
+ }
110
+ }
111
+ result.stderr = result.stderr ? `${result.stderr}\n${note}` : note
112
+ }
113
+
114
+ /** Tell the parent where a kept worktree lives. */
115
+ function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
116
+ appendResultNote(result, `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`)
117
+ }
118
+
119
+ /** Claude marks a maxTurns-capped run's output as partial; the note rides the
120
+ * final assistant message like the worktree note, so the parent model sees it
121
+ * with the output. A no-op for uncapped runs. */
122
+ function appendPartialNote(result: SingleResult): void {
123
+ if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
124
+ }
125
+
126
+ export async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
127
+ const agent = options.agents.find((a) => a.name === options.agentName)
128
+ if (!agent) return runSingleAgentInner(options)
129
+ // A refused launch (Claude's zero-tools error) never starts, so no
130
+ // SubagentStart/Stop pair fires for it.
131
+ const toolsError = unresolvedToolsError(agent)
132
+ if (toolsError) {
133
+ return {
134
+ agent: agent.name,
135
+ agentSource: agent.source,
136
+ task: options.task,
137
+ exitCode: 1,
138
+ messages: [],
139
+ stderr: toolsError,
140
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
141
+ step: options.step,
142
+ }
143
+ }
144
+ const agentId = `fg-${randomUUID().slice(0, 8)}`
145
+ // SubagentStart hooks run pre-spawn through the seam so their additionalContext
146
+ // reaches the child before its first prompt.
147
+ const startContexts = await runSubagentStartHooks(agent.name, agentId)
148
+ options.onPhase?.('start', agent.name, agentId)
149
+ let result: SingleResult | undefined
150
+ try {
151
+ result = await runSingleAgentInner({ ...options, agentId, startContexts })
152
+ return result
153
+ } finally {
154
+ options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
155
+ }
156
+ }
157
+
158
+ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResult> {
159
+ const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
160
+ const agent = agents.find((a) => a.name === agentName)
161
+
162
+ if (!agent) {
163
+ const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
164
+ return {
165
+ agent: agentName,
166
+ agentSource: 'unknown',
167
+ task,
168
+ exitCode: 1,
169
+ messages: [],
170
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
171
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
172
+ step,
173
+ }
174
+ }
175
+
176
+ const runCwd = cwd ?? defaultCwd
177
+ // Claude's isolation: worktree gives the child an isolated copy of the repository.
178
+ // A boundary that cannot be created fails the run: running against the real
179
+ // checkout would silently drop the isolation the agent declared.
180
+ let worktree: AgentWorktree | undefined
181
+ if (agent.isolation === 'worktree') {
182
+ const created = await createAgentWorktree(runCwd, agent.name)
183
+ if ('error' in created) {
184
+ return {
185
+ agent: agentName,
186
+ agentSource: agent.source,
187
+ task,
188
+ exitCode: 1,
189
+ messages: [],
190
+ stderr: `isolation: worktree could not be created: ${created.error}`,
191
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
192
+ step,
193
+ }
194
+ }
195
+ worktree = created
196
+ }
197
+ // Project/local memory is anchored at the SESSION project (defaultCwd), not the
198
+ // model-supplied runCwd: projectApproved gates the session's repo, so anchoring the
199
+ // store on a different (possibly unapproved) cwd would inject that repo's memory as
200
+ // trusted. User-scope memory ignores cwd, so this is safe for it too.
201
+ const memorySection = agentMemoryPromptSection(agent, defaultCwd, options.projectApproved ?? false)
202
+ // A memory-enabled child must be able to manage its store files even when the
203
+ // agent pins a tools allowlist.
204
+ const invocationAgent = memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent
205
+ const args = agentInvocationArgs(invocationAgent, resolveModelAlias(agent.modelAlias, options.availableModels ?? []))
206
+
207
+ let tmpPromptDir: string | null = null
208
+ let tmpPromptPath: string | null = null
209
+
210
+ const currentResult: SingleResult = {
211
+ agent: agentName,
212
+ agentSource: agent.source,
213
+ task,
214
+ exitCode: 0,
215
+ messages: [],
216
+ stderr: '',
217
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
218
+ model: agent.model,
219
+ step,
220
+ }
221
+
222
+ const emitUpdate = () => {
223
+ if (onUpdate) {
224
+ onUpdate({
225
+ content: [{ type: 'text', text: getFinalOutput(currentResult.messages) || '(running...)' }],
226
+ details: makeDetails([currentResult]),
227
+ })
228
+ }
229
+ }
230
+
231
+ try {
232
+ const promptBody = childPromptBody(agent, options.skillRoots ?? [], memorySection)
233
+ if (promptBody.trim()) {
234
+ const tmp = await writePromptToTempFile(agent.name, promptBody)
235
+ tmpPromptDir = tmp.dir
236
+ tmpPromptPath = tmp.filePath
237
+ // Claude: the agent body IS the subagent's system prompt, replacing the
238
+ // default, not an addition to it (--system-prompt reads a file path too).
239
+ args.push('--system-prompt', tmpPromptPath)
240
+ }
241
+
242
+ args.push(taskWithStartContext(task, options.startContexts ?? []))
243
+ let wasAborted = false
244
+
245
+ const exitCode = await new Promise<number>((resolve) => {
246
+ const invocation = getPiInvocation(args)
247
+ const proc = spawn(invocation.command, invocation.args, {
248
+ cwd: worktree?.dir ?? runCwd,
249
+ shell: false,
250
+ stdio: ['ignore', 'pipe', 'pipe'],
251
+ // Its own group, so an abort reaches grandchildren too: killing only the
252
+ // direct child orphans a build or dev server the agent started.
253
+ detached: true,
254
+ // The marker lets the child's subagent tool refuse to nest further.
255
+ env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
256
+ })
257
+ let buffer = ''
258
+ let assistantTurns = 0
259
+
260
+ const processLine = (line: string) => {
261
+ if (!line.trim()) return
262
+ let event: { type?: string; message?: unknown }
263
+ try {
264
+ event = JSON.parse(line)
265
+ } catch {
266
+ return
267
+ }
268
+
269
+ if (!event.message) return
270
+
271
+ if (event.type === 'message_end') {
272
+ const msg = event.message as Message
273
+ currentResult.messages.push(msg)
274
+ if (msg.role === 'assistant') {
275
+ accumulateAssistantMessage(currentResult, msg)
276
+ assistantTurns++
277
+ // Claude's maxTurns cap: end the child at the turn boundary once it has
278
+ // produced its Nth turn, so the collected output is kept and no turn is
279
+ // cut; the returned output is marked partial, as Claude documents.
280
+ if (agent.maxTurns && assistantTurns >= agent.maxTurns) {
281
+ currentResult.partial = true
282
+ killGroup('SIGTERM')
283
+ }
284
+ }
285
+ emitUpdate()
286
+ } else if (event.type === 'tool_result_end') {
287
+ currentResult.messages.push(event.message as Message)
288
+ emitUpdate()
289
+ }
290
+ }
291
+
292
+ let killTimer: ReturnType<typeof setTimeout> | undefined
293
+ let onAbort: (() => void) | undefined
294
+ const cleanup = () => {
295
+ if (killTimer) clearTimeout(killTimer)
296
+ if (onAbort && signal) signal.removeEventListener('abort', onAbort)
297
+ }
298
+
299
+ proc.stdout.on('data', (data) => {
300
+ buffer += data.toString()
301
+ const lines = buffer.split('\n')
302
+ buffer = lines.pop() || ''
303
+ for (const line of lines) processLine(line)
304
+ })
305
+ proc.stdout.on('error', () => {})
306
+
307
+ proc.stderr.on('data', (data) => {
308
+ currentResult.stderr += data.toString()
309
+ })
310
+ proc.stderr.on('error', () => {})
311
+
312
+ proc.on('close', (code) => {
313
+ cleanup()
314
+ if (buffer.trim()) processLine(buffer)
315
+ resolve(code ?? 0)
316
+ })
317
+
318
+ proc.on('error', (error: Error) => {
319
+ cleanup()
320
+ // A child that never started leaves no stdout and no stderr, so this message is the
321
+ // only diagnostic; without it the failure reads as "(no output)".
322
+ if (!currentResult.stderr) currentResult.stderr = error.message
323
+ resolve(1)
324
+ })
325
+
326
+ const killGroup = (sig: NodeJS.Signals): void => {
327
+ try {
328
+ // A child that never spawned has no pid and no group; the direct kill is all there is.
329
+ if (proc.pid) process.kill(-proc.pid, sig)
330
+ else proc.kill(sig)
331
+ } catch {
332
+ try {
333
+ proc.kill(sig)
334
+ } catch {
335
+ /* already gone */
336
+ }
337
+ }
338
+ }
339
+ if (signal) {
340
+ onAbort = () => {
341
+ wasAborted = true
342
+ killGroup('SIGTERM')
343
+ // proc.killed only reports that the signal was sent, not that the child died. Escalate
344
+ // on a timer that the 'close' handler clears once the child has actually exited.
345
+ killTimer = setTimeout(() => killGroup('SIGKILL'), 5000)
346
+ }
347
+ if (signal.aborted) onAbort()
348
+ else signal.addEventListener('abort', onAbort, { once: true })
349
+ }
350
+ })
351
+
352
+ currentResult.exitCode = exitCode
353
+ if (wasAborted) throw new Error('Subagent was aborted')
354
+ appendPartialNote(currentResult)
355
+ return currentResult
356
+ } finally {
357
+ // Cleanup runs on abort too: it only removes a pristine worktree, so an
358
+ // interrupted agent's changes always survive.
359
+ if (worktree && (await cleanupAgentWorktree(runCwd, worktree)) === 'kept') {
360
+ appendWorktreeNote(currentResult, worktree)
361
+ }
362
+ if (tmpPromptPath)
363
+ try {
364
+ fs.unlinkSync(tmpPromptPath)
365
+ } catch {
366
+ /* ignore */
367
+ }
368
+ if (tmpPromptDir)
369
+ try {
370
+ fs.rmdirSync(tmpPromptDir)
371
+ } catch {
372
+ /* ignore */
373
+ }
374
+ }
375
+ }