pi-code 1.0.56 → 1.0.58

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 (44) hide show
  1. package/extensions/claude-rules.ts +3 -4
  2. package/extensions/commands.ts +6 -12
  3. package/extensions/context-imports.ts +250 -22
  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 +4 -3
  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/project-approval.ts +14 -0
  17. package/extensions/internal/settings-chain.ts +19 -0
  18. package/extensions/internal/tool-target.ts +23 -0
  19. package/extensions/internal/values.ts +38 -0
  20. package/extensions/mcp/index.ts +6 -5
  21. package/extensions/mcp/listing.ts +2 -1
  22. package/extensions/mcp/oauth-flow.ts +2 -1
  23. package/extensions/mcp/policy.ts +9 -2
  24. package/extensions/memory.ts +10 -15
  25. package/extensions/output-styles.ts +4 -17
  26. package/extensions/plan-mode/index.ts +9 -9
  27. package/extensions/plan-mode/utils.ts +31 -0
  28. package/extensions/session-title.ts +2 -12
  29. package/extensions/skills.ts +6 -18
  30. package/extensions/status-line.ts +2 -8
  31. package/extensions/subagent/README.md +12 -2
  32. package/extensions/subagent/agents.ts +2 -2
  33. package/extensions/subagent/background.ts +2 -1
  34. package/extensions/subagent/child.ts +197 -0
  35. package/extensions/subagent/concurrency.ts +23 -0
  36. package/extensions/subagent/index.ts +36 -1426
  37. package/extensions/subagent/modes.ts +405 -0
  38. package/extensions/subagent/params.ts +56 -0
  39. package/extensions/subagent/registry-text.ts +105 -0
  40. package/extensions/subagent/render-result.ts +306 -0
  41. package/extensions/subagent/run.ts +375 -0
  42. package/extensions/subagent/types.ts +41 -0
  43. package/extensions/subagent/worktree.ts +2 -1
  44. 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
+ }