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
|
@@ -1,1440 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Subagent
|
|
2
|
+
* Subagent tool: delegate a task to a named agent running in its own `pi` process,
|
|
3
|
+
* so it gets an isolated context window.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* This file is the entry point only: the tool schema, the dispatch between single,
|
|
6
|
+
* parallel, chain and background, and the session hooks. The work lives beside it,
|
|
7
|
+
* one concern per module:
|
|
8
|
+
* agents.ts discovery and frontmatter
|
|
9
|
+
* child.ts how a child is configured before it is spawned
|
|
10
|
+
* run.ts spawning one child and parsing its JSON event stream
|
|
11
|
+
* modes.ts the four ways a request runs, and the project-agent gate
|
|
12
|
+
* background.ts detached runs, /tasks state, resume and cancel
|
|
13
|
+
* params.ts the tool schema and the types derived from it
|
|
14
|
+
* registry-text.ts the text /tasks, /agents and completion notices read out
|
|
15
|
+
* render.ts transcript formatting shared with the parent
|
|
16
|
+
* render-result.ts how a call and its results are drawn
|
|
6
17
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
|
|
10
|
-
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
|
|
11
|
-
*
|
|
12
|
-
* Uses JSON mode to capture structured output from subagents.
|
|
18
|
+
* Names the tests reach for are re-exported below, so the public surface is this file
|
|
19
|
+
* regardless of which module owns them.
|
|
13
20
|
*/
|
|
14
|
-
|
|
15
|
-
import { spawn } from 'node:child_process'
|
|
16
|
-
import { randomUUID } from 'node:crypto'
|
|
17
|
-
import * as fs from 'node:fs'
|
|
18
21
|
import * as os from 'node:os'
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import { StringEnum } from '@earendil-works/pi-ai'
|
|
23
|
-
import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
24
|
-
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
25
|
-
import { type Static, Type } from 'typebox'
|
|
26
|
-
import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
|
|
27
|
-
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
22
|
+
import { type ExtensionAPI, getMarkdownTheme } from '@earendil-works/pi-coding-agent'
|
|
23
|
+
import { Text } from '@earendil-works/pi-tui'
|
|
24
|
+
import { setAgentRunner } from '../internal/agent-run.js'
|
|
28
25
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
29
|
-
import {
|
|
30
|
-
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
31
|
-
import { repoRoot } from '../internal/project-root.js'
|
|
26
|
+
import { isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
32
27
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
33
|
-
import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
34
|
-
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
35
28
|
import { skillDirs } from '../skills.js'
|
|
36
|
-
import { type AgentConfig, type
|
|
37
|
-
import { activeBackgroundRuns, allBackgroundRuns,
|
|
38
|
-
import {
|
|
39
|
-
import { type
|
|
40
|
-
|
|
29
|
+
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
30
|
+
import { activeBackgroundRuns, allBackgroundRuns, backgroundStatusText, cancelAllBackgroundRuns } from './background.js'
|
|
31
|
+
import { buildHookAgent, forkAgent, setKnownMcpAliases } from './child.js'
|
|
32
|
+
import { checkProjectAgentGate, type ModeContext, runBackgroundMode, runChainMode, runParallelMode, runSingleMode, wantsBackground } from './modes.js'
|
|
33
|
+
import { type SubagentMode, SubagentParams } from './params.js'
|
|
34
|
+
import { agentsListText, backgroundCompletionText, cancelResultText, resumeResultText, tasksStatusText } from './registry-text.js'
|
|
35
|
+
import { getFinalOutput } from './render.js'
|
|
36
|
+
import { renderChainCall, renderChainResult, renderParallelCall, renderParallelResult, renderSingleCall, renderSingleResult } from './render-result.js'
|
|
37
|
+
import { runSingleAgent } from './run.js'
|
|
38
|
+
import type { SingleResult, SubagentDetails } from './types.js'
|
|
39
|
+
|
|
40
|
+
export { AGENT_HOOK_SYSTEM, agentMemoryDir, agentMemorySection, buildHookAgent, setKnownMcpAliases, withMemoryTools } from './child.js'
|
|
41
|
+
export { mapWithConcurrencyLimit } from './concurrency.js'
|
|
42
|
+
export { projectAgentGate } from './modes.js'
|
|
43
|
+
export { agentsListText, backgroundCompletionText, cancelResultText, resumeResultText, tasksStatusText } from './registry-text.js'
|
|
41
44
|
// Re-exported so the render formatters stay importable from the subagent entry point,
|
|
42
45
|
// where the tests and the tool itself have always reached for them.
|
|
43
46
|
export { formatTokens, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
|
|
44
|
-
|
|
45
|
-
const MAX_PARALLEL_TASKS = 8
|
|
46
|
-
const MAX_CONCURRENCY = 4
|
|
47
|
-
const COLLAPSED_ITEM_COUNT = 10
|
|
48
|
-
|
|
49
|
-
interface UsageStats {
|
|
50
|
-
input: number
|
|
51
|
-
output: number
|
|
52
|
-
cacheRead: number
|
|
53
|
-
cacheWrite: number
|
|
54
|
-
cost: number
|
|
55
|
-
contextTokens: number
|
|
56
|
-
turns: number
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface SingleResult {
|
|
60
|
-
agent: string
|
|
61
|
-
agentSource: 'user' | 'project' | 'builtin' | 'plugin' | 'unknown'
|
|
62
|
-
task: string
|
|
63
|
-
exitCode: number
|
|
64
|
-
messages: Message[]
|
|
65
|
-
stderr: string
|
|
66
|
-
usage: UsageStats
|
|
67
|
-
model?: string
|
|
68
|
-
stopReason?: string
|
|
69
|
-
errorMessage?: string
|
|
70
|
-
step?: number
|
|
71
|
-
/** Claude's partial marker: the run stopped at its maxTurns limit. */
|
|
72
|
-
partial?: boolean
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface SubagentDetails {
|
|
76
|
-
mode: 'single' | 'parallel' | 'chain'
|
|
77
|
-
agentScope: AgentScope
|
|
78
|
-
projectAgentsDir: string | null
|
|
79
|
-
results: SingleResult[]
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
|
|
83
|
-
if (items.length === 0) return []
|
|
84
|
-
const limit = Math.max(1, Math.min(concurrency, items.length))
|
|
85
|
-
const results: TOut[] = new Array(items.length)
|
|
86
|
-
let nextIndex = 0
|
|
87
|
-
const workers = new Array(limit).fill(null).map(async () => {
|
|
88
|
-
while (true) {
|
|
89
|
-
const current = nextIndex++
|
|
90
|
-
if (current >= items.length) return
|
|
91
|
-
results[current] = await fn(items[current], current)
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
await Promise.all(workers)
|
|
95
|
-
return results
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
99
|
-
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pi-subagent-'))
|
|
100
|
-
const safeName = agentName.replace(/[^\w.-]+/g, '_')
|
|
101
|
-
const filePath = path.join(tmpDir, `prompt-${safeName}.md`)
|
|
102
|
-
await withFileMutationQueue(filePath, async () => {
|
|
103
|
-
await fs.promises.writeFile(filePath, prompt, { encoding: 'utf-8', mode: 0o600 })
|
|
104
|
-
})
|
|
105
|
-
return { dir: tmpDir, filePath }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Exported as a test seam: the fallbacks only fire in packaged distributions
|
|
109
|
-
* (bun single-file, compiled binary), which no CI run reaches naturally. */
|
|
110
|
-
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
111
|
-
const currentScript = process.argv[1]
|
|
112
|
-
const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/')
|
|
113
|
-
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
114
|
-
return { command: process.execPath, args: [currentScript, ...args] }
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const execName = path.basename(process.execPath).toLowerCase()
|
|
118
|
-
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName)
|
|
119
|
-
if (!isGenericRuntime) {
|
|
120
|
-
return { command: process.execPath, args }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return { command: 'pi', args }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void
|
|
127
|
-
|
|
128
|
-
type AssistantMessage = Extract<Message, { role: 'assistant' }>
|
|
129
|
-
|
|
130
|
-
function accumulateAssistantMessage(result: SingleResult, msg: AssistantMessage): void {
|
|
131
|
-
result.usage.turns++
|
|
132
|
-
const usage = msg.usage
|
|
133
|
-
if (usage) {
|
|
134
|
-
result.usage.input += usage.input || 0
|
|
135
|
-
result.usage.output += usage.output || 0
|
|
136
|
-
result.usage.cacheRead += usage.cacheRead || 0
|
|
137
|
-
result.usage.cacheWrite += usage.cacheWrite || 0
|
|
138
|
-
result.usage.cost += usage.cost?.total || 0
|
|
139
|
-
result.usage.contextTokens = usage.totalTokens || 0
|
|
140
|
-
}
|
|
141
|
-
if (!result.model && msg.model) result.model = msg.model
|
|
142
|
-
if (msg.stopReason) result.stopReason = msg.stopReason
|
|
143
|
-
if (msg.errorMessage) result.errorMessage = msg.errorMessage
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
interface RunAgentOptions {
|
|
147
|
-
defaultCwd: string
|
|
148
|
-
agents: AgentConfig[]
|
|
149
|
-
agentName: string
|
|
150
|
-
task: string
|
|
151
|
-
cwd?: string
|
|
152
|
-
step?: number
|
|
153
|
-
signal?: AbortSignal
|
|
154
|
-
onUpdate?: OnUpdateCallback
|
|
155
|
-
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
156
|
-
onPhase?: SubagentPhaseSink
|
|
157
|
-
/** The child's run id, set by the wrapper so the spawn env can carry it. */
|
|
158
|
-
agentId?: string
|
|
159
|
-
/** SubagentStart hook context, injected ahead of the child's first prompt. */
|
|
160
|
-
startContexts?: string[]
|
|
161
|
-
/** Skill directories to preload from, resolved where project trust is known. */
|
|
162
|
-
skillRoots?: string[]
|
|
163
|
-
/** Models this user can actually run, for resolving a tier alias. */
|
|
164
|
-
availableModels?: ReadonlyArray<{ id: string }>
|
|
165
|
-
/** Whether repo-controlled config (a project/local agent memory store) may be read. */
|
|
166
|
-
projectApproved?: boolean
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop.
|
|
170
|
-
* The stop carries the run's final assistant text, which Claude's SubagentStop
|
|
171
|
-
* delivers as last_assistant_message. */
|
|
172
|
-
type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string, lastAssistantMessage?: string) => void
|
|
173
|
-
|
|
174
|
-
/** Append a note to the final assistant message so it rides the run's normal
|
|
175
|
-
* output; stderr when there is none. */
|
|
176
|
-
function appendResultNote(result: SingleResult, note: string): void {
|
|
177
|
-
for (let i = result.messages.length - 1; i >= 0; i--) {
|
|
178
|
-
const msg = result.messages[i]
|
|
179
|
-
if (msg.role === 'assistant') {
|
|
180
|
-
msg.content.push({ type: 'text', text: note })
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
result.stderr = result.stderr ? `${result.stderr}\n${note}` : note
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Tell the parent where a kept worktree lives. */
|
|
188
|
-
function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
|
|
189
|
-
appendResultNote(result, `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Claude marks a maxTurns-capped run's output as partial; the note rides the
|
|
193
|
-
* final assistant message like the worktree note, so the parent model sees it
|
|
194
|
-
* with the output. A no-op for uncapped runs. */
|
|
195
|
-
function appendPartialNote(result: SingleResult): void {
|
|
196
|
-
if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
200
|
-
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
201
|
-
if (!agent) return runSingleAgentInner(options)
|
|
202
|
-
// A refused launch (Claude's zero-tools error) never starts, so no
|
|
203
|
-
// SubagentStart/Stop pair fires for it.
|
|
204
|
-
const toolsError = unresolvedToolsError(agent)
|
|
205
|
-
if (toolsError) {
|
|
206
|
-
return {
|
|
207
|
-
agent: agent.name,
|
|
208
|
-
agentSource: agent.source,
|
|
209
|
-
task: options.task,
|
|
210
|
-
exitCode: 1,
|
|
211
|
-
messages: [],
|
|
212
|
-
stderr: toolsError,
|
|
213
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
214
|
-
step: options.step,
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const agentId = `fg-${randomUUID().slice(0, 8)}`
|
|
218
|
-
// SubagentStart hooks run pre-spawn through the seam so their additionalContext
|
|
219
|
-
// reaches the child before its first prompt.
|
|
220
|
-
const startContexts = await runSubagentStartHooks(agent.name, agentId)
|
|
221
|
-
options.onPhase?.('start', agent.name, agentId)
|
|
222
|
-
let result: SingleResult | undefined
|
|
223
|
-
try {
|
|
224
|
-
result = await runSingleAgentInner({ ...options, agentId, startContexts })
|
|
225
|
-
return result
|
|
226
|
-
} finally {
|
|
227
|
-
options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResult> {
|
|
232
|
-
const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
|
|
233
|
-
const agent = agents.find((a) => a.name === agentName)
|
|
234
|
-
|
|
235
|
-
if (!agent) {
|
|
236
|
-
const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
|
|
237
|
-
return {
|
|
238
|
-
agent: agentName,
|
|
239
|
-
agentSource: 'unknown',
|
|
240
|
-
task,
|
|
241
|
-
exitCode: 1,
|
|
242
|
-
messages: [],
|
|
243
|
-
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
244
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
245
|
-
step,
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const runCwd = cwd ?? defaultCwd
|
|
250
|
-
// Claude's isolation: worktree gives the child an isolated copy of the repository.
|
|
251
|
-
// A boundary that cannot be created fails the run: running against the real
|
|
252
|
-
// checkout would silently drop the isolation the agent declared.
|
|
253
|
-
let worktree: AgentWorktree | undefined
|
|
254
|
-
if (agent.isolation === 'worktree') {
|
|
255
|
-
const created = await createAgentWorktree(runCwd, agent.name)
|
|
256
|
-
if ('error' in created) {
|
|
257
|
-
return {
|
|
258
|
-
agent: agentName,
|
|
259
|
-
agentSource: agent.source,
|
|
260
|
-
task,
|
|
261
|
-
exitCode: 1,
|
|
262
|
-
messages: [],
|
|
263
|
-
stderr: `isolation: worktree could not be created: ${created.error}`,
|
|
264
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
265
|
-
step,
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
worktree = created
|
|
269
|
-
}
|
|
270
|
-
// Project/local memory is anchored at the SESSION project (defaultCwd), not the
|
|
271
|
-
// model-supplied runCwd: projectApproved gates the session's repo, so anchoring the
|
|
272
|
-
// store on a different (possibly unapproved) cwd would inject that repo's memory as
|
|
273
|
-
// trusted. User-scope memory ignores cwd, so this is safe for it too.
|
|
274
|
-
const memorySection = agentMemoryPromptSection(agent, defaultCwd, options.projectApproved ?? false)
|
|
275
|
-
// A memory-enabled child must be able to manage its store files even when the
|
|
276
|
-
// agent pins a tools allowlist.
|
|
277
|
-
const invocationAgent = memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent
|
|
278
|
-
const args = agentInvocationArgs(invocationAgent, resolveModelAlias(agent.modelAlias, options.availableModels ?? []))
|
|
279
|
-
|
|
280
|
-
let tmpPromptDir: string | null = null
|
|
281
|
-
let tmpPromptPath: string | null = null
|
|
282
|
-
|
|
283
|
-
const currentResult: SingleResult = {
|
|
284
|
-
agent: agentName,
|
|
285
|
-
agentSource: agent.source,
|
|
286
|
-
task,
|
|
287
|
-
exitCode: 0,
|
|
288
|
-
messages: [],
|
|
289
|
-
stderr: '',
|
|
290
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
291
|
-
model: agent.model,
|
|
292
|
-
step,
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const emitUpdate = () => {
|
|
296
|
-
if (onUpdate) {
|
|
297
|
-
onUpdate({
|
|
298
|
-
content: [{ type: 'text', text: getFinalOutput(currentResult.messages) || '(running...)' }],
|
|
299
|
-
details: makeDetails([currentResult]),
|
|
300
|
-
})
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
try {
|
|
305
|
-
const promptBody = childPromptBody(agent, options.skillRoots ?? [], memorySection)
|
|
306
|
-
if (promptBody.trim()) {
|
|
307
|
-
const tmp = await writePromptToTempFile(agent.name, promptBody)
|
|
308
|
-
tmpPromptDir = tmp.dir
|
|
309
|
-
tmpPromptPath = tmp.filePath
|
|
310
|
-
// Claude: the agent body IS the subagent's system prompt, replacing the
|
|
311
|
-
// default, not an addition to it (--system-prompt reads a file path too).
|
|
312
|
-
args.push('--system-prompt', tmpPromptPath)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
args.push(taskWithStartContext(task, options.startContexts ?? []))
|
|
316
|
-
let wasAborted = false
|
|
317
|
-
|
|
318
|
-
const exitCode = await new Promise<number>((resolve) => {
|
|
319
|
-
const invocation = getPiInvocation(args)
|
|
320
|
-
const proc = spawn(invocation.command, invocation.args, {
|
|
321
|
-
cwd: worktree?.dir ?? runCwd,
|
|
322
|
-
shell: false,
|
|
323
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
324
|
-
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
325
|
-
// direct child orphans a build or dev server the agent started.
|
|
326
|
-
detached: true,
|
|
327
|
-
// The marker lets the child's subagent tool refuse to nest further.
|
|
328
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
|
|
329
|
-
})
|
|
330
|
-
let buffer = ''
|
|
331
|
-
let assistantTurns = 0
|
|
332
|
-
|
|
333
|
-
const processLine = (line: string) => {
|
|
334
|
-
if (!line.trim()) return
|
|
335
|
-
let event: { type?: string; message?: unknown }
|
|
336
|
-
try {
|
|
337
|
-
event = JSON.parse(line)
|
|
338
|
-
} catch {
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (!event.message) return
|
|
343
|
-
|
|
344
|
-
if (event.type === 'message_end') {
|
|
345
|
-
const msg = event.message as Message
|
|
346
|
-
currentResult.messages.push(msg)
|
|
347
|
-
if (msg.role === 'assistant') {
|
|
348
|
-
accumulateAssistantMessage(currentResult, msg)
|
|
349
|
-
assistantTurns++
|
|
350
|
-
// Claude's maxTurns cap: end the child at the turn boundary once it has
|
|
351
|
-
// produced its Nth turn, so the collected output is kept and no turn is
|
|
352
|
-
// cut; the returned output is marked partial, as Claude documents.
|
|
353
|
-
if (agent.maxTurns && assistantTurns >= agent.maxTurns) {
|
|
354
|
-
currentResult.partial = true
|
|
355
|
-
killGroup('SIGTERM')
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
emitUpdate()
|
|
359
|
-
} else if (event.type === 'tool_result_end') {
|
|
360
|
-
currentResult.messages.push(event.message as Message)
|
|
361
|
-
emitUpdate()
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
366
|
-
let onAbort: (() => void) | undefined
|
|
367
|
-
const cleanup = () => {
|
|
368
|
-
if (killTimer) clearTimeout(killTimer)
|
|
369
|
-
if (onAbort && signal) signal.removeEventListener('abort', onAbort)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
proc.stdout.on('data', (data) => {
|
|
373
|
-
buffer += data.toString()
|
|
374
|
-
const lines = buffer.split('\n')
|
|
375
|
-
buffer = lines.pop() || ''
|
|
376
|
-
for (const line of lines) processLine(line)
|
|
377
|
-
})
|
|
378
|
-
proc.stdout.on('error', () => {})
|
|
379
|
-
|
|
380
|
-
proc.stderr.on('data', (data) => {
|
|
381
|
-
currentResult.stderr += data.toString()
|
|
382
|
-
})
|
|
383
|
-
proc.stderr.on('error', () => {})
|
|
384
|
-
|
|
385
|
-
proc.on('close', (code) => {
|
|
386
|
-
cleanup()
|
|
387
|
-
if (buffer.trim()) processLine(buffer)
|
|
388
|
-
resolve(code ?? 0)
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
proc.on('error', (error: Error) => {
|
|
392
|
-
cleanup()
|
|
393
|
-
// A child that never started leaves no stdout and no stderr, so this message is the
|
|
394
|
-
// only diagnostic; without it the failure reads as "(no output)".
|
|
395
|
-
if (!currentResult.stderr) currentResult.stderr = error.message
|
|
396
|
-
resolve(1)
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
const killGroup = (sig: NodeJS.Signals): void => {
|
|
400
|
-
try {
|
|
401
|
-
// A child that never spawned has no pid and no group; the direct kill is all there is.
|
|
402
|
-
if (proc.pid) process.kill(-proc.pid, sig)
|
|
403
|
-
else proc.kill(sig)
|
|
404
|
-
} catch {
|
|
405
|
-
try {
|
|
406
|
-
proc.kill(sig)
|
|
407
|
-
} catch {
|
|
408
|
-
/* already gone */
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (signal) {
|
|
413
|
-
onAbort = () => {
|
|
414
|
-
wasAborted = true
|
|
415
|
-
killGroup('SIGTERM')
|
|
416
|
-
// proc.killed only reports that the signal was sent, not that the child died. Escalate
|
|
417
|
-
// on a timer that the 'close' handler clears once the child has actually exited.
|
|
418
|
-
killTimer = setTimeout(() => killGroup('SIGKILL'), 5000)
|
|
419
|
-
}
|
|
420
|
-
if (signal.aborted) onAbort()
|
|
421
|
-
else signal.addEventListener('abort', onAbort, { once: true })
|
|
422
|
-
}
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
currentResult.exitCode = exitCode
|
|
426
|
-
if (wasAborted) throw new Error('Subagent was aborted')
|
|
427
|
-
appendPartialNote(currentResult)
|
|
428
|
-
return currentResult
|
|
429
|
-
} finally {
|
|
430
|
-
// Cleanup runs on abort too: it only removes a pristine worktree, so an
|
|
431
|
-
// interrupted agent's changes always survive.
|
|
432
|
-
if (worktree && (await cleanupAgentWorktree(runCwd, worktree)) === 'kept') {
|
|
433
|
-
appendWorktreeNote(currentResult, worktree)
|
|
434
|
-
}
|
|
435
|
-
if (tmpPromptPath)
|
|
436
|
-
try {
|
|
437
|
-
fs.unlinkSync(tmpPromptPath)
|
|
438
|
-
} catch {
|
|
439
|
-
/* ignore */
|
|
440
|
-
}
|
|
441
|
-
if (tmpPromptDir)
|
|
442
|
-
try {
|
|
443
|
-
fs.rmdirSync(tmpPromptDir)
|
|
444
|
-
} catch {
|
|
445
|
-
/* ignore */
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
const TaskItem = Type.Object({
|
|
451
|
-
agent: Type.String({ description: 'Name of the agent to invoke' }),
|
|
452
|
-
task: Type.String({ description: 'Task to delegate to the agent' }),
|
|
453
|
-
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
const ChainItem = Type.Object({
|
|
457
|
-
agent: Type.String({ description: 'Name of the agent to invoke' }),
|
|
458
|
-
task: Type.String({ description: 'Task with optional {previous} placeholder for prior output' }),
|
|
459
|
-
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process' })),
|
|
460
|
-
})
|
|
461
|
-
|
|
462
|
-
const AgentScopeSchema = StringEnum(['user', 'project', 'both'] as const, {
|
|
463
|
-
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
|
464
|
-
default: 'user',
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
const SubagentParams = Type.Object({
|
|
468
|
-
agent: Type.Optional(Type.String({ description: 'Name of the agent to invoke (for single mode)' })),
|
|
469
|
-
task: Type.Optional(Type.String({ description: 'Task to delegate (for single mode)' })),
|
|
470
|
-
tasks: Type.Optional(Type.Array(TaskItem, { description: 'Array of {agent, task} for parallel execution' })),
|
|
471
|
-
chain: Type.Optional(Type.Array(ChainItem, { description: 'Array of {agent, task} for sequential execution' })),
|
|
472
|
-
agentScope: Type.Optional(AgentScopeSchema),
|
|
473
|
-
confirmProjectAgents: Type.Optional(Type.Boolean({ description: 'Prompt before running project-local agents. Default: true.', default: true })),
|
|
474
|
-
cwd: Type.Optional(Type.String({ description: 'Working directory for the agent process (single mode)' })),
|
|
475
|
-
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.' })),
|
|
476
|
-
status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
|
|
477
|
-
cancel: Type.Optional(Type.String({ description: 'Background run id to cancel (from the id returned when it started, or from status).' })),
|
|
478
|
-
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.' })),
|
|
479
|
-
})
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Decide how to gate project-scoped agents, whose system prompt and tools are
|
|
483
|
-
* repo-controlled. Untrusted projects require interactive confirmation, and are
|
|
484
|
-
* refused when headless; trusted projects run unless confirmProjectAgents asks
|
|
485
|
-
* for a prompt anyway.
|
|
486
|
-
*/
|
|
487
|
-
export function projectAgentGate(projectAgentCount: number, trusted: boolean, hasUI: boolean, confirmProjectAgents: boolean): 'allow' | 'confirm' | 'refuse' {
|
|
488
|
-
if (projectAgentCount === 0) return 'allow'
|
|
489
|
-
if (trusted && !confirmProjectAgents) return 'allow'
|
|
490
|
-
if (hasUI) return 'confirm'
|
|
491
|
-
return trusted ? 'allow' : 'refuse'
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* pi only sets a tool result's error flag when execute() throws; a returned isError is
|
|
496
|
-
* ignored (docs/extensions.md, "Signaling errors"). Throwing here would be worse: the
|
|
497
|
-
* agent loop replaces the result with createErrorToolResult(message), discarding the
|
|
498
|
-
* details renderResult needs to show the failed agent's transcript. The failure is
|
|
499
|
-
* carried in the content text instead, which is what reaches the model.
|
|
500
|
-
*/
|
|
501
|
-
type ToolResult = AgentToolResult<SubagentDetails>
|
|
502
|
-
type SubagentMode = 'single' | 'parallel' | 'chain'
|
|
503
|
-
type MakeDetails = (mode: SubagentMode) => (results: SingleResult[]) => SubagentDetails
|
|
504
|
-
type SubagentParamsStatic = Static<typeof SubagentParams>
|
|
505
|
-
type ChainStepParam = Static<typeof ChainItem>
|
|
506
|
-
type TaskItemParam = Static<typeof TaskItem>
|
|
507
|
-
|
|
508
|
-
/** The completion notice a background run sends when it finishes. */
|
|
509
|
-
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string; partial?: boolean }): string {
|
|
510
|
-
const output = capForContext(run.output ?? '') || '(no output)'
|
|
511
|
-
// A child that dies at boot writes its reason only to stderr; without this the
|
|
512
|
-
// notice reads "failed after 0 turns ... (no output)" with nothing to act on.
|
|
513
|
-
const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
|
|
514
|
-
// Claude marks maxTurns-capped output as partial and notes the run can be
|
|
515
|
-
// resumed to continue from where it stopped.
|
|
516
|
-
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.]` : ''
|
|
517
|
-
return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}${partialNote}`
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** What to tell the model about a resume request. */
|
|
521
|
-
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 {
|
|
522
|
-
if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
|
|
523
|
-
const outcome = resumeBackgroundRun(id, task, onComplete)
|
|
524
|
-
if (outcome === 'resumed') {
|
|
525
|
-
const run = backgroundRun(id)
|
|
526
|
-
if (run) onResumed?.({ id: run.id, agent: run.agent })
|
|
527
|
-
return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
|
|
528
|
-
}
|
|
529
|
-
if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
|
|
530
|
-
if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
|
|
531
|
-
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.`
|
|
532
|
-
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/** What to tell the model about a cancel request. */
|
|
536
|
-
export function cancelResultText(id: string): string {
|
|
537
|
-
const outcome = cancelBackgroundRun(id)
|
|
538
|
-
if (outcome === 'cancelled') return `Cancelled background run ${id}.`
|
|
539
|
-
if (outcome === 'not-running') return `Background run ${id} already finished; nothing to cancel.`
|
|
540
|
-
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** The registry fields the /tasks listing prints. */
|
|
544
|
-
type BackgroundRunView = Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'output' | 'stderr'>
|
|
545
|
-
|
|
546
|
-
const TASK_PREVIEW_CHARS = 60
|
|
547
|
-
const TAIL_PREVIEW_CHARS = 200
|
|
548
|
-
|
|
549
|
-
/** Truncate to at most `max` codepoints, iterating by codepoint so a multi-byte
|
|
550
|
-
* character on the boundary is never cut into a lone surrogate. Returns the whole
|
|
551
|
-
* string when it already fits, so a caller can tell it did not clip. */
|
|
552
|
-
function clipCodepoints(text: string, max: number): string {
|
|
553
|
-
const points = Array.from(text)
|
|
554
|
-
return points.length > max ? points.slice(0, max).join('') : text
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/** A one-line tail of what a run last said: the stderr tail for a failure (the only
|
|
558
|
-
* diagnostics a boot failure leaves), the latest assistant text otherwise. */
|
|
559
|
-
function runOutputTail(run: BackgroundRunView): string | undefined {
|
|
560
|
-
const stderrTail = run.state === 'failed' ? run.stderr?.trim() : undefined
|
|
561
|
-
const raw = (stderrTail || run.output)?.trim()
|
|
562
|
-
if (!raw) return undefined
|
|
563
|
-
const last = raw.split('\n').at(-1)?.trim() ?? ''
|
|
564
|
-
const shortened = clipCodepoints(last, TAIL_PREVIEW_CHARS)
|
|
565
|
-
const clipped = shortened === last ? last : `${shortened}...`
|
|
566
|
-
return stderrTail ? `stderr: ${clipped}` : clipped
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/** The /tasks listing: one line per background run, plus the short output tail the
|
|
570
|
-
* registry's own status lines omit. A pure formatter so it tests against a plain list. */
|
|
571
|
-
export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
|
|
572
|
-
if (runs.length === 0) return 'No background subagent runs.'
|
|
573
|
-
return runs
|
|
574
|
-
.map((run) => {
|
|
575
|
-
const plural = run.turns === 1 ? '' : 's'
|
|
576
|
-
const label = run.state === 'running' ? 'running' : `${run.state} (${run.turns} turn${plural})`
|
|
577
|
-
const head = `${run.id} ${run.agent}: ${label} - ${clipCodepoints(run.task, TASK_PREVIEW_CHARS)}`
|
|
578
|
-
const tail = runOutputTail(run)
|
|
579
|
-
return tail ? `${head}\n ${tail}` : head
|
|
580
|
-
})
|
|
581
|
-
.join('\n')
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/** Listing order for /agents: lowest to highest precedence, matching how discovery
|
|
585
|
-
* lets a later source win a name clash. */
|
|
586
|
-
const AGENT_SOURCE_ORDER: ReadonlyArray<AgentSource> = ['builtin', 'plugin', 'user', 'project']
|
|
587
|
-
|
|
588
|
-
const AGENTS_DIR_HINT = 'Add agents as markdown files under ~/.claude/agents (user) or .claude/agents (project).'
|
|
589
|
-
|
|
590
|
-
/** The /agents listing: the discovered roster grouped by source, with file paths.
|
|
591
|
-
* A pure formatter so it tests against a sample roster. */
|
|
592
|
-
export function agentsListText(agents: ReadonlyArray<Pick<AgentConfig, 'name' | 'source' | 'filePath'>>): string {
|
|
593
|
-
if (agents.length === 0) return `No agents discovered.\n${AGENTS_DIR_HINT}`
|
|
594
|
-
const sections: string[] = []
|
|
595
|
-
for (const source of AGENT_SOURCE_ORDER) {
|
|
596
|
-
const group = agents.filter((agent) => agent.source === source)
|
|
597
|
-
if (group.length === 0) continue
|
|
598
|
-
const lines = group.map((agent) => ` ${agent.name} - ${agent.filePath}`).join('\n')
|
|
599
|
-
sections.push(`${source}:\n${lines}`)
|
|
600
|
-
}
|
|
601
|
-
return `${sections.join('\n')}\n\n${AGENTS_DIR_HINT}`
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/** Everything a mode handler needs from the surrounding execute() call. */
|
|
605
|
-
interface ModeContext {
|
|
606
|
-
agents: AgentConfig[]
|
|
607
|
-
defaultCwd: string
|
|
608
|
-
signal: AbortSignal | undefined
|
|
609
|
-
onUpdate: OnUpdateCallback | undefined
|
|
610
|
-
makeDetails: MakeDetails
|
|
611
|
-
onPhase?: SubagentPhaseSink
|
|
612
|
-
skillRoots: string[]
|
|
613
|
-
availableModels: ReadonlyArray<{ id: string }>
|
|
614
|
-
projectApproved: boolean
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
|
|
618
|
-
const requestedAgentNames = new Set<string>()
|
|
619
|
-
if (params.agent) requestedAgentNames.add(params.agent)
|
|
620
|
-
for (const step of params.chain ?? []) requestedAgentNames.add(step.agent)
|
|
621
|
-
for (const t of params.tasks ?? []) requestedAgentNames.add(t.agent)
|
|
622
|
-
const requestedProjectAgents = [...requestedAgentNames].map((name) => agents.find((a) => a.name === name)).filter((a): a is AgentConfig => a?.source === 'project')
|
|
623
|
-
|
|
624
|
-
// No project agents means nothing repo-controlled to gate; skip the approval check so a
|
|
625
|
-
// user-scope run never prompts or persists a trust decision it does not need.
|
|
626
|
-
if (requestedProjectAgents.length === 0) return null
|
|
627
|
-
|
|
628
|
-
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
|
629
|
-
const approved = await isProjectApproved(ctx)
|
|
630
|
-
const gate = projectAgentGate(requestedProjectAgents.length, approved, ctx.hasUI, params.confirmProjectAgents ?? true)
|
|
631
|
-
// Agent names come from repo-controlled frontmatter; a newline in one would otherwise
|
|
632
|
-
// let it write its own "Source:" line into the prompt body.
|
|
633
|
-
const names = requestedProjectAgents.map((a) => a.name.replace(/\s+/g, ' ').trim()).join(', ')
|
|
634
|
-
if (gate === 'refuse') {
|
|
635
|
-
return { content: [{ type: 'text', text: `Project-local agents (${names}) require a trusted project; refusing in non-interactive mode.` }], details: makeDetails(gateMode)([]) }
|
|
636
|
-
}
|
|
637
|
-
if (gate === 'confirm') {
|
|
638
|
-
// Each agent knows where it was loaded from; projectAgentsDir only ever held .pi/agents.
|
|
639
|
-
const dirs = [...new Set(requestedProjectAgents.map((a) => path.dirname(a.filePath)))]
|
|
640
|
-
const dir = dirs.join(', ') || projectAgentsDir || '(unknown)'
|
|
641
|
-
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.`)
|
|
642
|
-
if (!ok) return { content: [{ type: 'text', text: 'Canceled: project-local agents not approved.' }], details: makeDetails(gateMode)([]) }
|
|
643
|
-
}
|
|
644
|
-
return null
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** The system prompt for Claude's experimental `type: "agent"` hooks: the subagent
|
|
648
|
-
* inspects with read-only tools and returns the same JSON decision a command hook's
|
|
649
|
-
* stdout carries. A hook-supplied `systemPrompt` is appended after it. */
|
|
650
|
-
export const AGENT_HOOK_SYSTEM = [
|
|
651
|
-
'You are a Claude Code agent hook verifying whether an action should proceed.',
|
|
652
|
-
'Use the Read, Grep, and Glob tools to inspect files as needed before deciding.',
|
|
653
|
-
'When done, respond with ONLY a JSON object and nothing else:',
|
|
654
|
-
'{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
|
|
655
|
-
'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
|
|
656
|
-
].join('\n')
|
|
657
|
-
|
|
658
|
-
/** A throwaway agent config for one agent-hook run: read-only inspection tools, the
|
|
659
|
-
* hook's model (a fast default when unset), and the decision-returning system prompt. */
|
|
660
|
-
/** The agent a context: fork skill runs as when it names none: full toolset, no
|
|
661
|
-
* extra system prompt (the child keeps pi's default), the skill content as the
|
|
662
|
-
* task. */
|
|
663
|
-
function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
|
|
664
|
-
return {
|
|
665
|
-
name: 'fork',
|
|
666
|
-
description: 'forked skill run',
|
|
667
|
-
systemPrompt: request.systemPrompt ?? '',
|
|
668
|
-
...(request.model ? { model: request.model } : {}),
|
|
669
|
-
source: 'builtin',
|
|
670
|
-
filePath: '',
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
|
|
675
|
-
return {
|
|
676
|
-
name: 'agent-hook',
|
|
677
|
-
description: 'Verifies a hook condition using read-only inspection tools.',
|
|
678
|
-
tools: ['read', 'grep', 'find'],
|
|
679
|
-
model: request.model,
|
|
680
|
-
systemPrompt: request.systemPrompt ? `${AGENT_HOOK_SYSTEM}\n\n${request.systemPrompt}` : AGENT_HOOK_SYSTEM,
|
|
681
|
-
source: 'builtin',
|
|
682
|
-
filePath: '',
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/** The file-management tools a memory-enabled child needs for its store. */
|
|
687
|
-
const MEMORY_TOOLS = ['read', 'write', 'edit']
|
|
688
|
-
|
|
689
|
-
/** Where an agent's own persistent memory lives, per its `memory:` scope (Claude:
|
|
690
|
-
* user -> ~/.claude/agent-memory/<name>, project -> <root>/.claude/agent-memory/<name>,
|
|
691
|
-
* local -> <root>/.claude/agent-memory-local/<name>). The name comes from frontmatter
|
|
692
|
-
* a repository can control, so it is sanitized before becoming a path segment. */
|
|
693
|
-
export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: string, home: string): string {
|
|
694
|
-
const sanitized = name.replace(/[^\w.-]+/g, '_')
|
|
695
|
-
// A name of only dots ('.', '..') survives the character filter but still traverses.
|
|
696
|
-
const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
|
|
697
|
-
if (scope === 'user') return path.join(claudeConfigDir(home), 'agent-memory', segment)
|
|
698
|
-
const root = repoRoot(cwd) ?? cwd
|
|
699
|
-
return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/** The prompt section giving a memory-enabled child its own persistent store: the
|
|
703
|
-
* directory, read/write/curation instructions, and its MEMORY.md capped like the
|
|
704
|
-
* parent's index load (first 200 lines or 25KB, whichever comes first). */
|
|
705
|
-
export function agentMemorySection(dir: string, memoryMd: string): string {
|
|
706
|
-
const indexPath = path.join(dir, 'MEMORY.md')
|
|
707
|
-
const capped = capIndexForPrompt(memoryMd)
|
|
708
|
-
const current = capped.trim() ? `Current ${indexPath}:\n\n${capped}` : `${indexPath} does not exist yet; create it once you have something worth keeping.`
|
|
709
|
-
return [
|
|
710
|
-
'## Agent memory',
|
|
711
|
-
'',
|
|
712
|
-
`You have a persistent memory directory at ${dir} that survives across sessions.`,
|
|
713
|
-
'Use the read, write, and edit tools to record durable insights, project patterns, and lessons learned there, and consult them when relevant.',
|
|
714
|
-
`Only the first ${INDEX_MAX_LINES} lines or ${INDEX_MAX_BYTES} bytes of ${indexPath} are loaded at startup, so keep it a concise, curated index and move details into separate files in the directory.`,
|
|
715
|
-
'',
|
|
716
|
-
current,
|
|
717
|
-
].join('\n')
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/** The memory section for one run, or undefined when the agent declares no memory,
|
|
721
|
-
* auto memory is off, or a repo-scoped store is not approved. Subagent memory is part
|
|
722
|
-
* of auto memory, so the same settings chain and env kill switch gate it. */
|
|
723
|
-
function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
|
|
724
|
-
if (!agent.memory) return undefined
|
|
725
|
-
// project and local stores live under the repository's .claude, a repo-controlled
|
|
726
|
-
// path; like rules, they are only read once the project is approved.
|
|
727
|
-
if (agent.memory !== 'user' && !projectApproved) return undefined
|
|
728
|
-
const settings = readMemorySettings(memorySettingsFiles(cwd, os.homedir(), projectApproved))
|
|
729
|
-
if (!autoMemoryEnabled(settings.autoMemoryEnabled, process.env)) return undefined
|
|
730
|
-
const dir = agentMemoryDir(agent.memory, agent.name, cwd, os.homedir())
|
|
731
|
-
let memoryMd = ''
|
|
732
|
-
try {
|
|
733
|
-
memoryMd = fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf-8')
|
|
734
|
-
} catch {
|
|
735
|
-
// no store yet: the section still tells the child where to create one
|
|
736
|
-
}
|
|
737
|
-
return agentMemorySection(dir, memoryMd)
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/** Widen a restricted agent's allowlist so it can manage its memory files. An
|
|
741
|
-
* unrestricted agent (no allowlist) already has every tool. */
|
|
742
|
-
export function withMemoryTools(tools: string[] | undefined): string[] | undefined {
|
|
743
|
-
if (!tools || tools.length === 0) return tools
|
|
744
|
-
return [...tools, ...MEMORY_TOOLS.filter((tool) => !tools.includes(tool))]
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
/** The child's --append-system-prompt body: the skills-preloaded prompt plus the
|
|
748
|
-
* agent memory section, without a stray separator when either part is empty. */
|
|
749
|
-
function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection: string | undefined): string {
|
|
750
|
-
const prompt = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
|
|
751
|
-
if (!memorySection) return prompt
|
|
752
|
-
return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/** The parent's MCP tool aliases, published by the mcp extension on the shared bus;
|
|
756
|
-
* the module-level seam matches setMcpToolCaller's. Children read the same MCP config
|
|
757
|
-
* files, so the parent's roster is the translation table for server-level patterns. */
|
|
758
|
-
let knownMcpAliases: ReadonlyArray<{ pi: string; claude: string }> = []
|
|
759
|
-
|
|
760
|
-
export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude: string }>): void {
|
|
761
|
-
knownMcpAliases = aliases
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
|
|
765
|
-
* does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
|
|
766
|
-
* extensions register in a child. Claude's capitalized spellings fold onto these. */
|
|
767
|
-
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource', 'todo', 'question', 'memory', 'slash_command', 'plan_mode_complete'])
|
|
768
|
-
|
|
769
|
-
/** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
|
|
770
|
-
* to launch with an error naming the entries, instead of running tool-less. */
|
|
771
|
-
function unresolvedToolsError(agent: AgentConfig): string | undefined {
|
|
772
|
-
if (!agent.tools || agent.tools.length === 0) return undefined
|
|
773
|
-
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
774
|
-
const known = new Set(knownMcpAliases.map((alias) => fold(alias.pi)))
|
|
775
|
-
const resolves = expandMcpToolPatterns(agent.tools, knownMcpAliases).some((entry) => CHILD_TOOL_NAMES.has(fold(entry)) || known.has(fold(entry)))
|
|
776
|
-
if (resolves) return undefined
|
|
777
|
-
return `Agent "${agent.name}" would launch with zero tools: no entry in [${agent.tools.join(', ')}] resolves to a tool.`
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/** CLI args shared by foreground and background children, from the agent's config. */
|
|
781
|
-
function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
|
|
782
|
-
const args: string[] = ['--mode', 'json', '-p', '--no-session']
|
|
783
|
-
// A concrete model wins; otherwise a Claude tier alias resolved against the models
|
|
784
|
-
// this user can actually run; then CLAUDE_CODE_SUBAGENT_MODEL, per Claude's model
|
|
785
|
-
// order (invocation model, frontmatter model, this variable, the session model).
|
|
786
|
-
// pi reads a thinking level from the model pattern's :suffix when a model is
|
|
787
|
-
// pinned, and from --thinking otherwise.
|
|
788
|
-
// Claude exempts the two built-ins from the environment variable: "Setting
|
|
789
|
-
// CLAUDE_CODE_SUBAGENT_MODEL by itself doesn't change the model the built-in Explore and
|
|
790
|
-
// Plan subagents run on." A model they name themselves, or one the invocation names,
|
|
791
|
-
// still applies.
|
|
792
|
-
const exemptFromEnvModel = agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')
|
|
793
|
-
const model = agent.model ?? aliasModel ?? (exemptFromEnvModel ? undefined : process.env.CLAUDE_CODE_SUBAGENT_MODEL)
|
|
794
|
-
if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
|
|
795
|
-
else if (agent.effort) args.push('--thinking', agent.effort)
|
|
796
|
-
// Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
|
|
797
|
-
// without this a server-level deny removed nothing (fail open) and a server-level
|
|
798
|
-
// grant granted nothing.
|
|
799
|
-
if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
|
|
800
|
-
if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
|
|
801
|
-
// Claude: "Explore and Plan are the only subagents that omit CLAUDE.md" (and no
|
|
802
|
-
// field or setting changes which agents skip them), to keep research fast.
|
|
803
|
-
if (agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')) args.push('--no-context-files')
|
|
804
|
-
return args
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
/** Claude's agent-frontmatter hooks ride to the child as env; the child's hooks
|
|
808
|
-
* extension merges them for the run only (they die with the process, matching
|
|
809
|
-
* "only while that subagent is running"). Stop converts to SubagentStop, the
|
|
810
|
-
* event the child fires when it completes, as Claude documents. */
|
|
811
|
-
function agentHooksEnv(agent: AgentConfig, agentId: string): Record<string, string> {
|
|
812
|
-
if (!agent.hooks) return {}
|
|
813
|
-
const hooks: Record<string, unknown> = { ...agent.hooks }
|
|
814
|
-
const stop = hooks.Stop
|
|
815
|
-
delete hooks.Stop
|
|
816
|
-
if (Array.isArray(stop)) hooks.SubagentStop = [...(Array.isArray(hooks.SubagentStop) ? (hooks.SubagentStop as unknown[]) : []), ...stop]
|
|
817
|
-
return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
/** Whether a run belongs in the background: the caller asked, or Claude's
|
|
821
|
-
* `background: true` frontmatter keeps the agent there even on a foreground ask
|
|
822
|
-
* (single mode). */
|
|
823
|
-
function wantsBackground(params: { background?: boolean; agent?: string }, agents: AgentConfig[]): boolean {
|
|
824
|
-
if (params.background) return true
|
|
825
|
-
return params.agent !== undefined && agents.find((a) => a.name === params.agent)?.background === true
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/** The task argument with any SubagentStart hook context ahead of it, per Claude:
|
|
829
|
-
* "added to the subagent's context at the start of its conversation, before its
|
|
830
|
-
* first prompt". */
|
|
831
|
-
function taskWithStartContext(task: string, contexts: string[]): string {
|
|
832
|
-
const context = contexts.filter(Boolean).join('\n')
|
|
833
|
-
return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
837
|
-
return {
|
|
838
|
-
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
839
|
-
details: makeDetails('single')([]),
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefined): void {
|
|
844
|
-
if (!tmpPrompt) return
|
|
845
|
-
try {
|
|
846
|
-
fs.unlinkSync(tmpPrompt.filePath)
|
|
847
|
-
} catch {
|
|
848
|
-
/* ignore */
|
|
849
|
-
}
|
|
850
|
-
try {
|
|
851
|
-
fs.rmdirSync(tmpPrompt.dir)
|
|
852
|
-
} catch {
|
|
853
|
-
/* ignore */
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/** Everything runBackgroundMode needs from the surrounding execute() call, grouped so
|
|
858
|
-
* the parameter list stays in bounds. */
|
|
859
|
-
interface BackgroundContext {
|
|
860
|
-
agents: AgentConfig[]
|
|
861
|
-
defaultCwd: string
|
|
862
|
-
pi: ExtensionAPI
|
|
863
|
-
makeDetails: MakeDetails
|
|
864
|
-
skillRoots: string[]
|
|
865
|
-
availableModels: ReadonlyArray<{ id: string }>
|
|
866
|
-
projectApproved: boolean
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext): Promise<ToolResult> {
|
|
870
|
-
const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
|
|
871
|
-
const task = params.task
|
|
872
|
-
const agentName = params.agent
|
|
873
|
-
if (!task || !agentName) {
|
|
874
|
-
return {
|
|
875
|
-
content: [{ type: 'text', text: 'background: true requires single mode (agent + task).' }],
|
|
876
|
-
details: makeDetails('single')([]),
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
const agent = agents.find((a) => a.name === agentName)
|
|
880
|
-
if (!agent) {
|
|
881
|
-
const available = agents.map((a) => `"${a.name}"`).join(', ') || 'none'
|
|
882
|
-
return {
|
|
883
|
-
content: [{ type: 'text', text: `Unknown agent: "${agentName}". Available agents: ${available}.` }],
|
|
884
|
-
details: makeDetails('single')([]),
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
const toolsError = unresolvedToolsError(agent)
|
|
888
|
-
if (toolsError) {
|
|
889
|
-
return {
|
|
890
|
-
content: [{ type: 'text', text: toolsError }],
|
|
891
|
-
details: makeDetails('single')([]),
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
|
|
895
|
-
return backgroundCapResult(makeDetails)
|
|
896
|
-
}
|
|
897
|
-
const runCwd = params.cwd ?? defaultCwd
|
|
898
|
-
// The same isolation boundary as the foreground path: no worktree, no run.
|
|
899
|
-
let worktree: AgentWorktree | undefined
|
|
900
|
-
if (agent.isolation === 'worktree') {
|
|
901
|
-
const created = await createAgentWorktree(runCwd, agent.name)
|
|
902
|
-
if ('error' in created) {
|
|
903
|
-
return {
|
|
904
|
-
content: [{ type: 'text', text: `isolation: worktree could not be created for ${agent.name}: ${created.error}` }],
|
|
905
|
-
details: makeDetails('single')([]),
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
worktree = created
|
|
909
|
-
}
|
|
910
|
-
// Anchor project/local memory at the session project (defaultCwd), which is the one
|
|
911
|
-
// projectApproved gated; see the foreground path for why runCwd must not be used.
|
|
912
|
-
const memorySection = agentMemoryPromptSection(agent, defaultCwd, projectApproved)
|
|
913
|
-
const args = agentInvocationArgs(memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent, resolveModelAlias(agent.modelAlias, availableModels))
|
|
914
|
-
let tmpPrompt: { dir: string; filePath: string } | undefined
|
|
915
|
-
const promptBody = childPromptBody(agent, skillRoots, memorySection)
|
|
916
|
-
if (promptBody.trim()) {
|
|
917
|
-
tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
|
|
918
|
-
// Claude: the agent body replaces the default system prompt (see the
|
|
919
|
-
// foreground path).
|
|
920
|
-
args.push('--system-prompt', tmpPrompt.filePath)
|
|
921
|
-
}
|
|
922
|
-
// The id is preset so SubagentStart hooks run pre-spawn with the id the run
|
|
923
|
-
// will actually carry, and their context lands before the child's first prompt.
|
|
924
|
-
const presetId = `bg-${randomUUID().slice(0, 8)}`
|
|
925
|
-
const startContexts = await runSubagentStartHooks(agent.name, presetId)
|
|
926
|
-
args.push(taskWithStartContext(task, startContexts))
|
|
927
|
-
const invocation = getPiInvocation(args)
|
|
928
|
-
const id = startBackgroundRun(
|
|
929
|
-
agent.name,
|
|
930
|
-
task,
|
|
931
|
-
{ command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, env: agentHooksEnv(agent, presetId), promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns },
|
|
932
|
-
(run) => {
|
|
933
|
-
removeTmpPrompt(tmpPrompt)
|
|
934
|
-
const finish = (): void => {
|
|
935
|
-
// Both calls throw once the session that started the run is disposed. driveRun's
|
|
936
|
-
// catch covers the synchronous path, but the worktree branch reaches here from an
|
|
937
|
-
// async continuation outside it, so the guard must live in finish itself.
|
|
938
|
-
try {
|
|
939
|
-
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
|
|
940
|
-
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
941
|
-
} catch {
|
|
942
|
-
// Session disposed after the run outlived it; nothing to notify.
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
if (!worktree) {
|
|
946
|
-
finish()
|
|
947
|
-
return
|
|
948
|
-
}
|
|
949
|
-
// Cleanup only removes a pristine worktree; a kept one is reported in the
|
|
950
|
-
// completion text so the parent knows where the changes live.
|
|
951
|
-
const keptWorktree = worktree
|
|
952
|
-
void cleanupAgentWorktree(runCwd, keptWorktree)
|
|
953
|
-
.then((outcome) => {
|
|
954
|
-
if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
|
|
955
|
-
})
|
|
956
|
-
.catch(() => {})
|
|
957
|
-
.finally(finish)
|
|
958
|
-
},
|
|
959
|
-
presetId,
|
|
960
|
-
)
|
|
961
|
-
if (id === null) {
|
|
962
|
-
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
963
|
-
removeTmpPrompt(tmpPrompt)
|
|
964
|
-
return backgroundCapResult(makeDetails)
|
|
965
|
-
}
|
|
966
|
-
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
967
|
-
return {
|
|
968
|
-
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
969
|
-
details: makeDetails('single')([]),
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise<ToolResult> {
|
|
974
|
-
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
975
|
-
const results: SingleResult[] = []
|
|
976
|
-
let previousOutput = ''
|
|
977
|
-
|
|
978
|
-
for (let i = 0; i < chain.length; i++) {
|
|
979
|
-
const step = chain[i]
|
|
980
|
-
// Function replacement: a string here would interpret $-patterns in the output.
|
|
981
|
-
const taskWithContext = step.task.replaceAll('{previous}', () => previousOutput)
|
|
982
|
-
|
|
983
|
-
// Create update callback that includes all previous results
|
|
984
|
-
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
985
|
-
? (partial) => {
|
|
986
|
-
// Combine completed results with current streaming result
|
|
987
|
-
const currentResult = partial.details?.results[0]
|
|
988
|
-
if (currentResult) {
|
|
989
|
-
const allResults = [...results, currentResult]
|
|
990
|
-
onUpdate({
|
|
991
|
-
content: partial.content,
|
|
992
|
-
details: makeDetails('chain')(allResults),
|
|
993
|
-
})
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
: undefined
|
|
997
|
-
|
|
998
|
-
const result = await runSingleAgent({
|
|
999
|
-
defaultCwd,
|
|
1000
|
-
agents,
|
|
1001
|
-
agentName: step.agent,
|
|
1002
|
-
task: taskWithContext,
|
|
1003
|
-
cwd: step.cwd,
|
|
1004
|
-
step: i + 1,
|
|
1005
|
-
signal,
|
|
1006
|
-
onUpdate: chainUpdate,
|
|
1007
|
-
makeDetails: makeDetails('chain'),
|
|
1008
|
-
onPhase: mode.onPhase,
|
|
1009
|
-
skillRoots: mode.skillRoots,
|
|
1010
|
-
availableModels: mode.availableModels,
|
|
1011
|
-
projectApproved: mode.projectApproved,
|
|
1012
|
-
})
|
|
1013
|
-
results.push(result)
|
|
1014
|
-
|
|
1015
|
-
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
1016
|
-
if (isError) {
|
|
1017
|
-
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
1018
|
-
return {
|
|
1019
|
-
content: [{ type: 'text', text: capForContext(`Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`) }],
|
|
1020
|
-
details: makeDetails('chain')(results),
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
previousOutput = capForContext(getFinalOutput(result.messages))
|
|
1024
|
-
}
|
|
1025
|
-
return {
|
|
1026
|
-
content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
|
|
1027
|
-
details: makeDetails('chain')(results),
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promise<ToolResult> {
|
|
1032
|
-
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
1033
|
-
if (tasks.length > MAX_PARALLEL_TASKS)
|
|
1034
|
-
return {
|
|
1035
|
-
content: [
|
|
1036
|
-
{
|
|
1037
|
-
type: 'text',
|
|
1038
|
-
text: `Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
1039
|
-
},
|
|
1040
|
-
],
|
|
1041
|
-
details: makeDetails('parallel')([]),
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// Track all results for streaming updates
|
|
1045
|
-
const allResults: SingleResult[] = new Array(tasks.length)
|
|
1046
|
-
|
|
1047
|
-
// Initialize placeholder results
|
|
1048
|
-
for (let i = 0; i < tasks.length; i++) {
|
|
1049
|
-
allResults[i] = {
|
|
1050
|
-
agent: tasks[i].agent,
|
|
1051
|
-
agentSource: 'unknown',
|
|
1052
|
-
task: tasks[i].task,
|
|
1053
|
-
exitCode: -1, // -1 = still running
|
|
1054
|
-
messages: [],
|
|
1055
|
-
stderr: '',
|
|
1056
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
const emitParallelUpdate = () => {
|
|
1061
|
-
if (onUpdate) {
|
|
1062
|
-
const running = allResults.filter((r) => r.exitCode === -1).length
|
|
1063
|
-
const done = allResults.filter((r) => r.exitCode !== -1).length
|
|
1064
|
-
onUpdate({
|
|
1065
|
-
content: [{ type: 'text', text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
|
1066
|
-
details: makeDetails('parallel')([...allResults]),
|
|
1067
|
-
})
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
1072
|
-
const result = await runSingleAgent({
|
|
1073
|
-
defaultCwd,
|
|
1074
|
-
agents,
|
|
1075
|
-
agentName: t.agent,
|
|
1076
|
-
task: t.task,
|
|
1077
|
-
cwd: t.cwd,
|
|
1078
|
-
signal,
|
|
1079
|
-
onPhase: mode.onPhase,
|
|
1080
|
-
// Same context single and chain mode pass: without these, an agent's skills
|
|
1081
|
-
// preload and its model tier alias silently do nothing in parallel mode only.
|
|
1082
|
-
skillRoots: mode.skillRoots,
|
|
1083
|
-
availableModels: mode.availableModels,
|
|
1084
|
-
projectApproved: mode.projectApproved,
|
|
1085
|
-
// Per-task update callback
|
|
1086
|
-
onUpdate: (partial) => {
|
|
1087
|
-
const live = partial.details?.results[0]
|
|
1088
|
-
if (live) {
|
|
1089
|
-
// Keep the running sentinel until the child closes: the streamed result carries
|
|
1090
|
-
// exitCode 0 mid-run, which would otherwise count and render the task as done.
|
|
1091
|
-
allResults[index] = { ...live, exitCode: -1 }
|
|
1092
|
-
emitParallelUpdate()
|
|
1093
|
-
}
|
|
1094
|
-
},
|
|
1095
|
-
makeDetails: makeDetails('parallel'),
|
|
1096
|
-
})
|
|
1097
|
-
allResults[index] = result
|
|
1098
|
-
emitParallelUpdate()
|
|
1099
|
-
return result
|
|
1100
|
-
})
|
|
1101
|
-
|
|
1102
|
-
const successCount = results.filter((r) => r.exitCode === 0).length
|
|
1103
|
-
const summaries = results.map((r) => {
|
|
1104
|
-
const output = getFinalOutput(r.messages)
|
|
1105
|
-
const status = r.exitCode === 0 ? 'completed' : 'failed'
|
|
1106
|
-
return `[${r.agent}] ${status}: ${output || '(no output)'}`
|
|
1107
|
-
})
|
|
1108
|
-
return {
|
|
1109
|
-
content: [
|
|
1110
|
-
{
|
|
1111
|
-
type: 'text',
|
|
1112
|
-
// Full reports, so a fan-out can be synthesized from; capped at pi's tool-output budget.
|
|
1113
|
-
text: capForContext(`Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`),
|
|
1114
|
-
},
|
|
1115
|
-
],
|
|
1116
|
-
details: makeDetails('parallel')(results),
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
async function runSingleMode(agentName: string, task: string, cwd: string | undefined, mode: ModeContext): Promise<ToolResult> {
|
|
1121
|
-
const { agents, defaultCwd, signal, onUpdate, makeDetails } = mode
|
|
1122
|
-
const result = await runSingleAgent({
|
|
1123
|
-
defaultCwd,
|
|
1124
|
-
agents,
|
|
1125
|
-
agentName,
|
|
1126
|
-
task,
|
|
1127
|
-
cwd,
|
|
1128
|
-
signal,
|
|
1129
|
-
onUpdate,
|
|
1130
|
-
makeDetails: makeDetails('single'),
|
|
1131
|
-
onPhase: mode.onPhase,
|
|
1132
|
-
skillRoots: mode.skillRoots,
|
|
1133
|
-
availableModels: mode.availableModels,
|
|
1134
|
-
projectApproved: mode.projectApproved,
|
|
1135
|
-
})
|
|
1136
|
-
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
1137
|
-
if (isError) {
|
|
1138
|
-
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
|
|
1139
|
-
return {
|
|
1140
|
-
content: [{ type: 'text', text: capForContext(`Agent ${result.stopReason || 'failed'}: ${errorMsg}`) }],
|
|
1141
|
-
details: makeDetails('single')([result]),
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
return {
|
|
1145
|
-
content: [{ type: 'text', text: capForContext(getFinalOutput(result.messages)) || '(no output)' }],
|
|
1146
|
-
details: makeDetails('single')([result]),
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
interface CallItem {
|
|
1151
|
-
agent: string
|
|
1152
|
-
task: string
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function renderChainCall(chain: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
|
|
1156
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + scopeTag(scope, theme)
|
|
1157
|
-
for (let i = 0; i < Math.min(chain.length, 3); i++) {
|
|
1158
|
-
const step = chain[i]
|
|
1159
|
-
// Clean up {previous} placeholder for display
|
|
1160
|
-
const cleanTask = step.task.replaceAll('{previous}', '').trim()
|
|
1161
|
-
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask
|
|
1162
|
-
const stepNumber = theme.fg('muted', `${i + 1}.`)
|
|
1163
|
-
const stepLabel = theme.fg('accent', step.agent) + theme.fg('dim', ` ${preview}`)
|
|
1164
|
-
text += `\n ${stepNumber} ${stepLabel}`
|
|
1165
|
-
}
|
|
1166
|
-
if (chain.length > 3) {
|
|
1167
|
-
const more = theme.fg('muted', `... +${chain.length - 3} more`)
|
|
1168
|
-
text += `\n ${more}`
|
|
1169
|
-
}
|
|
1170
|
-
return new Text(text, 0, 0)
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
function renderParallelCall(tasks: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
|
|
1174
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + scopeTag(scope, theme)
|
|
1175
|
-
for (const t of tasks.slice(0, 3)) {
|
|
1176
|
-
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
|
|
1177
|
-
const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
|
|
1178
|
-
text += `\n ${taskLabel}`
|
|
1179
|
-
}
|
|
1180
|
-
if (tasks.length > 3) {
|
|
1181
|
-
const more = theme.fg('muted', `... +${tasks.length - 3} more`)
|
|
1182
|
-
text += `\n ${more}`
|
|
1183
|
-
}
|
|
1184
|
-
return new Text(text, 0, 0)
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
const scopeTag = (scope: AgentScope | undefined, theme: Theme): string => (scope ? theme.fg('muted', ` [${scope}]`) : '')
|
|
1188
|
-
|
|
1189
|
-
function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope | undefined, theme: Theme): Text {
|
|
1190
|
-
const agentName = agent || '...'
|
|
1191
|
-
let preview = '...'
|
|
1192
|
-
if (task) preview = task.length > 60 ? `${task.slice(0, 60)}...` : task
|
|
1193
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + scopeTag(scope, theme)
|
|
1194
|
-
text += `\n ${theme.fg('dim', preview)}`
|
|
1195
|
-
return new Text(text, 0, 0)
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
type MarkdownTheme = ReturnType<typeof getMarkdownTheme>
|
|
1199
|
-
|
|
1200
|
-
function aggregateUsage(results: SingleResult[]) {
|
|
1201
|
-
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }
|
|
1202
|
-
for (const r of results) {
|
|
1203
|
-
total.input += r.usage.input
|
|
1204
|
-
total.output += r.usage.output
|
|
1205
|
-
total.cacheRead += r.usage.cacheRead
|
|
1206
|
-
total.cacheWrite += r.usage.cacheWrite
|
|
1207
|
-
total.cost += r.usage.cost
|
|
1208
|
-
total.turns += r.usage.turns
|
|
1209
|
-
}
|
|
1210
|
-
return total
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
function renderDisplayItems(items: DisplayItem[], expanded: boolean, theme: Theme, limit?: number): string {
|
|
1214
|
-
const toShow = limit ? items.slice(-limit) : items
|
|
1215
|
-
const skipped = limit && items.length > limit ? items.length - limit : 0
|
|
1216
|
-
let text = ''
|
|
1217
|
-
if (skipped > 0) text += theme.fg('muted', `... ${skipped} earlier items\n`)
|
|
1218
|
-
for (const item of toShow) {
|
|
1219
|
-
if (item.type === 'text') {
|
|
1220
|
-
const preview = expanded ? item.text : item.text.split('\n').slice(0, 3).join('\n')
|
|
1221
|
-
text += `${theme.fg('toolOutput', preview)}\n`
|
|
1222
|
-
} else {
|
|
1223
|
-
text += `${theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
return text.trimEnd()
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
function addToolCallNodes(container: Container, items: DisplayItem[], theme: Theme): void {
|
|
1230
|
-
for (const item of items) {
|
|
1231
|
-
if (item.type === 'toolCall') {
|
|
1232
|
-
container.addChild(new Text(theme.fg('muted', '→ ') + formatToolCall(item.name, item.args, theme.fg.bind(theme)), 0, 0))
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function addTotalUsage(container: Container, results: SingleResult[], theme: Theme): void {
|
|
1238
|
-
const usageStr = formatUsageStats(aggregateUsage(results))
|
|
1239
|
-
if (usageStr) {
|
|
1240
|
-
container.addChild(new Spacer(1))
|
|
1241
|
-
const totalLine = theme.fg('dim', `Total: ${usageStr}`)
|
|
1242
|
-
container.addChild(new Text(totalLine, 0, 0))
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function renderSingleExpanded(r: SingleResult, isError: boolean, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
|
|
1247
|
-
const container = new Container()
|
|
1248
|
-
const source = theme.fg('muted', ` (${r.agentSource})`)
|
|
1249
|
-
let header = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
|
|
1250
|
-
if (isError && r.stopReason) {
|
|
1251
|
-
const reason = theme.fg('error', `[${r.stopReason}]`)
|
|
1252
|
-
header += ` ${reason}`
|
|
1253
|
-
}
|
|
1254
|
-
container.addChild(new Text(header, 0, 0))
|
|
1255
|
-
if (isError && r.errorMessage) container.addChild(new Text(theme.fg('error', `Error: ${r.errorMessage}`), 0, 0))
|
|
1256
|
-
container.addChild(new Spacer(1))
|
|
1257
|
-
container.addChild(new Text(theme.fg('muted', '─── Task ───'), 0, 0))
|
|
1258
|
-
container.addChild(new Text(theme.fg('dim', r.task), 0, 0))
|
|
1259
|
-
container.addChild(new Spacer(1))
|
|
1260
|
-
container.addChild(new Text(theme.fg('muted', '─── Output ───'), 0, 0))
|
|
1261
|
-
|
|
1262
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1263
|
-
const finalOutput = getFinalOutput(r.messages)
|
|
1264
|
-
if (displayItems.length === 0 && !finalOutput) {
|
|
1265
|
-
container.addChild(new Text(theme.fg('muted', '(no output)'), 0, 0))
|
|
1266
|
-
} else {
|
|
1267
|
-
addToolCallNodes(container, displayItems, theme)
|
|
1268
|
-
if (finalOutput) {
|
|
1269
|
-
container.addChild(new Spacer(1))
|
|
1270
|
-
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const usageStr = formatUsageStats(r.usage, r.model)
|
|
1275
|
-
if (usageStr) {
|
|
1276
|
-
container.addChild(new Spacer(1))
|
|
1277
|
-
container.addChild(new Text(theme.fg('dim', usageStr), 0, 0))
|
|
1278
|
-
}
|
|
1279
|
-
return container
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
function renderSingleCollapsed(r: SingleResult, isError: boolean, icon: string, theme: Theme, expanded: boolean): Text {
|
|
1283
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1284
|
-
const source = theme.fg('muted', ` (${r.agentSource})`)
|
|
1285
|
-
let text = `${icon} ${theme.fg('toolTitle', theme.bold(r.agent))}${source}`
|
|
1286
|
-
if (isError && r.stopReason) {
|
|
1287
|
-
const reason = theme.fg('error', `[${r.stopReason}]`)
|
|
1288
|
-
text += ` ${reason}`
|
|
1289
|
-
}
|
|
1290
|
-
if (isError && r.errorMessage) {
|
|
1291
|
-
const errorLine = theme.fg('error', `Error: ${r.errorMessage}`)
|
|
1292
|
-
text += `\n${errorLine}`
|
|
1293
|
-
} else if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
|
|
1294
|
-
else {
|
|
1295
|
-
text += `\n${renderDisplayItems(displayItems, expanded, theme, COLLAPSED_ITEM_COUNT)}`
|
|
1296
|
-
if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
|
|
1297
|
-
}
|
|
1298
|
-
const usageStr = formatUsageStats(r.usage, r.model)
|
|
1299
|
-
if (usageStr) text += `\n${theme.fg('dim', usageStr)}`
|
|
1300
|
-
return new Text(text, 0, 0)
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
function renderSingleResult(r: SingleResult, expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
|
|
1304
|
-
const isError = r.exitCode !== 0 || r.stopReason === 'error' || r.stopReason === 'aborted'
|
|
1305
|
-
const icon = isError ? theme.fg('error', '✗') : theme.fg('success', '✓')
|
|
1306
|
-
if (expanded) return renderSingleExpanded(r, isError, icon, theme, mdTheme)
|
|
1307
|
-
return renderSingleCollapsed(r, isError, icon, theme, expanded)
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function renderChainExpanded(results: SingleResult[], successCount: number, icon: string, theme: Theme, mdTheme: MarkdownTheme): Container {
|
|
1311
|
-
const container = new Container()
|
|
1312
|
-
const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
|
|
1313
|
-
container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`, 0, 0))
|
|
1314
|
-
|
|
1315
|
-
for (const r of results) {
|
|
1316
|
-
const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
|
|
1317
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1318
|
-
const finalOutput = getFinalOutput(r.messages)
|
|
1319
|
-
|
|
1320
|
-
container.addChild(new Spacer(1))
|
|
1321
|
-
const stepLabel = theme.fg('muted', `─── Step ${r.step}: `) + theme.fg('accent', r.agent)
|
|
1322
|
-
container.addChild(new Text(`${stepLabel} ${rIcon}`, 0, 0))
|
|
1323
|
-
container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
|
|
1324
|
-
|
|
1325
|
-
addToolCallNodes(container, displayItems, theme)
|
|
1326
|
-
|
|
1327
|
-
if (finalOutput) {
|
|
1328
|
-
container.addChild(new Spacer(1))
|
|
1329
|
-
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
const stepUsage = formatUsageStats(r.usage, r.model)
|
|
1333
|
-
if (stepUsage) container.addChild(new Text(theme.fg('dim', stepUsage), 0, 0))
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
addTotalUsage(container, results, theme)
|
|
1337
|
-
return container
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
function renderChainCollapsed(results: SingleResult[], successCount: number, icon: string, theme: Theme, expanded: boolean): Text {
|
|
1341
|
-
const summary = theme.fg('accent', `${successCount}/${results.length} steps`)
|
|
1342
|
-
let text = `${icon} ${theme.fg('toolTitle', theme.bold('chain '))}${summary}`
|
|
1343
|
-
for (const r of results) {
|
|
1344
|
-
const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
|
|
1345
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1346
|
-
const stepLabel = theme.fg('muted', `─── Step ${r.step}: `)
|
|
1347
|
-
text += `\n\n${stepLabel}${theme.fg('accent', r.agent)} ${rIcon}`
|
|
1348
|
-
if (displayItems.length === 0) text += `\n${theme.fg('muted', '(no output)')}`
|
|
1349
|
-
else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
|
|
1350
|
-
}
|
|
1351
|
-
const usageStr = formatUsageStats(aggregateUsage(results))
|
|
1352
|
-
if (usageStr) {
|
|
1353
|
-
const totalLine = theme.fg('dim', `Total: ${usageStr}`)
|
|
1354
|
-
text += `\n\n${totalLine}`
|
|
1355
|
-
}
|
|
1356
|
-
text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
|
|
1357
|
-
return new Text(text, 0, 0)
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
function renderChainResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
|
|
1361
|
-
const successCount = results.filter((r) => r.exitCode === 0).length
|
|
1362
|
-
const icon = successCount === results.length ? theme.fg('success', '✓') : theme.fg('error', '✗')
|
|
1363
|
-
if (expanded) return renderChainExpanded(results, successCount, icon, theme, mdTheme)
|
|
1364
|
-
return renderChainCollapsed(results, successCount, icon, theme, expanded)
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
function renderParallelExpanded(results: SingleResult[], icon: string, status: string, theme: Theme, mdTheme: MarkdownTheme): Container {
|
|
1368
|
-
const container = new Container()
|
|
1369
|
-
const summary = theme.fg('accent', status)
|
|
1370
|
-
container.addChild(new Text(`${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`, 0, 0))
|
|
1371
|
-
|
|
1372
|
-
for (const r of results) {
|
|
1373
|
-
const rIcon = r.exitCode === 0 ? theme.fg('success', '✓') : theme.fg('error', '✗')
|
|
1374
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1375
|
-
const finalOutput = getFinalOutput(r.messages)
|
|
1376
|
-
|
|
1377
|
-
container.addChild(new Spacer(1))
|
|
1378
|
-
const agentLabel = theme.fg('muted', '─── ') + theme.fg('accent', r.agent)
|
|
1379
|
-
container.addChild(new Text(`${agentLabel} ${rIcon}`, 0, 0))
|
|
1380
|
-
container.addChild(new Text(theme.fg('muted', 'Task: ') + theme.fg('dim', r.task), 0, 0))
|
|
1381
|
-
|
|
1382
|
-
addToolCallNodes(container, displayItems, theme)
|
|
1383
|
-
|
|
1384
|
-
if (finalOutput) {
|
|
1385
|
-
container.addChild(new Spacer(1))
|
|
1386
|
-
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme))
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
const taskUsage = formatUsageStats(r.usage, r.model)
|
|
1390
|
-
if (taskUsage) container.addChild(new Text(theme.fg('dim', taskUsage), 0, 0))
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
addTotalUsage(container, results, theme)
|
|
1394
|
-
return container
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
function renderParallelCollapsed(results: SingleResult[], icon: string, status: string, theme: Theme, expanded: boolean, isRunning: boolean): Text {
|
|
1398
|
-
const summary = theme.fg('accent', status)
|
|
1399
|
-
let text = `${icon} ${theme.fg('toolTitle', theme.bold('parallel '))}${summary}`
|
|
1400
|
-
for (const r of results) {
|
|
1401
|
-
let rIcon = theme.fg('error', '✗')
|
|
1402
|
-
if (r.exitCode === -1) rIcon = theme.fg('warning', '⏳')
|
|
1403
|
-
else if (r.exitCode === 0) rIcon = theme.fg('success', '✓')
|
|
1404
|
-
const displayItems = getDisplayItems(r.messages)
|
|
1405
|
-
text += `\n\n${theme.fg('muted', '─── ')}${theme.fg('accent', r.agent)} ${rIcon}`
|
|
1406
|
-
if (displayItems.length === 0) {
|
|
1407
|
-
const placeholder = r.exitCode === -1 ? '(running...)' : '(no output)'
|
|
1408
|
-
text += `\n${theme.fg('muted', placeholder)}`
|
|
1409
|
-
} else text += `\n${renderDisplayItems(displayItems, expanded, theme, 5)}`
|
|
1410
|
-
}
|
|
1411
|
-
if (!isRunning) {
|
|
1412
|
-
const usageStr = formatUsageStats(aggregateUsage(results))
|
|
1413
|
-
if (usageStr) {
|
|
1414
|
-
const totalLine = theme.fg('dim', `Total: ${usageStr}`)
|
|
1415
|
-
text += `\n\n${totalLine}`
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
if (!expanded) text += `\n${theme.fg('muted', '(Ctrl+O to expand)')}`
|
|
1419
|
-
return new Text(text, 0, 0)
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
function renderParallelResult(results: SingleResult[], expanded: boolean, theme: Theme, mdTheme: MarkdownTheme): Container | Text {
|
|
1423
|
-
const running = results.filter((r) => r.exitCode === -1).length
|
|
1424
|
-
const successCount = results.filter((r) => r.exitCode === 0).length
|
|
1425
|
-
const failCount = results.filter((r) => r.exitCode > 0).length
|
|
1426
|
-
const isRunning = running > 0
|
|
1427
|
-
|
|
1428
|
-
let icon = theme.fg('success', '✓')
|
|
1429
|
-
if (isRunning) icon = theme.fg('warning', '⏳')
|
|
1430
|
-
else if (failCount > 0) icon = theme.fg('warning', '◐')
|
|
1431
|
-
|
|
1432
|
-
let status = `${successCount}/${results.length} tasks`
|
|
1433
|
-
if (isRunning) status = `${successCount + failCount}/${results.length} done, ${running} running`
|
|
1434
|
-
|
|
1435
|
-
if (expanded && !isRunning) return renderParallelExpanded(results, icon, status, theme, mdTheme)
|
|
1436
|
-
return renderParallelCollapsed(results, icon, status, theme, expanded, isRunning)
|
|
1437
|
-
}
|
|
47
|
+
export { getPiInvocation } from './run.js'
|
|
1438
48
|
|
|
1439
49
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1440
50
|
// Claude's mcp__<server> tool patterns translate against the parent's MCP roster,
|