pi-code 1.0.30 → 1.0.32

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.
@@ -19,9 +19,14 @@ export interface HookDecision {
19
19
  ask?: boolean
20
20
  }
21
21
 
22
+ /** Claude's stdout shape rule: only output that starts with `{` and ends with `}`
23
+ * (ignoring surrounding whitespace) is read as JSON output; a JSON array, a quoted
24
+ * string, or a bare number is plain text. Multi-line output whose lines each parse
25
+ * as JSON on their own with no output field set is plain text too (that case
26
+ * arrives here as a parse failure and hookJsonError sorts it from a real error). */
22
27
  export function tryParseJson(text: string):
23
28
  | {
24
- hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }
29
+ hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown; suppressOriginalPrompt?: boolean }
25
30
  decision?: string
26
31
  reason?: string
27
32
  continue?: boolean
@@ -32,13 +37,58 @@ export function tryParseJson(text: string):
32
37
  ok?: boolean
33
38
  }
34
39
  | undefined {
40
+ const trimmed = text.trim()
41
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return undefined
35
42
  try {
36
- return JSON.parse(text)
43
+ const parsed: unknown = JSON.parse(trimmed)
44
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as ReturnType<typeof tryParseJson>) : undefined
37
45
  } catch {
38
46
  return undefined
39
47
  }
40
48
  }
41
49
 
50
+ /** Top-level JSON output fields; a multi-line output where a line sets one of
51
+ * these is a parse failure rather than plain text, as Claude documents. */
52
+ const OUTPUT_FIELDS = new Set(['decision', 'reason', 'continue', 'stopReason', 'systemMessage', 'suppressOutput', 'hookSpecificOutput', 'updatedToolOutput', 'updatedMCPToolOutput', 'ok'])
53
+
54
+ /** Claude reports a `<hook> hook error` notice when {..}-shaped stdout cannot be
55
+ * read as JSON output, and does not treat that stdout as plain text. Returns the
56
+ * error message for that case; undefined for valid JSON output and for output the
57
+ * shape rule already reads as plain text (including the multi-line case). */
58
+ export function hookJsonError(text: string): string | undefined {
59
+ const trimmed = text.trim()
60
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return undefined
61
+ let parseError: string
62
+ try {
63
+ JSON.parse(trimmed)
64
+ return undefined
65
+ } catch (error) {
66
+ parseError = error instanceof Error ? error.message : String(error)
67
+ }
68
+ const lines = trimmed
69
+ .split('\n')
70
+ .map((line) => line.trim())
71
+ .filter((line) => line !== '')
72
+ if (lines.length >= 2 && !multiLineSetsOutputField(lines)) return undefined
73
+ return `invalid JSON output: ${parseError}`
74
+ }
75
+
76
+ /** Whether every line parses as JSON and at least one sets an output field; a
77
+ * non-JSON line means the multi-line rule does not apply (still a parse error). */
78
+ function multiLineSetsOutputField(lines: string[]): boolean {
79
+ let setsField = false
80
+ for (const line of lines) {
81
+ let parsed: unknown
82
+ try {
83
+ parsed = JSON.parse(line)
84
+ } catch {
85
+ return true // not all-JSON: the whole output is a parse failure
86
+ }
87
+ if (parsed !== null && typeof parsed === 'object' && Object.keys(parsed).some((key) => OUTPUT_FIELDS.has(key))) setsField = true
88
+ }
89
+ return setsField
90
+ }
91
+
42
92
  /** The reason a JSON body's blocking decision carries, whichever spelling made it. */
43
93
  function jsonBlockingReason(parsed: ReturnType<typeof tryParseJson>): string | undefined {
44
94
  if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return parsed.hookSpecificOutput.permissionDecisionReason
@@ -94,6 +144,10 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
94
144
  if (!notify) return
95
145
  for (const [i, result] of results.entries()) {
96
146
  if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
147
+ // Claude shows a `<hook> hook error` notice when {..}-shaped stdout cannot be
148
+ // read as JSON output (exit 2 still blocks and reads its own channels).
149
+ const jsonError = result.code === 2 ? undefined : hookJsonError(result.stdout)
150
+ if (jsonError !== undefined) notify(`${commands[i].command} hook error: ${jsonError}`)
97
151
  }
98
152
  }
99
153
 
@@ -196,6 +250,8 @@ export interface PromptDecision {
196
250
  block: boolean
197
251
  reason?: string
198
252
  context: string
253
+ /** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
254
+ suppress?: boolean
199
255
  }
200
256
 
201
257
  /** Additional context a UserPromptSubmit hook contributes: an explicit
@@ -203,6 +259,8 @@ export interface PromptDecision {
203
259
  export function promptContext(stdout: string): string {
204
260
  const parsed = tryParseJson(stdout)
205
261
  if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
262
+ // Malformed JSON output is an error, not plain text: no context is added.
263
+ if (hookJsonError(stdout) !== undefined) return ''
206
264
  return stdout.trim()
207
265
  }
208
266
 
@@ -221,13 +279,17 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
221
279
  }
222
280
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
223
281
  const contexts: string[] = []
282
+ let suppress = false
224
283
  for (const result of results) {
225
284
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
226
285
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
286
+ // Claude's suppressOriginalPrompt: any hook setting it hides the original
287
+ // prompt, and the collected context is what reaches the model.
288
+ if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
227
289
  const context = promptContext(result.stdout)
228
290
  if (context) contexts.push(context)
229
291
  }
230
- return { block: false, context: contexts.join('\n') }
292
+ return { block: false, context: contexts.join('\n'), suppress }
231
293
  }
232
294
 
233
295
  /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
@@ -187,6 +187,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
187
187
  /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
188
188
  * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
189
189
  let stopHookBlockCount = 0
190
+ /** Tool start times per call id, for Claude's duration_ms on PostToolUse: the
191
+ * clock starts after PreToolUse hooks and any confirm dialog resolve. */
192
+ const toolStartTimes = new Map<string, number>()
190
193
  /** The pending idle_prompt notification: Claude fires it when the turn ended about
191
194
  * 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
192
195
  * canceled by input or the next turn. */
@@ -337,7 +340,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
337
340
  if (!isSubagentPhaseEvent(data) || !sessionCtx) return
338
341
  const ctx = sessionCtx
339
342
  const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
340
- const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
343
+ // Claude's SubagentStop carries the subagent's final text; agent_transcript_path
344
+ // 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 } : {}) }
341
346
  try {
342
347
  const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
343
348
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
@@ -417,13 +422,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
417
422
  // additionalContext is delivered alongside the tool result, so stash it for
418
423
  // this call's tool_result to append.
419
424
  if (decision.context && decision.context.length > 0) pendingToolContext.set(event.toolCallId, decision.context)
425
+ // Claude's duration_ms excludes PreToolUse hook time, so the clock starts here.
426
+ toolStartTimes.set(event.toolCallId, Date.now())
420
427
  return undefined
421
428
  }
422
429
  // Claude's "ask": prompt the user and let the call through if they approve.
423
430
  // With no UI (headless) the block stands, which is the safe default.
424
431
  if (decision.ask && ctx.hasUI) {
425
432
  const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
426
- return approved ? undefined : blockedToolCall(decision.reason)
433
+ if (!approved) return blockedToolCall(decision.reason)
434
+ // Claude's duration_ms also excludes time in permission prompts.
435
+ toolStartTimes.set(event.toolCallId, Date.now())
436
+ return undefined
427
437
  }
428
438
  return blockedToolCall(decision.reason)
429
439
  })
@@ -451,7 +461,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
451
461
  if (commands.length === 0 && pending.length === 0) return
452
462
  const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
453
463
  const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
454
- const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response }
464
+ const startedAt = toolStartTimes.get(event.toolCallId)
465
+ toolStartTimes.delete(event.toolCallId)
466
+ const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
455
467
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
456
468
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
457
469
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
@@ -515,8 +527,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
515
527
  return { action: 'handled' }
516
528
  }
517
529
  // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
518
- // pi's seam for rewriting the submitted text.
519
- if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
530
+ // pi's seam for rewriting the submitted text. With suppressOriginalPrompt the
531
+ // context replaces the prompt entirely (honored only when context exists, since
532
+ // an empty submission would be no turn at all).
533
+ if (decision.context) return { action: 'transform', text: decision.suppress ? decision.context : `${decision.context}\n\n${event.text}` }
520
534
  return { action: 'continue' }
521
535
  })
522
536
 
@@ -609,13 +623,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
609
623
 
610
624
  pi.on('session_before_compact', async (event, ctx) => {
611
625
  const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
612
- const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
626
+ // Claude's custom_instructions: the /compact arguments on a manual run, empty
627
+ // on an automatic one; pi carries them on the event directly.
628
+ const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
629
+ const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
613
630
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
614
631
  })
615
632
 
616
633
  pi.on('session_compact', async (event, ctx) => {
617
634
  const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
618
- const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
635
+ // Claude's compact_summary: the summary that replaced the compacted history.
636
+ const summary = (event as { compactionEntry?: { summary?: unknown } }).compactionEntry?.summary
637
+ const payload = { hook_event_name: 'PostCompact', trigger: trigger.value, ...(typeof summary === 'string' ? { compact_summary: summary } : {}) }
638
+ const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), payload, boundRunner(ctx))
619
639
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
620
640
  // Claude also fires SessionStart with source "compact" when the session
621
641
  // continues after compaction; its stdout context rides the next agent start,
@@ -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 {
@@ -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,7 @@ 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
+ /** The --system-prompt body, kept so a resume can rebuild the file the
45
47
  * completing run deleted. Without it the resumed child is handed a path that no
46
48
  * longer exists, and pi falls back to using that path as the prompt text. */
47
49
  promptBody?: string
@@ -191,9 +193,9 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
191
193
  return 'resumed'
192
194
  }
193
195
 
194
- /** Re-point --append-system-prompt at a fresh file when the original is gone. */
196
+ /** Re-point --system-prompt at a fresh file when the original is gone. */
195
197
  function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
196
- const flag = spawnSpec.args.indexOf('--append-system-prompt')
198
+ const flag = spawnSpec.args.indexOf('--system-prompt')
197
199
  if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
198
200
  const current = spawnSpec.args[flag + 1]
199
201
  if (current && fs.existsSync(current)) return spawnSpec.args
@@ -314,6 +316,8 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
314
316
  // maxTurns cap ends cleanly with output preserved, so it counts as done, not failed.
315
317
  if (run.state !== 'cancelled') run.state = code === 0 || cappedByMaxTurns ? 'done' : 'failed'
316
318
  run.exitCode = cappedByMaxTurns ? 0 : (code ?? 0)
319
+ // Claude marks a maxTurns-capped run's output as partial and offers a resume.
320
+ if (cappedByMaxTurns) run.partial = true
317
321
  run.output = text
318
322
  run.turns = turns
319
323
  run.stderr = stderrTail.trim() || undefined
@@ -67,6 +67,8 @@ interface SingleResult {
67
67
  stopReason?: string
68
68
  errorMessage?: string
69
69
  step?: number
70
+ /** Claude's partial marker: the run stopped at its maxTurns limit. */
71
+ partial?: boolean
70
72
  }
71
73
 
72
74
  interface SubagentDetails {
@@ -157,13 +159,14 @@ interface RunAgentOptions {
157
159
  projectApproved?: boolean
158
160
  }
159
161
 
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
162
+ /** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop.
163
+ * The stop carries the run's final assistant text, which Claude's SubagentStop
164
+ * delivers as last_assistant_message. */
165
+ type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string, lastAssistantMessage?: string) => void
162
166
 
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]`
167
+ /** Append a note to the final assistant message so it rides the run's normal
168
+ * output; stderr when there is none. */
169
+ function appendResultNote(result: SingleResult, note: string): void {
167
170
  for (let i = result.messages.length - 1; i >= 0; i--) {
168
171
  const msg = result.messages[i]
169
172
  if (msg.role === 'assistant') {
@@ -174,15 +177,44 @@ function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void
174
177
  result.stderr = result.stderr ? `${result.stderr}\n${note}` : note
175
178
  }
176
179
 
180
+ /** Tell the parent where a kept worktree lives. */
181
+ function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
182
+ appendResultNote(result, `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`)
183
+ }
184
+
185
+ /** Claude marks a maxTurns-capped run's output as partial; the note rides the
186
+ * final assistant message like the worktree note, so the parent model sees it
187
+ * with the output. A no-op for uncapped runs. */
188
+ function appendPartialNote(result: SingleResult): void {
189
+ if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
190
+ }
191
+
177
192
  async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
178
193
  const agent = options.agents.find((a) => a.name === options.agentName)
179
194
  if (!agent) return runSingleAgentInner(options)
195
+ // A refused launch (Claude's zero-tools error) never starts, so no
196
+ // SubagentStart/Stop pair fires for it.
197
+ const toolsError = unresolvedToolsError(agent)
198
+ if (toolsError) {
199
+ return {
200
+ agent: agent.name,
201
+ agentSource: agent.source,
202
+ task: options.task,
203
+ exitCode: 1,
204
+ messages: [],
205
+ stderr: toolsError,
206
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
207
+ step: options.step,
208
+ }
209
+ }
180
210
  const agentId = `fg-${randomUUID().slice(0, 8)}`
181
211
  options.onPhase?.('start', agent.name, agentId)
212
+ let result: SingleResult | undefined
182
213
  try {
183
- return await runSingleAgentInner(options)
214
+ result = await runSingleAgentInner(options)
215
+ return result
184
216
  } finally {
185
- options.onPhase?.('stop', agent.name, agentId)
217
+ options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
186
218
  }
187
219
  }
188
220
 
@@ -265,7 +297,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
265
297
  const tmp = await writePromptToTempFile(agent.name, promptBody)
266
298
  tmpPromptDir = tmp.dir
267
299
  tmpPromptPath = tmp.filePath
268
- args.push('--append-system-prompt', tmpPromptPath)
300
+ // Claude: the agent body IS the subagent's system prompt, replacing the
301
+ // default, not an addition to it (--system-prompt reads a file path too).
302
+ args.push('--system-prompt', tmpPromptPath)
269
303
  }
270
304
 
271
305
  args.push(`Task: ${task}`)
@@ -304,8 +338,12 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
304
338
  accumulateAssistantMessage(currentResult, msg)
305
339
  assistantTurns++
306
340
  // 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')
341
+ // produced its Nth turn, so the collected output is kept and no turn is
342
+ // cut; the returned output is marked partial, as Claude documents.
343
+ if (agent.maxTurns && assistantTurns >= agent.maxTurns) {
344
+ currentResult.partial = true
345
+ killGroup('SIGTERM')
346
+ }
309
347
  }
310
348
  emitUpdate()
311
349
  } else if (event.type === 'tool_result_end') {
@@ -371,6 +409,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
371
409
 
372
410
  currentResult.exitCode = exitCode
373
411
  if (wasAborted) throw new Error('Subagent was aborted')
412
+ appendPartialNote(currentResult)
374
413
  return currentResult
375
414
  } finally {
376
415
  // Cleanup runs on abort too: it only removes a pristine worktree, so an
@@ -452,12 +491,15 @@ type ChainStepParam = Static<typeof ChainItem>
452
491
  type TaskItemParam = Static<typeof TaskItem>
453
492
 
454
493
  /** 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 {
494
+ export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string; partial?: boolean }): string {
456
495
  const output = capForContext(run.output ?? '') || '(no output)'
457
496
  // A child that dies at boot writes its reason only to stderr; without this the
458
497
  // notice reads "failed after 0 turns ... (no output)" with nothing to act on.
459
498
  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}`
499
+ // Claude marks maxTurns-capped output as partial and notes the run can be
500
+ // resumed to continue from where it stopped.
501
+ 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.]` : ''
502
+ return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}${partialNote}`
461
503
  }
462
504
 
463
505
  /** What to tell the model about a resume request. */
@@ -690,13 +732,31 @@ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude:
690
732
  knownMcpAliases = aliases
691
733
  }
692
734
 
735
+ /** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
736
+ * does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
737
+ * extensions register in a child. Claude's capitalized spellings fold onto these. */
738
+ const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource'])
739
+
740
+ /** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
741
+ * to launch with an error naming the entries, instead of running tool-less. */
742
+ function unresolvedToolsError(agent: AgentConfig): string | undefined {
743
+ if (!agent.tools || agent.tools.length === 0) return undefined
744
+ const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
745
+ const known = new Set(knownMcpAliases.map((alias) => fold(alias.pi)))
746
+ const resolves = expandMcpToolPatterns(agent.tools, knownMcpAliases).some((entry) => CHILD_TOOL_NAMES.has(fold(entry)) || known.has(fold(entry)))
747
+ if (resolves) return undefined
748
+ return `Agent "${agent.name}" would launch with zero tools: no entry in [${agent.tools.join(', ')}] resolves to a tool.`
749
+ }
750
+
693
751
  /** CLI args shared by foreground and background children, from the agent's config. */
694
752
  function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
695
753
  const args: string[] = ['--mode', 'json', '-p', '--no-session']
696
754
  // 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
755
+ // this user can actually run; then CLAUDE_CODE_SUBAGENT_MODEL, per Claude's model
756
+ // order (invocation model, frontmatter model, this variable, the session model).
757
+ // pi reads a thinking level from the model pattern's :suffix when a model is
758
+ // pinned, and from --thinking otherwise.
759
+ const model = agent.model ?? aliasModel ?? process.env.CLAUDE_CODE_SUBAGENT_MODEL
700
760
  if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
701
761
  else if (agent.effort) args.push('--thinking', agent.effort)
702
762
  // Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
@@ -758,6 +818,13 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
758
818
  details: makeDetails('single')([]),
759
819
  }
760
820
  }
821
+ const toolsError = unresolvedToolsError(agent)
822
+ if (toolsError) {
823
+ return {
824
+ content: [{ type: 'text', text: toolsError }],
825
+ details: makeDetails('single')([]),
826
+ }
827
+ }
761
828
  if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
762
829
  return backgroundCapResult(makeDetails)
763
830
  }
@@ -782,7 +849,9 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
782
849
  const promptBody = childPromptBody(agent, skillRoots, memorySection)
783
850
  if (promptBody.trim()) {
784
851
  tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
785
- args.push('--append-system-prompt', tmpPrompt.filePath)
852
+ // Claude: the agent body replaces the default system prompt (see the
853
+ // foreground path).
854
+ args.push('--system-prompt', tmpPrompt.filePath)
786
855
  }
787
856
  args.push(`Task: ${task}`)
788
857
  const invocation = getPiInvocation(args)
@@ -793,7 +862,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
793
862
  // catch covers the synchronous path, but the worktree branch reaches here from an
794
863
  // async continuation outside it, so the guard must live in finish itself.
795
864
  try {
796
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
865
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
797
866
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
798
867
  } catch {
799
868
  // Session disposed after the run outlived it; nothing to notify.
@@ -1486,7 +1555,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
1486
1555
 
1487
1556
  if (params.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1488
1557
 
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 }) }
1558
+ const mode: ModeContext = {
1559
+ agents,
1560
+ defaultCwd: ctx.cwd,
1561
+ signal,
1562
+ onUpdate,
1563
+ makeDetails,
1564
+ skillRoots,
1565
+ availableModels,
1566
+ projectApproved,
1567
+ onPhase: (phase, agentType, agentId, lastAssistantMessage) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId, ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }),
1568
+ }
1490
1569
 
1491
1570
  if (params.chain?.length) return runChainMode(params.chain, mode)
1492
1571
  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.30",
3
+ "version": "1.0.32",
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",