pi-code 1.0.31 → 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.
@@ -340,7 +340,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
340
340
  if (!isSubagentPhaseEvent(data) || !sessionCtx) return
341
341
  const ctx = sessionCtx
342
342
  const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
343
- 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 } : {}) }
344
346
  try {
345
347
  const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
346
348
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
@@ -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.31",
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",