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.
@@ -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/SubagentStop ride pi-code's own subagent extension, which publishes
68
- * child-run lifecycle on the shared bus (notify-style: a child has already exited by
69
- * the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
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
- if (!isSubagentPhaseEvent(data) || !sessionCtx) return
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: eventName, agent_type: data.agentType, agent_id: data.agentId, ...(data.phase === 'stop' && data.lastAssistantMessage !== undefined ? { last_assistant_message: data.lastAssistantMessage } : {}) }
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[eventName], data.agentType), payload, boundRunner(ctx))
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 = typeof frontmatter.name === 'string' ? frontmatter.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: `agents/` unless the manifest
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((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
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
- const id = `bg-${randomUUID().slice(0, 8)}`
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(`Task: ${task}`)
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
- args.push(`Task: ${task}`)
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(agent.name, task, { command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
859
- removeTmpPrompt(tmpPrompt)
860
- const finish = (): void => {
861
- // Both calls throw once the session that started the run is disposed. driveRun's
862
- // catch covers the synchronous path, but the worktree branch reaches here from an
863
- // async continuation outside it, so the guard must live in finish itself.
864
- try {
865
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
866
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
867
- } catch {
868
- // Session disposed after the run outlived it; nothing to notify.
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
- if (!worktree) {
872
- finish()
873
- return
874
- }
875
- // Cleanup only removes a pristine worktree; a kept one is reported in the
876
- // completion text so the parent knows where the changes live.
877
- const keptWorktree = worktree
878
- void cleanupAgentWorktree(runCwd, keptWorktree)
879
- .then((outcome) => {
880
- if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
881
- })
882
- .catch(() => {})
883
- .finally(finish)
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.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
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.32",
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",