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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
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 --
|
|
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('--
|
|
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
|
-
|
|
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
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
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
|
|
698
|
-
//
|
|
699
|
-
|
|
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
|
-
|
|
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 = {
|
|
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.
|
|
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",
|