pi-code 1.0.31 → 1.0.33
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/hooks/config.ts +19 -0
- package/extensions/hooks/index.ts +49 -8
- package/extensions/internal/subagent-events.ts +3 -0
- package/extensions/internal/subagent-hooks.ts +26 -0
- package/extensions/subagent/agents.ts +33 -9
- package/extensions/subagent/background.ts +14 -6
- package/extensions/subagent/index.ts +178 -49
- package/package.json +1 -1
|
@@ -115,6 +115,25 @@ export function mergeSkillHooks(config: HooksConfig, skillName: string, hooks: u
|
|
|
115
115
|
mergeHooksJson(config, JSON.stringify({ hooks }), `${skillName} (skill)`, sources, `skill:${skillName}`)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Claude's agent-frontmatter hooks, inside the subagent child: the parent passes
|
|
119
|
+
* them via PI_CODE_AGENT_HOOKS (Stop already converted to SubagentStop), and they
|
|
120
|
+
* run only while this child runs because they die with the process. Returns the
|
|
121
|
+
* agent identity for the child's SubagentStop firing, or undefined outside a
|
|
122
|
+
* subagent or with no hooks passed. */
|
|
123
|
+
export function mergeAgentEnvHooks(config: HooksConfig, sources?: Map<HookMatcher, string>): { agent: string; id?: string } | undefined {
|
|
124
|
+
if (process.env.PI_CODE_SUBAGENT !== '1') return undefined
|
|
125
|
+
const raw = process.env.PI_CODE_AGENT_HOOKS
|
|
126
|
+
if (!raw) return undefined
|
|
127
|
+
try {
|
|
128
|
+
const parsed: unknown = JSON.parse(raw)
|
|
129
|
+
if (!isRecord(parsed) || typeof parsed.agent !== 'string' || !isRecord(parsed.hooks)) return undefined
|
|
130
|
+
mergeHooksJson(config, JSON.stringify({ hooks: parsed.hooks }), `${parsed.agent} (agent)`, sources, `agent:${parsed.agent}`)
|
|
131
|
+
return { agent: parsed.agent, ...(typeof parsed.id === 'string' ? { id: parsed.id } : {}) }
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
118
137
|
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
119
138
|
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
120
139
|
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
@@ -64,9 +64,13 @@
|
|
|
64
64
|
* (asyncRewake keeps its own), and hooks still running at session end are killed,
|
|
65
65
|
* as Claude does at teardown.
|
|
66
66
|
*
|
|
67
|
-
* SubagentStart
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* SubagentStart runs through the pre-spawn seam (internal/subagent-hooks) so its
|
|
68
|
+
* additionalContext reaches the child before its first prompt; it cannot block a
|
|
69
|
+
* spawn, as Claude documents. SubagentStop rides the subagent extension's bus stop
|
|
70
|
+
* event (notify-style: the child has already exited, so exit-2 block semantics
|
|
71
|
+
* cannot be honored) and carries last_assistant_message. Inside a subagent child,
|
|
72
|
+
* agent-frontmatter hooks arrive via PI_CODE_AGENT_HOOKS (Stop pre-converted to
|
|
73
|
+
* SubagentStop, fired at the child's own agent end) and die with the process.
|
|
70
74
|
*
|
|
71
75
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
72
76
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -99,8 +103,9 @@ import { isProjectApproved } from '../internal/project-approval.js'
|
|
|
99
103
|
import { repoRoot } from '../internal/project-root.js'
|
|
100
104
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
101
105
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
106
|
+
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
102
107
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
103
|
-
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
108
|
+
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
104
109
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
105
110
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
106
111
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
@@ -199,6 +204,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
199
204
|
idlePromptTimer = undefined
|
|
200
205
|
}
|
|
201
206
|
let sessionCtx: ExtensionContext | undefined
|
|
207
|
+
/** Set inside a subagent child that carries agent-frontmatter hooks: the child's
|
|
208
|
+
* own agent end fires their SubagentStop, per Claude's Stop conversion. */
|
|
209
|
+
let agentIdentity: { agent: string; id?: string } | undefined
|
|
202
210
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
203
211
|
let hooksDisabled = false
|
|
204
212
|
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
@@ -337,12 +345,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
337
345
|
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
338
346
|
// captured at session_start supplies the common payload fields.
|
|
339
347
|
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
340
|
-
|
|
348
|
+
// SubagentStart runs through the pre-spawn seam below (so its context can
|
|
349
|
+
// reach the child before its first prompt); the bus start event would
|
|
350
|
+
// double-run it, so only the stop phase is handled here.
|
|
351
|
+
if (!isSubagentPhaseEvent(data) || data.phase !== 'stop' || !sessionCtx) return
|
|
341
352
|
const ctx = sessionCtx
|
|
342
|
-
|
|
343
|
-
|
|
353
|
+
// Claude's SubagentStop carries the subagent's final text; agent_transcript_path
|
|
354
|
+
// stays absent (a --no-session child writes no transcript, see docs/hooks.md).
|
|
355
|
+
const payload = { hook_event_name: 'SubagentStop', agent_type: data.agentType, agent_id: data.agentId, ...(data.lastAssistantMessage !== undefined ? { last_assistant_message: data.lastAssistantMessage } : {}) }
|
|
344
356
|
try {
|
|
345
|
-
const results = await runNotifyHooks(matchingCommands(config
|
|
357
|
+
const results = await runNotifyHooks(matchingCommands(config.SubagentStop, data.agentType), payload, boundRunner(ctx))
|
|
346
358
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
347
359
|
} catch {
|
|
348
360
|
// The bus outlives the session: an event landing between /new disposing this
|
|
@@ -351,6 +363,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
351
363
|
}
|
|
352
364
|
})
|
|
353
365
|
|
|
366
|
+
// Claude's SubagentStart hooks inject additionalContext into the subagent before
|
|
367
|
+
// its first prompt, so they must run before the spawn: the subagent extension
|
|
368
|
+
// calls this seam pre-spawn and prepends the returned context to the child's task.
|
|
369
|
+
setSubagentStartHookRunner(async (agentType, agentId) => {
|
|
370
|
+
if (!sessionCtx) return []
|
|
371
|
+
const ctx = sessionCtx
|
|
372
|
+
const commands = matchingCommands(config.SubagentStart, agentType)
|
|
373
|
+
if (commands.length === 0) return []
|
|
374
|
+
const results = await runNotifyHooks(commands, { hook_event_name: 'SubagentStart', agent_type: agentType, agent_id: agentId }, boundRunner(ctx))
|
|
375
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
376
|
+
return results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
377
|
+
})
|
|
378
|
+
|
|
354
379
|
pi.on('session_start', async (event, ctx) => {
|
|
355
380
|
sessionCtx = ctx
|
|
356
381
|
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
@@ -387,6 +412,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
387
412
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
388
413
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
389
414
|
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
415
|
+
// Inside a subagent child, the parent passes the agent's frontmatter hooks via
|
|
416
|
+
// env (Stop already converted to SubagentStop, per Claude); they run only for
|
|
417
|
+
// this child process.
|
|
418
|
+
agentIdentity = mergeAgentEnvHooks(config, hookSources)
|
|
390
419
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
391
420
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
392
421
|
if (event.reason === 'reload') return
|
|
@@ -558,6 +587,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
558
587
|
idlePromptTimer.unref?.()
|
|
559
588
|
}
|
|
560
589
|
|
|
590
|
+
// In a subagent child, the agent-frontmatter Stop hooks were converted to
|
|
591
|
+
// SubagentStop and fire here, at the child's own end, notify-style; before the
|
|
592
|
+
// Stop early-returns, which do not apply to them.
|
|
593
|
+
if (agentIdentity) {
|
|
594
|
+
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent)
|
|
595
|
+
if (subStop.length > 0) {
|
|
596
|
+
const subText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
597
|
+
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
598
|
+
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
561
602
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
562
603
|
// and an `if`-carrying hook never runs on a non-tool event.
|
|
563
604
|
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
@@ -10,6 +10,9 @@ export interface SubagentPhaseEvent {
|
|
|
10
10
|
phase: 'start' | 'stop'
|
|
11
11
|
agentType: string
|
|
12
12
|
agentId: string
|
|
13
|
+
/** The run's final assistant text, on stop: Claude's SubagentStop delivers it
|
|
14
|
+
* as last_assistant_message so hooks need not parse a transcript. */
|
|
15
|
+
lastAssistantMessage?: string
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function isSubagentPhaseEvent(data: unknown): data is SubagentPhaseEvent {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-spawn seam for Claude's SubagentStart hooks. The hooks extension registers
|
|
3
|
+
* the runner; the subagent extension calls it before spawning a child, so the
|
|
4
|
+
* hooks' additionalContext can be injected before the child's first prompt,
|
|
5
|
+
* which the after-the-fact bus event structurally cannot do. Same module-seam
|
|
6
|
+
* pattern as mcp-call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type SubagentStartHookRunner = (agentType: string, agentId: string) => Promise<string[]>
|
|
10
|
+
|
|
11
|
+
let runner: SubagentStartHookRunner | undefined
|
|
12
|
+
|
|
13
|
+
export function setSubagentStartHookRunner(fn: SubagentStartHookRunner | undefined): void {
|
|
14
|
+
runner = fn
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Context strings SubagentStart hooks contribute; empty when no runner is
|
|
18
|
+
* registered or the runner fails (hooks must never block a spawn). */
|
|
19
|
+
export async function runSubagentStartHooks(agentType: string, agentId: string): Promise<string[]> {
|
|
20
|
+
if (!runner) return []
|
|
21
|
+
try {
|
|
22
|
+
return await runner(agentType, agentId)
|
|
23
|
+
} catch {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -157,8 +157,23 @@ function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
|
157
157
|
* the intent into a read-only toolset unless the file pins tools itself. */
|
|
158
158
|
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
|
|
159
159
|
|
|
160
|
+
/** The agent's name per Claude's naming rules: a plugin agent registers under the
|
|
161
|
+
* scoped id `<plugin>:<name>` with the filename standing in for a missing name;
|
|
162
|
+
* elsewhere the frontmatter name is required and `:` is reserved for plugin ids,
|
|
163
|
+
* so a file carrying one is not loaded. */
|
|
164
|
+
function agentName(frontmatter: Record<string, unknown>, filePath: string, pluginName?: string): string | null {
|
|
165
|
+
const declared = typeof frontmatter.name === 'string' ? frontmatter.name.trim() : ''
|
|
166
|
+
if (pluginName !== undefined) return `${pluginName}:${declared || path.basename(filePath, '.md')}`
|
|
167
|
+
if (!declared) return null
|
|
168
|
+
if (declared.includes(':')) {
|
|
169
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: names cannot contain ":", which is reserved for plugin-scoped identifiers`)
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
return declared
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
/** Parse one agent markdown file; null when it is not a usable agent definition. */
|
|
161
|
-
function parseAgentFile(content: string, source: AgentSource, filePath: string): AgentConfig | null {
|
|
176
|
+
function parseAgentFile(content: string, source: AgentSource, filePath: string, pluginName?: string): AgentConfig | null {
|
|
162
177
|
let parsed: { frontmatter: Record<string, unknown>; body: string }
|
|
163
178
|
try {
|
|
164
179
|
parsed = parseFrontmatter<Record<string, unknown>>(content)
|
|
@@ -166,7 +181,7 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
166
181
|
return null // malformed YAML must not abort discovery for the whole directory
|
|
167
182
|
}
|
|
168
183
|
const { frontmatter, body } = parsed
|
|
169
|
-
const name =
|
|
184
|
+
const name = agentName(frontmatter, filePath, pluginName)
|
|
170
185
|
const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
|
|
171
186
|
if (!name || !description) return null
|
|
172
187
|
const tools = parseToolsField(frontmatter.tools, true)
|
|
@@ -198,6 +213,8 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
198
213
|
memory: parseMemoryField(frontmatter.memory),
|
|
199
214
|
maxTurns: parseMaxTurns(frontmatter.maxTurns),
|
|
200
215
|
isolation,
|
|
216
|
+
hooks: frontmatter.hooks !== null && typeof frontmatter.hooks === 'object' && !Array.isArray(frontmatter.hooks) ? (frontmatter.hooks as Record<string, unknown>) : undefined,
|
|
217
|
+
background: frontmatter.background === true ? true : undefined,
|
|
201
218
|
systemPrompt: body,
|
|
202
219
|
source,
|
|
203
220
|
filePath,
|
|
@@ -262,6 +279,12 @@ export interface AgentConfig {
|
|
|
262
279
|
maxTurns?: number
|
|
263
280
|
/** Claude's `isolation: worktree`: run the child in a temporary git worktree. */
|
|
264
281
|
isolation?: 'worktree'
|
|
282
|
+
/** Claude's frontmatter `hooks`, scoped to this subagent: passed to the child
|
|
283
|
+
* via env, with Stop converted to SubagentStop (see agentHooksEnv). */
|
|
284
|
+
hooks?: Record<string, unknown>
|
|
285
|
+
/** Claude's `background: true`: keep this agent in the background even when
|
|
286
|
+
* asked to run it in the foreground. */
|
|
287
|
+
background?: boolean
|
|
265
288
|
systemPrompt: string
|
|
266
289
|
source: AgentSource
|
|
267
290
|
filePath: string
|
|
@@ -274,7 +297,7 @@ export interface AgentDiscoveryResult {
|
|
|
274
297
|
|
|
275
298
|
/** Claude scans .claude/agents recursively so agents can be organized into
|
|
276
299
|
* subfolders (agents/review/, agents/research/); the walk mirrors that. */
|
|
277
|
-
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
300
|
+
function loadAgentsFromDir(dir: string, source: AgentSource, pluginName?: string): AgentConfig[] {
|
|
278
301
|
const agents: AgentConfig[] = []
|
|
279
302
|
|
|
280
303
|
let entries: fs.Dirent[]
|
|
@@ -287,7 +310,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
287
310
|
for (const entry of entries) {
|
|
288
311
|
const filePath = path.join(dir, entry.name)
|
|
289
312
|
if (entry.isDirectory()) {
|
|
290
|
-
agents.push(...loadAgentsFromDir(filePath, source))
|
|
313
|
+
agents.push(...loadAgentsFromDir(filePath, source, pluginName))
|
|
291
314
|
continue
|
|
292
315
|
}
|
|
293
316
|
if (!entry.name.endsWith('.md')) continue
|
|
@@ -300,7 +323,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
300
323
|
continue
|
|
301
324
|
}
|
|
302
325
|
|
|
303
|
-
const agent = parseAgentFile(content, source, filePath)
|
|
326
|
+
const agent = parseAgentFile(content, source, filePath, pluginName)
|
|
304
327
|
if (agent) agents.push(agent)
|
|
305
328
|
}
|
|
306
329
|
|
|
@@ -323,13 +346,14 @@ export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
|
|
|
323
346
|
/** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
|
|
324
347
|
const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
|
|
325
348
|
|
|
326
|
-
/** Agent directories of every enabled plugin
|
|
349
|
+
/** Agent directories of every enabled plugin, each with its plugin name (Claude
|
|
350
|
+
* scopes plugin agent ids as `<plugin>:<name>`): `agents/` unless the manifest
|
|
327
351
|
* points elsewhere. Plugins are user-installed, so user scope only decides. */
|
|
328
|
-
function pluginAgentDirs(home: string): string
|
|
352
|
+
function pluginAgentDirs(home: string): Array<{ dir: string; pluginName: string }> {
|
|
329
353
|
return installedPlugins(home).flatMap((plugin) => {
|
|
330
354
|
const declared = plugin.manifest.agents
|
|
331
355
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
|
|
332
|
-
return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
|
|
356
|
+
return dirs.map((dir) => ({ dir: path.resolve(plugin.root, String(dir)), pluginName: plugin.name }))
|
|
333
357
|
})
|
|
334
358
|
}
|
|
335
359
|
|
|
@@ -344,7 +368,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
344
368
|
|
|
345
369
|
// Plugins load after builtins and before the user's own dirs, so a user agent
|
|
346
370
|
// wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
|
|
347
|
-
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((
|
|
371
|
+
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((entry) => loadAgentsFromDir(entry.dir, 'plugin', entry.pluginName)), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
|
|
348
372
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
349
373
|
const projectAgents = scope === 'user' ? [] : [...projectClaudeDirs.flatMap((dir) => loadAgentsFromDir(dir, 'project')), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
350
374
|
|
|
@@ -22,6 +22,8 @@ export interface BackgroundRun {
|
|
|
22
22
|
turns: number
|
|
23
23
|
/** Last stderr bytes of a failed child; the only diagnostics a boot failure leaves. */
|
|
24
24
|
stderr?: string
|
|
25
|
+
/** Claude's partial marker: the run stopped at its maxTurns limit. */
|
|
26
|
+
partial?: boolean
|
|
25
27
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
26
28
|
kill?: () => void
|
|
27
29
|
/** True until the child process actually closes: a cancelled child that ignores
|
|
@@ -41,7 +43,9 @@ export interface BackgroundSpawn {
|
|
|
41
43
|
command: string
|
|
42
44
|
args: string[]
|
|
43
45
|
cwd: string
|
|
44
|
-
/**
|
|
46
|
+
/** Extra child environment (agent-frontmatter hooks ride here). */
|
|
47
|
+
env?: Record<string, string>
|
|
48
|
+
/** The --system-prompt body, kept so a resume can rebuild the file the
|
|
45
49
|
* completing run deleted. Without it the resumed child is handed a path that no
|
|
46
50
|
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
47
51
|
promptBody?: string
|
|
@@ -191,9 +195,9 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
191
195
|
return 'resumed'
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
/** Re-point --
|
|
198
|
+
/** Re-point --system-prompt at a fresh file when the original is gone. */
|
|
195
199
|
function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
|
|
196
|
-
const flag = spawnSpec.args.indexOf('--
|
|
200
|
+
const flag = spawnSpec.args.indexOf('--system-prompt')
|
|
197
201
|
if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
|
|
198
202
|
const current = spawnSpec.args[flag + 1]
|
|
199
203
|
if (current && fs.existsSync(current)) return spawnSpec.args
|
|
@@ -211,12 +215,14 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
|
|
|
211
215
|
}
|
|
212
216
|
}
|
|
213
217
|
|
|
214
|
-
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
218
|
+
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
|
|
215
219
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
216
220
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
217
221
|
// could otherwise all pass that earlier check and overshoot the cap.
|
|
218
222
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
219
|
-
|
|
223
|
+
// A preset id lets the caller run SubagentStart hooks pre-spawn with the same
|
|
224
|
+
// id the run will carry.
|
|
225
|
+
const id = presetId ?? `bg-${randomUUID().slice(0, 8)}`
|
|
220
226
|
// A stable session id per run: the child persists its session, so a follow-up can
|
|
221
227
|
// resume it instead of starting cold.
|
|
222
228
|
const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
|
|
@@ -238,7 +244,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
238
244
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
239
245
|
detached: true,
|
|
240
246
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
241
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
247
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env },
|
|
242
248
|
})
|
|
243
249
|
run.live = true
|
|
244
250
|
const killGroup = (signal: NodeJS.Signals): void => {
|
|
@@ -314,6 +320,8 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
314
320
|
// maxTurns cap ends cleanly with output preserved, so it counts as done, not failed.
|
|
315
321
|
if (run.state !== 'cancelled') run.state = code === 0 || cappedByMaxTurns ? 'done' : 'failed'
|
|
316
322
|
run.exitCode = cappedByMaxTurns ? 0 : (code ?? 0)
|
|
323
|
+
// Claude marks a maxTurns-capped run's output as partial and offers a resume.
|
|
324
|
+
if (cappedByMaxTurns) run.partial = true
|
|
317
325
|
run.output = text
|
|
318
326
|
run.turns = turns
|
|
319
327
|
run.stderr = stderrTail.trim() || undefined
|
|
@@ -30,6 +30,7 @@ import { capForContext } from '../internal/output-guard.js'
|
|
|
30
30
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
31
31
|
import { repoRoot } from '../internal/project-root.js'
|
|
32
32
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
33
|
+
import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
33
34
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
34
35
|
import { skillDirs } from '../skills.js'
|
|
35
36
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
@@ -67,6 +68,8 @@ interface SingleResult {
|
|
|
67
68
|
stopReason?: string
|
|
68
69
|
errorMessage?: string
|
|
69
70
|
step?: number
|
|
71
|
+
/** Claude's partial marker: the run stopped at its maxTurns limit. */
|
|
72
|
+
partial?: boolean
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
interface SubagentDetails {
|
|
@@ -149,6 +152,10 @@ interface RunAgentOptions {
|
|
|
149
152
|
onUpdate?: OnUpdateCallback
|
|
150
153
|
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
151
154
|
onPhase?: SubagentPhaseSink
|
|
155
|
+
/** The child's run id, set by the wrapper so the spawn env can carry it. */
|
|
156
|
+
agentId?: string
|
|
157
|
+
/** SubagentStart hook context, injected ahead of the child's first prompt. */
|
|
158
|
+
startContexts?: string[]
|
|
152
159
|
/** Skill directories to preload from, resolved where project trust is known. */
|
|
153
160
|
skillRoots?: string[]
|
|
154
161
|
/** Models this user can actually run, for resolving a tier alias. */
|
|
@@ -157,13 +164,14 @@ interface RunAgentOptions {
|
|
|
157
164
|
projectApproved?: boolean
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop.
|
|
161
|
-
|
|
167
|
+
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop.
|
|
168
|
+
* The stop carries the run's final assistant text, which Claude's SubagentStop
|
|
169
|
+
* delivers as last_assistant_message. */
|
|
170
|
+
type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string, lastAssistantMessage?: string) => void
|
|
162
171
|
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
function
|
|
166
|
-
const note = `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`
|
|
172
|
+
/** Append a note to the final assistant message so it rides the run's normal
|
|
173
|
+
* output; stderr when there is none. */
|
|
174
|
+
function appendResultNote(result: SingleResult, note: string): void {
|
|
167
175
|
for (let i = result.messages.length - 1; i >= 0; i--) {
|
|
168
176
|
const msg = result.messages[i]
|
|
169
177
|
if (msg.role === 'assistant') {
|
|
@@ -174,15 +182,47 @@ function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void
|
|
|
174
182
|
result.stderr = result.stderr ? `${result.stderr}\n${note}` : note
|
|
175
183
|
}
|
|
176
184
|
|
|
185
|
+
/** Tell the parent where a kept worktree lives. */
|
|
186
|
+
function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
|
|
187
|
+
appendResultNote(result, `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Claude marks a maxTurns-capped run's output as partial; the note rides the
|
|
191
|
+
* final assistant message like the worktree note, so the parent model sees it
|
|
192
|
+
* with the output. A no-op for uncapped runs. */
|
|
193
|
+
function appendPartialNote(result: SingleResult): void {
|
|
194
|
+
if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
|
|
195
|
+
}
|
|
196
|
+
|
|
177
197
|
async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
178
198
|
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
179
199
|
if (!agent) return runSingleAgentInner(options)
|
|
200
|
+
// A refused launch (Claude's zero-tools error) never starts, so no
|
|
201
|
+
// SubagentStart/Stop pair fires for it.
|
|
202
|
+
const toolsError = unresolvedToolsError(agent)
|
|
203
|
+
if (toolsError) {
|
|
204
|
+
return {
|
|
205
|
+
agent: agent.name,
|
|
206
|
+
agentSource: agent.source,
|
|
207
|
+
task: options.task,
|
|
208
|
+
exitCode: 1,
|
|
209
|
+
messages: [],
|
|
210
|
+
stderr: toolsError,
|
|
211
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
212
|
+
step: options.step,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
180
215
|
const agentId = `fg-${randomUUID().slice(0, 8)}`
|
|
216
|
+
// SubagentStart hooks run pre-spawn through the seam so their additionalContext
|
|
217
|
+
// reaches the child before its first prompt.
|
|
218
|
+
const startContexts = await runSubagentStartHooks(agent.name, agentId)
|
|
181
219
|
options.onPhase?.('start', agent.name, agentId)
|
|
220
|
+
let result: SingleResult | undefined
|
|
182
221
|
try {
|
|
183
|
-
|
|
222
|
+
result = await runSingleAgentInner({ ...options, agentId, startContexts })
|
|
223
|
+
return result
|
|
184
224
|
} finally {
|
|
185
|
-
options.onPhase?.('stop', agent.name, agentId)
|
|
225
|
+
options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
|
|
186
226
|
}
|
|
187
227
|
}
|
|
188
228
|
|
|
@@ -265,10 +305,12 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
265
305
|
const tmp = await writePromptToTempFile(agent.name, promptBody)
|
|
266
306
|
tmpPromptDir = tmp.dir
|
|
267
307
|
tmpPromptPath = tmp.filePath
|
|
268
|
-
|
|
308
|
+
// Claude: the agent body IS the subagent's system prompt, replacing the
|
|
309
|
+
// default, not an addition to it (--system-prompt reads a file path too).
|
|
310
|
+
args.push('--system-prompt', tmpPromptPath)
|
|
269
311
|
}
|
|
270
312
|
|
|
271
|
-
args.push(
|
|
313
|
+
args.push(taskWithStartContext(task, options.startContexts ?? []))
|
|
272
314
|
let wasAborted = false
|
|
273
315
|
|
|
274
316
|
const exitCode = await new Promise<number>((resolve) => {
|
|
@@ -281,7 +323,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
281
323
|
// direct child orphans a build or dev server the agent started.
|
|
282
324
|
detached: true,
|
|
283
325
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
284
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
326
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
|
|
285
327
|
})
|
|
286
328
|
let buffer = ''
|
|
287
329
|
let assistantTurns = 0
|
|
@@ -304,8 +346,12 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
304
346
|
accumulateAssistantMessage(currentResult, msg)
|
|
305
347
|
assistantTurns++
|
|
306
348
|
// Claude's maxTurns cap: end the child at the turn boundary once it has
|
|
307
|
-
// produced its Nth turn, so the collected output is kept and no turn is
|
|
308
|
-
|
|
349
|
+
// produced its Nth turn, so the collected output is kept and no turn is
|
|
350
|
+
// cut; the returned output is marked partial, as Claude documents.
|
|
351
|
+
if (agent.maxTurns && assistantTurns >= agent.maxTurns) {
|
|
352
|
+
currentResult.partial = true
|
|
353
|
+
killGroup('SIGTERM')
|
|
354
|
+
}
|
|
309
355
|
}
|
|
310
356
|
emitUpdate()
|
|
311
357
|
} else if (event.type === 'tool_result_end') {
|
|
@@ -371,6 +417,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
371
417
|
|
|
372
418
|
currentResult.exitCode = exitCode
|
|
373
419
|
if (wasAborted) throw new Error('Subagent was aborted')
|
|
420
|
+
appendPartialNote(currentResult)
|
|
374
421
|
return currentResult
|
|
375
422
|
} finally {
|
|
376
423
|
// Cleanup runs on abort too: it only removes a pristine worktree, so an
|
|
@@ -452,12 +499,15 @@ type ChainStepParam = Static<typeof ChainItem>
|
|
|
452
499
|
type TaskItemParam = Static<typeof TaskItem>
|
|
453
500
|
|
|
454
501
|
/** The completion notice a background run sends when it finishes. */
|
|
455
|
-
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): string {
|
|
502
|
+
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string; partial?: boolean }): string {
|
|
456
503
|
const output = capForContext(run.output ?? '') || '(no output)'
|
|
457
504
|
// A child that dies at boot writes its reason only to stderr; without this the
|
|
458
505
|
// notice reads "failed after 0 turns ... (no output)" with nothing to act on.
|
|
459
506
|
const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
|
|
460
|
-
|
|
507
|
+
// Claude marks maxTurns-capped output as partial and notes the run can be
|
|
508
|
+
// resumed to continue from where it stopped.
|
|
509
|
+
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.]` : ''
|
|
510
|
+
return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}${partialNote}`
|
|
461
511
|
}
|
|
462
512
|
|
|
463
513
|
/** What to tell the model about a resume request. */
|
|
@@ -690,13 +740,31 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
|
|
|
690
740
|
knownMcpAliases = aliases
|
|
691
741
|
}
|
|
692
742
|
|
|
743
|
+
/** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
|
|
744
|
+
* does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
|
|
745
|
+
* extensions register in a child. Claude's capitalized spellings fold onto these. */
|
|
746
|
+
const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource'])
|
|
747
|
+
|
|
748
|
+
/** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
|
|
749
|
+
* to launch with an error naming the entries, instead of running tool-less. */
|
|
750
|
+
function unresolvedToolsError(agent: AgentConfig): string | undefined {
|
|
751
|
+
if (!agent.tools || agent.tools.length === 0) return undefined
|
|
752
|
+
const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
|
|
753
|
+
const known = new Set(knownMcpAliases.map((alias) => fold(alias.pi)))
|
|
754
|
+
const resolves = expandMcpToolPatterns(agent.tools, knownMcpAliases).some((entry) => CHILD_TOOL_NAMES.has(fold(entry)) || known.has(fold(entry)))
|
|
755
|
+
if (resolves) return undefined
|
|
756
|
+
return `Agent "${agent.name}" would launch with zero tools: no entry in [${agent.tools.join(', ')}] resolves to a tool.`
|
|
757
|
+
}
|
|
758
|
+
|
|
693
759
|
/** CLI args shared by foreground and background children, from the agent's config. */
|
|
694
760
|
function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
|
|
695
761
|
const args: string[] = ['--mode', 'json', '-p', '--no-session']
|
|
696
762
|
// A concrete model wins; otherwise a Claude tier alias resolved against the models
|
|
697
|
-
// this user can actually run
|
|
698
|
-
//
|
|
699
|
-
|
|
763
|
+
// this user can actually run; then CLAUDE_CODE_SUBAGENT_MODEL, per Claude's model
|
|
764
|
+
// order (invocation model, frontmatter model, this variable, the session model).
|
|
765
|
+
// pi reads a thinking level from the model pattern's :suffix when a model is
|
|
766
|
+
// pinned, and from --thinking otherwise.
|
|
767
|
+
const model = agent.model ?? aliasModel ?? process.env.CLAUDE_CODE_SUBAGENT_MODEL
|
|
700
768
|
if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
|
|
701
769
|
else if (agent.effort) args.push('--thinking', agent.effort)
|
|
702
770
|
// Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
|
|
@@ -704,9 +772,41 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
704
772
|
// grant granted nothing.
|
|
705
773
|
if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
|
|
706
774
|
if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
|
|
775
|
+
// Claude: "Explore and Plan are the only subagents that omit CLAUDE.md" (and no
|
|
776
|
+
// field or setting changes which agents skip them), to keep research fast.
|
|
777
|
+
if (agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')) args.push('--no-context-files')
|
|
707
778
|
return args
|
|
708
779
|
}
|
|
709
780
|
|
|
781
|
+
/** Claude's agent-frontmatter hooks ride to the child as env; the child's hooks
|
|
782
|
+
* extension merges them for the run only (they die with the process, matching
|
|
783
|
+
* "only while that subagent is running"). Stop converts to SubagentStop, the
|
|
784
|
+
* event the child fires when it completes, as Claude documents. */
|
|
785
|
+
function agentHooksEnv(agent: AgentConfig, agentId: string): Record<string, string> {
|
|
786
|
+
if (!agent.hooks) return {}
|
|
787
|
+
const hooks: Record<string, unknown> = { ...agent.hooks }
|
|
788
|
+
const stop = hooks.Stop
|
|
789
|
+
delete hooks.Stop
|
|
790
|
+
if (Array.isArray(stop)) hooks.SubagentStop = [...(Array.isArray(hooks.SubagentStop) ? (hooks.SubagentStop as unknown[]) : []), ...stop]
|
|
791
|
+
return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Whether a run belongs in the background: the caller asked, or Claude's
|
|
795
|
+
* `background: true` frontmatter keeps the agent there even on a foreground ask
|
|
796
|
+
* (single mode). */
|
|
797
|
+
function wantsBackground(params: { background?: boolean; agent?: string }, agents: AgentConfig[]): boolean {
|
|
798
|
+
if (params.background) return true
|
|
799
|
+
return params.agent !== undefined && agents.find((a) => a.name === params.agent)?.background === true
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The task argument with any SubagentStart hook context ahead of it, per Claude:
|
|
803
|
+
* "added to the subagent's context at the start of its conversation, before its
|
|
804
|
+
* first prompt". */
|
|
805
|
+
function taskWithStartContext(task: string, contexts: string[]): string {
|
|
806
|
+
const context = contexts.filter(Boolean).join('\n')
|
|
807
|
+
return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
|
|
808
|
+
}
|
|
809
|
+
|
|
710
810
|
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
711
811
|
return {
|
|
712
812
|
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
@@ -758,6 +858,13 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
758
858
|
details: makeDetails('single')([]),
|
|
759
859
|
}
|
|
760
860
|
}
|
|
861
|
+
const toolsError = unresolvedToolsError(agent)
|
|
862
|
+
if (toolsError) {
|
|
863
|
+
return {
|
|
864
|
+
content: [{ type: 'text', text: toolsError }],
|
|
865
|
+
details: makeDetails('single')([]),
|
|
866
|
+
}
|
|
867
|
+
}
|
|
761
868
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
|
|
762
869
|
return backgroundCapResult(makeDetails)
|
|
763
870
|
}
|
|
@@ -782,37 +889,49 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
782
889
|
const promptBody = childPromptBody(agent, skillRoots, memorySection)
|
|
783
890
|
if (promptBody.trim()) {
|
|
784
891
|
tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
|
|
785
|
-
|
|
892
|
+
// Claude: the agent body replaces the default system prompt (see the
|
|
893
|
+
// foreground path).
|
|
894
|
+
args.push('--system-prompt', tmpPrompt.filePath)
|
|
786
895
|
}
|
|
787
|
-
|
|
896
|
+
// The id is preset so SubagentStart hooks run pre-spawn with the id the run
|
|
897
|
+
// will actually carry, and their context lands before the child's first prompt.
|
|
898
|
+
const presetId = `bg-${randomUUID().slice(0, 8)}`
|
|
899
|
+
const startContexts = await runSubagentStartHooks(agent.name, presetId)
|
|
900
|
+
args.push(taskWithStartContext(task, startContexts))
|
|
788
901
|
const invocation = getPiInvocation(args)
|
|
789
|
-
const id = startBackgroundRun(
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
902
|
+
const id = startBackgroundRun(
|
|
903
|
+
agent.name,
|
|
904
|
+
task,
|
|
905
|
+
{ command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, env: agentHooksEnv(agent, presetId), promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns },
|
|
906
|
+
(run) => {
|
|
907
|
+
removeTmpPrompt(tmpPrompt)
|
|
908
|
+
const finish = (): void => {
|
|
909
|
+
// Both calls throw once the session that started the run is disposed. driveRun's
|
|
910
|
+
// catch covers the synchronous path, but the worktree branch reaches here from an
|
|
911
|
+
// async continuation outside it, so the guard must live in finish itself.
|
|
912
|
+
try {
|
|
913
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
|
|
914
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
915
|
+
} catch {
|
|
916
|
+
// Session disposed after the run outlived it; nothing to notify.
|
|
917
|
+
}
|
|
800
918
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
919
|
+
if (!worktree) {
|
|
920
|
+
finish()
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
// Cleanup only removes a pristine worktree; a kept one is reported in the
|
|
924
|
+
// completion text so the parent knows where the changes live.
|
|
925
|
+
const keptWorktree = worktree
|
|
926
|
+
void cleanupAgentWorktree(runCwd, keptWorktree)
|
|
927
|
+
.then((outcome) => {
|
|
928
|
+
if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
|
|
929
|
+
})
|
|
930
|
+
.catch(() => {})
|
|
931
|
+
.finally(finish)
|
|
932
|
+
},
|
|
933
|
+
presetId,
|
|
934
|
+
)
|
|
816
935
|
if (id === null) {
|
|
817
936
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
818
937
|
removeTmpPrompt(tmpPrompt)
|
|
@@ -1484,9 +1603,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1484
1603
|
// unavailable tier still falls back to the session model.
|
|
1485
1604
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1486
1605
|
|
|
1487
|
-
if (params
|
|
1488
|
-
|
|
1489
|
-
const mode: ModeContext = {
|
|
1606
|
+
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
|
|
1607
|
+
|
|
1608
|
+
const mode: ModeContext = {
|
|
1609
|
+
agents,
|
|
1610
|
+
defaultCwd: ctx.cwd,
|
|
1611
|
+
signal,
|
|
1612
|
+
onUpdate,
|
|
1613
|
+
makeDetails,
|
|
1614
|
+
skillRoots,
|
|
1615
|
+
availableModels,
|
|
1616
|
+
projectApproved,
|
|
1617
|
+
onPhase: (phase, agentType, agentId, lastAssistantMessage) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId, ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }),
|
|
1618
|
+
}
|
|
1490
1619
|
|
|
1491
1620
|
if (params.chain?.length) return runChainMode(params.chain, mode)
|
|
1492
1621
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.33",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|