pi-code 1.0.32 → 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 +47 -8
- package/extensions/internal/subagent-hooks.ts +26 -0
- package/extensions/subagent/agents.ts +33 -9
- package/extensions/subagent/background.ts +7 -3
- package/extensions/subagent/index.ts +81 -31
- 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,14 +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
|
-
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
343
353
|
// Claude's SubagentStop carries the subagent's final text; agent_transcript_path
|
|
344
354
|
// stays absent (a --no-session child writes no transcript, see docs/hooks.md).
|
|
345
|
-
const payload = { hook_event_name:
|
|
355
|
+
const payload = { hook_event_name: 'SubagentStop', agent_type: data.agentType, agent_id: data.agentId, ...(data.lastAssistantMessage !== undefined ? { last_assistant_message: data.lastAssistantMessage } : {}) }
|
|
346
356
|
try {
|
|
347
|
-
const results = await runNotifyHooks(matchingCommands(config
|
|
357
|
+
const results = await runNotifyHooks(matchingCommands(config.SubagentStop, data.agentType), payload, boundRunner(ctx))
|
|
348
358
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
349
359
|
} catch {
|
|
350
360
|
// The bus outlives the session: an event landing between /new disposing this
|
|
@@ -353,6 +363,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
353
363
|
}
|
|
354
364
|
})
|
|
355
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
|
+
|
|
356
379
|
pi.on('session_start', async (event, ctx) => {
|
|
357
380
|
sessionCtx = ctx
|
|
358
381
|
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
@@ -389,6 +412,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
389
412
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
390
413
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
391
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)
|
|
392
419
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
393
420
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
394
421
|
if (event.reason === 'reload') return
|
|
@@ -560,6 +587,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
560
587
|
idlePromptTimer.unref?.()
|
|
561
588
|
}
|
|
562
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
|
+
|
|
563
602
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
564
603
|
// and an `if`-carrying hook never runs on a non-tool event.
|
|
565
604
|
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
@@ -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
|
|
|
@@ -43,6 +43,8 @@ export interface BackgroundSpawn {
|
|
|
43
43
|
command: string
|
|
44
44
|
args: string[]
|
|
45
45
|
cwd: string
|
|
46
|
+
/** Extra child environment (agent-frontmatter hooks ride here). */
|
|
47
|
+
env?: Record<string, string>
|
|
46
48
|
/** The --system-prompt body, kept so a resume can rebuild the file the
|
|
47
49
|
* completing run deleted. Without it the resumed child is handed a path that no
|
|
48
50
|
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
@@ -213,12 +215,14 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
|
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
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 {
|
|
217
219
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
218
220
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
219
221
|
// could otherwise all pass that earlier check and overshoot the cap.
|
|
220
222
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
221
|
-
|
|
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)}`
|
|
222
226
|
// A stable session id per run: the child persists its session, so a follow-up can
|
|
223
227
|
// resume it instead of starting cold.
|
|
224
228
|
const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
|
|
@@ -240,7 +244,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
240
244
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
241
245
|
detached: true,
|
|
242
246
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
243
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
247
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env },
|
|
244
248
|
})
|
|
245
249
|
run.live = true
|
|
246
250
|
const killGroup = (signal: NodeJS.Signals): void => {
|
|
@@ -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'
|
|
@@ -151,6 +152,10 @@ interface RunAgentOptions {
|
|
|
151
152
|
onUpdate?: OnUpdateCallback
|
|
152
153
|
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
153
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[]
|
|
154
159
|
/** Skill directories to preload from, resolved where project trust is known. */
|
|
155
160
|
skillRoots?: string[]
|
|
156
161
|
/** Models this user can actually run, for resolving a tier alias. */
|
|
@@ -208,10 +213,13 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
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)
|
|
211
219
|
options.onPhase?.('start', agent.name, agentId)
|
|
212
220
|
let result: SingleResult | undefined
|
|
213
221
|
try {
|
|
214
|
-
result = await runSingleAgentInner(options)
|
|
222
|
+
result = await runSingleAgentInner({ ...options, agentId, startContexts })
|
|
215
223
|
return result
|
|
216
224
|
} finally {
|
|
217
225
|
options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
|
|
@@ -302,7 +310,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
302
310
|
args.push('--system-prompt', tmpPromptPath)
|
|
303
311
|
}
|
|
304
312
|
|
|
305
|
-
args.push(
|
|
313
|
+
args.push(taskWithStartContext(task, options.startContexts ?? []))
|
|
306
314
|
let wasAborted = false
|
|
307
315
|
|
|
308
316
|
const exitCode = await new Promise<number>((resolve) => {
|
|
@@ -315,7 +323,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
315
323
|
// direct child orphans a build or dev server the agent started.
|
|
316
324
|
detached: true,
|
|
317
325
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
318
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
326
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
|
|
319
327
|
})
|
|
320
328
|
let buffer = ''
|
|
321
329
|
let assistantTurns = 0
|
|
@@ -764,9 +772,41 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
764
772
|
// grant granted nothing.
|
|
765
773
|
if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
|
|
766
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')
|
|
767
778
|
return args
|
|
768
779
|
}
|
|
769
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
|
+
|
|
770
810
|
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
771
811
|
return {
|
|
772
812
|
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
@@ -853,35 +893,45 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
853
893
|
// foreground path).
|
|
854
894
|
args.push('--system-prompt', tmpPrompt.filePath)
|
|
855
895
|
}
|
|
856
|
-
|
|
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))
|
|
857
901
|
const invocation = getPiInvocation(args)
|
|
858
|
-
const id = startBackgroundRun(
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
+
}
|
|
869
918
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
+
)
|
|
885
935
|
if (id === null) {
|
|
886
936
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
887
937
|
removeTmpPrompt(tmpPrompt)
|
|
@@ -1553,7 +1603,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1553
1603
|
// unavailable tier still falls back to the session model.
|
|
1554
1604
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1555
1605
|
|
|
1556
|
-
if (params
|
|
1606
|
+
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
|
|
1557
1607
|
|
|
1558
1608
|
const mode: ModeContext = {
|
|
1559
1609
|
agents,
|
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",
|