pi-code 1.0.56 → 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.
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +2 -10
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +4 -3
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +12 -2
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- 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
|
+
}
|