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.
- package/README.md +2 -2
- 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 +10 -4
- 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 +15 -5
- 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,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four ways the subagent tool runs a request: single, chain, parallel and
|
|
3
|
+
* background, plus the project-agent gate every one of them passes first.
|
|
4
|
+
*
|
|
5
|
+
* Each returns a finished tool result; the extension entry point only picks which.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import * as fs from 'node:fs'
|
|
10
|
+
import * as path from 'node:path'
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
|
|
14
|
+
import { capForContext } from '../internal/output-guard.js'
|
|
15
|
+
import { isProjectApproved } from '../internal/project-approval.js'
|
|
16
|
+
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
17
|
+
import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
18
|
+
import { type AgentConfig, resolveModelAlias } from './agents.js'
|
|
19
|
+
import { activeBackgroundRuns, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
|
|
20
|
+
import { agentHooksEnv, agentInvocationArgs, agentMemoryPromptSection, childPromptBody, taskWithStartContext, unresolvedToolsError, withMemoryTools } from './child.js'
|
|
21
|
+
import { MAX_CONCURRENCY, MAX_PARALLEL_TASKS, mapWithConcurrencyLimit } from './concurrency.js'
|
|
22
|
+
import type { ChainStepParam, MakeDetails, SubagentMode, SubagentParamsStatic, TaskItemParam, ToolResult } from './params.js'
|
|
23
|
+
import { backgroundCompletionText } from './registry-text.js'
|
|
24
|
+
import { getFinalOutput } from './render.js'
|
|
25
|
+
import { getPiInvocation, type OnUpdateCallback, runSingleAgent, type SubagentPhaseSink, writePromptToTempFile } from './run.js'
|
|
26
|
+
import type { SingleResult } from './types.js'
|
|
27
|
+
import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decide how to gate project-scoped agents, whose system prompt and tools are
|
|
31
|
+
* repo-controlled. Untrusted projects require interactive confirmation, and are
|
|
32
|
+
* refused when headless; trusted projects run unless confirmProjectAgents asks
|
|
33
|
+
* for a prompt anyway.
|
|
34
|
+
*/
|
|
35
|
+
export function projectAgentGate(projectAgentCount: number, trusted: boolean, hasUI: boolean, confirmProjectAgents: boolean): 'allow' | 'confirm' | 'refuse' {
|
|
36
|
+
if (projectAgentCount === 0) return 'allow'
|
|
37
|
+
if (trusted && !confirmProjectAgents) return 'allow'
|
|
38
|
+
if (hasUI) return 'confirm'
|
|
39
|
+
return trusted ? 'allow' : 'refuse'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Everything a mode handler needs from the surrounding execute() call. */
|
|
43
|
+
export interface ModeContext {
|
|
44
|
+
agents: AgentConfig[]
|
|
45
|
+
defaultCwd: string
|
|
46
|
+
signal: AbortSignal | undefined
|
|
47
|
+
onUpdate: OnUpdateCallback | undefined
|
|
48
|
+
makeDetails: MakeDetails
|
|
49
|
+
onPhase?: SubagentPhaseSink
|
|
50
|
+
skillRoots: string[]
|
|
51
|
+
availableModels: ReadonlyArray<{ id: string }>
|
|
52
|
+
projectApproved: boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
|
|
56
|
+
const requestedAgentNames = new Set<string>()
|
|
57
|
+
if (params.agent) requestedAgentNames.add(params.agent)
|
|
58
|
+
for (const step of params.chain ?? []) requestedAgentNames.add(step.agent)
|
|
59
|
+
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
60
|
+
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
61
|
+
|
|
62
|
+
// No project agents means nothing repo-controlled to gate; skip the approval check so a
|
|
63
|
+
// user-scope run never prompts or persists a trust decision it does not need.
|
|
64
|
+
if (requestedProjectAgents.length === 0) return null
|
|
65
|
+
|
|
66
|
+
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
67
|
+
const approved = await isProjectApproved(ctx)
|
|
68
|
+
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
69
|
+
// Agent names come from repo-controlled frontmatter; a newline in one would otherwise
|
|
70
|
+
// let it write its own "Source:" line into the prompt body.
|
|
71
|
+
const names = requestedProjectAgents.map((a) => a.name.replace(/\s+/g, ' ').trim()).join(', ')
|
|
72
|
+
if (gate === 'refuse') {
|
|
73
|
+
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
|
74
|
+
}
|
|
75
|
+
if (gate === 'confirm') {
|
|
76
|
+
// Each agent knows where it was loaded from; projectAgentsDir only ever held .pi/agents.
|
|
77
|
+
const dirs = [...new Set(requestedProjectAgents.map((a) => path.dirname(a.filePath)))]
|
|
78
|
+
const dir = dirs.join(', ') || projectAgentsDir || '(unknown)'
|
|
79
|
+
const ok = await ctx.ui.confirm('Run project-local agents?', `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`)
|
|
80
|
+
if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a run belongs in the background: the caller asked, or Claude's
|
|
86
|
+
* `background: true` frontmatter keeps the agent there even on a foreground ask
|
|
87
|
+
* (single mode). */
|
|
88
|
+
export function wantsBackground(params: { background?: boolean; agent?: string }, agents: AgentConfig[]): boolean {
|
|
89
|
+
if (params.background) return true
|
|
90
|
+
return params.agent !== undefined && agents.find((a) => a.name === params.agent)?.background === true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
96
|
+
details: makeDetails('single')([]),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefined): void {
|
|
101
|
+
if (!tmpPrompt) return
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(tmpPrompt.filePath)
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
fs.rmdirSync(tmpPrompt.dir)
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Everything runBackgroundMode needs from the surrounding execute() call, grouped so
|
|
115
|
+
* the parameter list stays in bounds. */
|
|
116
|
+
export interface BackgroundContext {
|
|
117
|
+
agents: AgentConfig[]
|
|
118
|
+
defaultCwd: string
|
|
119
|
+
pi: ExtensionAPI
|
|
120
|
+
makeDetails: MakeDetails
|
|
121
|
+
skillRoots: string[]
|
|
122
|
+
availableModels: ReadonlyArray<{ id: string }>
|
|
123
|
+
projectApproved: boolean
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
|
|
127
|
+
const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
|
|
128
|
+
const task = params.task
|
|
129
|
+
const agentName = params.agent
|
|
130
|
+
if (!task || !agentName) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: 'text', text: 'background: true requires single mode (agent + task).' }],
|
|
133
|
+
details: makeDetails('single')([]),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const agent = agents.find((a) => a.name === agentName)
|
|
137
|
+
if (!agent) {
|
|
138
|
+
const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: 'text', text: `Unknown agent: "${agentName}". Available agents: ${available}.` }],
|
|
141
|
+
details: makeDetails('single')([]),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const toolsError = unresolvedToolsError(agent)
|
|
145
|
+
if (toolsError) {
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: 'text', text: toolsError }],
|
|
148
|
+
details: makeDetails('single')([]),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
|
|
152
|
+
return backgroundCapResult(makeDetails)
|
|
153
|
+
}
|
|
154
|
+
const runCwd = params.cwd ?? defaultCwd
|
|
155
|
+
// The same isolation boundary as the foreground path: no worktree, no run.
|
|
156
|
+
let worktree: AgentWorktree | undefined
|
|
157
|
+
if (agent.isolation === 'worktree') {
|
|
158
|
+
const created = await createAgentWorktree(runCwd, agent.name)
|
|
159
|
+
if ('error' in created) {
|
|
160
|
+
return {
|
|
161
|
+
content: [{ type: 'text', text: `isolation: worktree could not be created for ${agent.name}: ${created.error}` }],
|
|
162
|
+
details: makeDetails('single')([]),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
worktree = created
|
|
166
|
+
}
|
|
167
|
+
// Anchor project/local memory at the session project (defaultCwd), which is the one
|
|
168
|
+
// projectApproved gated; see the foreground path for why runCwd must not be used.
|
|
169
|
+
const memorySection = agentMemoryPromptSection(agent, defaultCwd, projectApproved)
|
|
170
|
+
const args = agentInvocationArgs(memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent, resolveModelAlias(agent.modelAlias, availableModels))
|
|
171
|
+
let tmpPrompt: { dir: string; filePath: string } | undefined
|
|
172
|
+
const promptBody = childPromptBody(agent, skillRoots, memorySection)
|
|
173
|
+
if (promptBody.trim()) {
|
|
174
|
+
tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
|
|
175
|
+
// Claude: the agent body replaces the default system prompt (see the
|
|
176
|
+
// foreground path).
|
|
177
|
+
args.push('--system-prompt', tmpPrompt.filePath)
|
|
178
|
+
}
|
|
179
|
+
// The id is preset so SubagentStart hooks run pre-spawn with the id the run
|
|
180
|
+
// will actually carry, and their context lands before the child's first prompt.
|
|
181
|
+
const presetId = `bg-${randomUUID().slice(0, 8)}`
|
|
182
|
+
const startContexts = await runSubagentStartHooks(agent.name, presetId)
|
|
183
|
+
args.push(taskWithStartContext(task, startContexts))
|
|
184
|
+
const invocation = getPiInvocation(args)
|
|
185
|
+
const id = startBackgroundRun(
|
|
186
|
+
agent.name,
|
|
187
|
+
task,
|
|
188
|
+
{ command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, env: agentHooksEnv(agent, presetId), promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns },
|
|
189
|
+
(run) => {
|
|
190
|
+
removeTmpPrompt(tmpPrompt)
|
|
191
|
+
const finish = (): void => {
|
|
192
|
+
// Both calls throw once the session that started the run is disposed. driveRun's
|
|
193
|
+
// catch covers the synchronous path, but the worktree branch reaches here from an
|
|
194
|
+
// async continuation outside it, so the guard must live in finish itself.
|
|
195
|
+
try {
|
|
196
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
|
|
197
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
198
|
+
} catch {
|
|
199
|
+
// Session disposed after the run outlived it; nothing to notify.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!worktree) {
|
|
203
|
+
finish()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
// Cleanup only removes a pristine worktree; a kept one is reported in the
|
|
207
|
+
// completion text so the parent knows where the changes live.
|
|
208
|
+
const keptWorktree = worktree
|
|
209
|
+
void cleanupAgentWorktree(runCwd, keptWorktree)
|
|
210
|
+
.then((outcome) => {
|
|
211
|
+
if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {})
|
|
214
|
+
.finally(finish)
|
|
215
|
+
},
|
|
216
|
+
presetId,
|
|
217
|
+
)
|
|
218
|
+
if (id === null) {
|
|
219
|
+
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
220
|
+
removeTmpPrompt(tmpPrompt)
|
|
221
|
+
return backgroundCapResult(makeDetails)
|
|
222
|
+
}
|
|
223
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
226
|
+
details: makeDetails('single')([]),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise<ToolResult> {
|
|
231
|
+
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
232
|
+
const results: SingleResult[] = []
|
|
233
|
+
let previousOutput = ''
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < chain.length; i++) {
|
|
236
|
+
const step = chain[i]
|
|
237
|
+
// Function replacement: a string here would interpret $-patterns in the output.
|
|
238
|
+
const taskWithContext = step.task.replaceAll('{previous}', () => previousOutput)
|
|
239
|
+
|
|
240
|
+
// Create update callback that includes all previous results
|
|
241
|
+
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
242
|
+
? (partial) => {
|
|
243
|
+
// Combine completed results with current streaming result
|
|
244
|
+
const currentResult = partial.details?.results[0]
|
|
245
|
+
if (currentResult) {
|
|
246
|
+
const allResults = [...results, currentResult]
|
|
247
|
+
onUpdate({
|
|
248
|
+
content: partial.content,
|
|
249
|
+
details: makeDetails('chain')(allResults),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
: undefined
|
|
254
|
+
|
|
255
|
+
const result = await runSingleAgent({
|
|
256
|
+
defaultCwd,
|
|
257
|
+
agents,
|
|
258
|
+
agentName: step.agent,
|
|
259
|
+
task: taskWithContext,
|
|
260
|
+
cwd: step.cwd,
|
|
261
|
+
step: i + 1,
|
|
262
|
+
signal,
|
|
263
|
+
onUpdate: chainUpdate,
|
|
264
|
+
makeDetails: makeDetails('chain'),
|
|
265
|
+
onPhase: mode.onPhase,
|
|
266
|
+
skillRoots: mode.skillRoots,
|
|
267
|
+
availableModels: mode.availableModels,
|
|
268
|
+
projectApproved: mode.projectApproved,
|
|
269
|
+
})
|
|
270
|
+
results.push(result)
|
|
271
|
+
|
|
272
|
+
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
273
|
+
if (isError) {
|
|
274
|
+
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: 'text', text: capForContext(`Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`) }],
|
|
277
|
+
details: makeDetails('chain')(results),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
previousOutput = capForContext(getFinalOutput(result.messages))
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
|
|
284
|
+
details: makeDetails('chain')(results),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promise<ToolResult> {
|
|
289
|
+
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
290
|
+
if (tasks.length > MAX_PARALLEL_TASKS)
|
|
291
|
+
return {
|
|
292
|
+
content: [
|
|
293
|
+
{
|
|
294
|
+
type: 'text',
|
|
295
|
+
text: `Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
details: makeDetails('parallel')([]),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Track all results for streaming updates
|
|
302
|
+
const allResults: SingleResult[] = new Array(tasks.length)
|
|
303
|
+
|
|
304
|
+
// Initialize placeholder results
|
|
305
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
306
|
+
allResults[i] = {
|
|
307
|
+
agent: tasks[i].agent,
|
|
308
|
+
agentSource: 'unknown',
|
|
309
|
+
task: tasks[i].task,
|
|
310
|
+
exitCode: -1, // -1 = still running
|
|
311
|
+
messages: [],
|
|
312
|
+
stderr: '',
|
|
313
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const emitParallelUpdate = () => {
|
|
318
|
+
if (onUpdate) {
|
|
319
|
+
const running = allResults.filter((r) => r.exitCode === -1).length
|
|
320
|
+
const done = allResults.filter((r) => r.exitCode !== -1).length
|
|
321
|
+
onUpdate({
|
|
322
|
+
content: [{ type: 'text', text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
|
323
|
+
details: makeDetails('parallel')([...allResults]),
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
329
|
+
const result = await runSingleAgent({
|
|
330
|
+
defaultCwd,
|
|
331
|
+
agents,
|
|
332
|
+
agentName: t.agent,
|
|
333
|
+
task: t.task,
|
|
334
|
+
cwd: t.cwd,
|
|
335
|
+
signal,
|
|
336
|
+
onPhase: mode.onPhase,
|
|
337
|
+
// Same context single and chain mode pass: without these, an agent's skills
|
|
338
|
+
// preload and its model tier alias silently do nothing in parallel mode only.
|
|
339
|
+
skillRoots: mode.skillRoots,
|
|
340
|
+
availableModels: mode.availableModels,
|
|
341
|
+
projectApproved: mode.projectApproved,
|
|
342
|
+
// Per-task update callback
|
|
343
|
+
onUpdate: (partial) => {
|
|
344
|
+
const live = partial.details?.results[0]
|
|
345
|
+
if (live) {
|
|
346
|
+
// Keep the running sentinel until the child closes: the streamed result carries
|
|
347
|
+
// exitCode 0 mid-run, which would otherwise count and render the task as done.
|
|
348
|
+
allResults[index] = { ...live, exitCode: -1 }
|
|
349
|
+
emitParallelUpdate()
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
makeDetails: makeDetails('parallel'),
|
|
353
|
+
})
|
|
354
|
+
allResults[index] = result
|
|
355
|
+
emitParallelUpdate()
|
|
356
|
+
return result
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const successCount = results.filter((r) => r.exitCode === 0).length
|
|
360
|
+
const summaries = results.map((r) => {
|
|
361
|
+
const output = getFinalOutput(r.messages)
|
|
362
|
+
const status = r.exitCode === 0 ? 'completed' : 'failed'
|
|
363
|
+
return `[${r.agent}] ${status}: ${output || '(no output)'}`
|
|
364
|
+
})
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: 'text',
|
|
369
|
+
// Full reports, so a fan-out can be synthesized from; capped at pi's tool-output budget.
|
|
370
|
+
text: capForContext(`Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`),
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
details: makeDetails('parallel')(results),
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function runSingleMode(agentName: string, task: string, cwd: string | undefined, mode: ModeContext): Promise<ToolResult> {
|
|
378
|
+
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
379
|
+
const result = await runSingleAgent({
|
|
380
|
+
defaultCwd,
|
|
381
|
+
agents,
|
|
382
|
+
agentName,
|
|
383
|
+
task,
|
|
384
|
+
cwd,
|
|
385
|
+
signal,
|
|
386
|
+
onUpdate,
|
|
387
|
+
makeDetails: makeDetails('single'),
|
|
388
|
+
onPhase: mode.onPhase,
|
|
389
|
+
skillRoots: mode.skillRoots,
|
|
390
|
+
availableModels: mode.availableModels,
|
|
391
|
+
projectApproved: mode.projectApproved,
|
|
392
|
+
})
|
|
393
|
+
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
394
|
+
if (isError) {
|
|
395
|
+
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
396
|
+
return {
|
|
397
|
+
content: [{ type: 'text', text: capForContext(`Agent ${result.stopReason || 'failed'}: ${errorMsg}`) }],
|
|
398
|
+
details: makeDetails('single')([result]),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: 'text', text: capForContext(getFinalOutput(result.messages)) || '(no output)' }],
|
|
403
|
+
details: makeDetails('single')([result]),
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subagent tool schema and the result types derived from it. Kept apart from the
|
|
3
|
+
* dispatch so the mode runners can name their own parameters without importing the
|
|
4
|
+
* extension entry point back.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentToolResult } from '@earendil-works/pi-agent-core'
|
|
8
|
+
import { StringEnum } from '@earendil-works/pi-ai'
|
|
9
|
+
import { type Static, Type } from 'typebox'
|
|
10
|
+
|
|
11
|
+
import type { SingleResult, SubagentDetails } from './types.js'
|
|
12
|
+
|
|
13
|
+
export const TaskItem = Type.Object({
|
|
14
|
+
agent: Type.String({ description: 'Name of the agent to invoke' }),
|
|
15
|
+
task: Type.String({ description: 'Task to delegate to the agent' }),
|
|
16
|
+
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export const ChainItem = Type.Object({
|
|
20
|
+
agent: Type.String({ description: 'Name of the agent to invoke' }),
|
|
21
|
+
task: Type.String({ description: 'Task with optional {previous} placeholder for prior output' }),
|
|
22
|
+
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const AgentScopeSchema = StringEnum(['user', 'project', 'both'] as const, {
|
|
26
|
+
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
|
27
|
+
default: 'user',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const SubagentParams = Type.Object({
|
|
31
|
+
agent: Type.Optional(Type.String({ description: 'Name of the agent to invoke (for single mode)' })),
|
|
32
|
+
task: Type.Optional(Type.String({ description: 'Task to delegate (for single mode)' })),
|
|
33
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: 'Array of {agent, task} for parallel execution' })),
|
|
34
|
+
chain: Type.Optional(Type.Array(ChainItem, { description: 'Array of {agent, task} for sequential execution' })),
|
|
35
|
+
agentScope: Type.Optional(AgentScopeSchema),
|
|
36
|
+
confirmProjectAgents: Type.Optional(Type.Boolean({ description: 'Prompt before running project-local agents. Default: true.', default: true })),
|
|
37
|
+
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process (single mode)' })),
|
|
38
|
+
background: Type.Optional(Type.Boolean({ description: 'Run the single-mode task in the background: returns a run id immediately and a notification arrives when it completes.' })),
|
|
39
|
+
status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
|
|
40
|
+
cancel: Type.Optional(Type.String({ description: 'Background run id to cancel (from the id returned when it started, or from status).' })),
|
|
41
|
+
resume: Type.Optional(Type.String({ description: 'Finished background run id to continue with a follow-up task; the child keeps everything it already saw. Pass task with it.' })),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* pi only sets a tool result's error flag when execute() throws; a returned isError is
|
|
46
|
+
* ignored (docs/extensions.md, "Signaling errors"). Throwing here would be worse: the
|
|
47
|
+
* agent loop replaces the result with createErrorToolResult(message), discarding the
|
|
48
|
+
* details renderResult needs to show the failed agent's transcript. The failure is
|
|
49
|
+
* carried in the content text instead, which is what reaches the model.
|
|
50
|
+
*/
|
|
51
|
+
export type ToolResult = AgentToolResult<SubagentDetails>
|
|
52
|
+
export type SubagentMode = 'single' | 'parallel' | 'chain'
|
|
53
|
+
export type MakeDetails = (mode: SubagentMode) => (results: SingleResult[]) => SubagentDetails
|
|
54
|
+
export type SubagentParamsStatic = Static<typeof SubagentParams>
|
|
55
|
+
export type ChainStepParam = Static<typeof ChainItem>
|
|
56
|
+
export type TaskItemParam = Static<typeof TaskItem>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text the subagent surfaces read out: a finished background run's notification,
|
|
3
|
+
* the /tasks table and the /agents roster. No process state, no rendering widgets, so
|
|
4
|
+
* each is a pure string function the tests can pin directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { capForContext } from '../internal/output-guard.js'
|
|
8
|
+
import type { AgentConfig, AgentSource } from './agents.js'
|
|
9
|
+
import { type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun } from './background.js'
|
|
10
|
+
|
|
11
|
+
/** The completion notice a background run sends when it finishes. */
|
|
12
|
+
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string; partial?: boolean }): string {
|
|
13
|
+
const output = capForContext(run.output ?? '') || '(no output)'
|
|
14
|
+
// A child that dies at boot writes its reason only to stderr; without this the
|
|
15
|
+
// notice reads "failed after 0 turns ... (no output)" with nothing to act on.
|
|
16
|
+
const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
|
|
17
|
+
// Claude marks maxTurns-capped output as partial and notes the run can be
|
|
18
|
+
// resumed to continue from where it stopped.
|
|
19
|
+
const partialNote = run.partial ? `\n\n[Output is partial: the run stopped at its maxTurns limit. Resume it with {resume: "${run.id}", task: "..."} to continue.]` : ''
|
|
20
|
+
return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}${partialNote}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What to tell the model about a resume request. */
|
|
24
|
+
export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }) => void, onResumed?: (run: { id: string; agent: string }) => void): string {
|
|
25
|
+
if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
|
|
26
|
+
const outcome = resumeBackgroundRun(id, task, onComplete)
|
|
27
|
+
if (outcome === 'resumed') {
|
|
28
|
+
const run = backgroundRun(id)
|
|
29
|
+
if (run) onResumed?.({ id: run.id, agent: run.agent })
|
|
30
|
+
return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
|
|
31
|
+
}
|
|
32
|
+
if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
|
|
33
|
+
if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
|
|
34
|
+
if (outcome === 'cwd-gone') return `Background run ${id} ran in a working directory that no longer exists (an isolation worktree is cleaned up after an unchanged run); start a new run instead.`
|
|
35
|
+
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What to tell the model about a cancel request. */
|
|
39
|
+
export function cancelResultText(id: string): string {
|
|
40
|
+
const outcome = cancelBackgroundRun(id)
|
|
41
|
+
if (outcome === 'cancelled') return `Cancelled background run ${id}.`
|
|
42
|
+
if (outcome === 'not-running') return `Background run ${id} already finished; nothing to cancel.`
|
|
43
|
+
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The registry fields the /tasks listing prints. */
|
|
47
|
+
type BackgroundRunView = Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'output' | 'stderr'>
|
|
48
|
+
|
|
49
|
+
const TASK_PREVIEW_CHARS = 60
|
|
50
|
+
const TAIL_PREVIEW_CHARS = 200
|
|
51
|
+
|
|
52
|
+
/** Truncate to at most `max` codepoints, iterating by codepoint so a multi-byte
|
|
53
|
+
* character on the boundary is never cut into a lone surrogate. Returns the whole
|
|
54
|
+
* string when it already fits, so a caller can tell it did not clip. */
|
|
55
|
+
function clipCodepoints(text: string, max: number): string {
|
|
56
|
+
const points = Array.from(text)
|
|
57
|
+
return points.length > max ? points.slice(0, max).join('') : text
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A one-line tail of what a run last said: the stderr tail for a failure (the only
|
|
61
|
+
* diagnostics a boot failure leaves), the latest assistant text otherwise. */
|
|
62
|
+
function runOutputTail(run: BackgroundRunView): string | undefined {
|
|
63
|
+
const stderrTail = run.state === 'failed' ? run.stderr?.trim() : undefined
|
|
64
|
+
const raw = (stderrTail || run.output)?.trim()
|
|
65
|
+
if (!raw) return undefined
|
|
66
|
+
const last = raw.split('\n').at(-1)?.trim() ?? ''
|
|
67
|
+
const shortened = clipCodepoints(last, TAIL_PREVIEW_CHARS)
|
|
68
|
+
const clipped = shortened === last ? last : `${shortened}...`
|
|
69
|
+
return stderrTail ? `stderr: ${clipped}` : clipped
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The /tasks listing: one line per background run, plus the short output tail the
|
|
73
|
+
* registry's own status lines omit. A pure formatter so it tests against a plain list. */
|
|
74
|
+
export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
|
|
75
|
+
if (runs.length === 0) return 'No background subagent runs.'
|
|
76
|
+
return runs
|
|
77
|
+
.map((run) => {
|
|
78
|
+
const plural = run.turns === 1 ? '' : 's'
|
|
79
|
+
const label = run.state === 'running' ? 'running' : `${run.state} (${run.turns} turn${plural})`
|
|
80
|
+
const head = `${run.id} ${run.agent}: ${label} - ${clipCodepoints(run.task, TASK_PREVIEW_CHARS)}`
|
|
81
|
+
const tail = runOutputTail(run)
|
|
82
|
+
return tail ? `${head}\n ${tail}` : head
|
|
83
|
+
})
|
|
84
|
+
.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Listing order for /agents: lowest to highest precedence, matching how discovery
|
|
88
|
+
* lets a later source win a name clash. */
|
|
89
|
+
const AGENT_SOURCE_ORDER: ReadonlyArray<AgentSource> = ['builtin', 'plugin', 'user', 'project']
|
|
90
|
+
|
|
91
|
+
const AGENTS_DIR_HINT = 'Add agents as markdown files under ~/.claude/agents (user) or .claude/agents (project).'
|
|
92
|
+
|
|
93
|
+
/** The /agents listing: the discovered roster grouped by source, with file paths.
|
|
94
|
+
* A pure formatter so it tests against a sample roster. */
|
|
95
|
+
export function agentsListText(agents: ReadonlyArray<Pick<AgentConfig, 'name' | 'source' | 'filePath'>>): string {
|
|
96
|
+
if (agents.length === 0) return `No agents discovered.\n${AGENTS_DIR_HINT}`
|
|
97
|
+
const sections: string[] = []
|
|
98
|
+
for (const source of AGENT_SOURCE_ORDER) {
|
|
99
|
+
const group = agents.filter((agent) => agent.source === source)
|
|
100
|
+
if (group.length === 0) continue
|
|
101
|
+
const lines = group.map((agent) => ` ${agent.name} - ${agent.filePath}`).join('\n')
|
|
102
|
+
sections.push(`${source}:\n${lines}`)
|
|
103
|
+
}
|
|
104
|
+
return `${sections.join('\n')}\n\n${AGENTS_DIR_HINT}`
|
|
105
|
+
}
|