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.
@@ -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,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
- 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
- const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
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[eventName], data.agentType), payload, boundRunner(ctx))
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 = 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
 
@@ -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
- /** The --append-system-prompt body, kept so a resume can rebuild the file the
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 --append-system-prompt at a fresh file when the original is gone. */
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('--append-system-prompt')
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
- 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)}`
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
- type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string) => void
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
- /** Tell the parent where a kept worktree lives: appended to the final assistant
164
- * message so it rides the run's normal output; stderr when there is none. */
165
- function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
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
- return await runSingleAgentInner(options)
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
- args.push('--append-system-prompt', tmpPromptPath)
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(`Task: ${task}`)
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 cut.
308
- if (agent.maxTurns && assistantTurns >= agent.maxTurns) killGroup('SIGTERM')
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
- return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}`
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. pi reads a thinking level from the model pattern's
698
- // :suffix when a model is pinned, and from --thinking otherwise.
699
- const model = agent.model ?? aliasModel
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
- args.push('--append-system-prompt', tmpPrompt.filePath)
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
- 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))
788
901
  const invocation = getPiInvocation(args)
789
- const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
790
- removeTmpPrompt(tmpPrompt)
791
- const finish = (): void => {
792
- // Both calls throw once the session that started the run is disposed. driveRun's
793
- // catch covers the synchronous path, but the worktree branch reaches here from an
794
- // async continuation outside it, so the guard must live in finish itself.
795
- try {
796
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
797
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
798
- } catch {
799
- // 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
+ }
800
918
  }
801
- }
802
- if (!worktree) {
803
- finish()
804
- return
805
- }
806
- // Cleanup only removes a pristine worktree; a kept one is reported in the
807
- // completion text so the parent knows where the changes live.
808
- const keptWorktree = worktree
809
- void cleanupAgentWorktree(runCwd, keptWorktree)
810
- .then((outcome) => {
811
- if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
812
- })
813
- .catch(() => {})
814
- .finally(finish)
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.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1488
-
1489
- const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, projectApproved, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
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.31",
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",