pi-code 1.0.3 → 1.0.4
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.
- package/extensions/commands.ts +6 -6
- package/extensions/context-imports.ts +5 -2
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +48 -18
- package/extensions/internal/command-file.ts +20 -10
- package/extensions/mcp.ts +20 -5
- package/extensions/memory.ts +39 -7
- package/extensions/notify.ts +1 -1
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/status-line.ts +4 -1
- package/extensions/subagent/background.ts +92 -22
- package/extensions/subagent/index.ts +33 -15
- package/extensions/web.ts +7 -1
- package/package.json +1 -1
package/extensions/commands.ts
CHANGED
|
@@ -86,12 +86,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
86
86
|
// fire-and-forget, so the restore would land before the agent ever read the tool
|
|
87
87
|
// list, leaving the command running with everything enabled.
|
|
88
88
|
if (parsed.allowedTools) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
// Only the first restriction in a turn sees the unrestricted set; a second
|
|
90
|
+
// command must grant and restore against that original set, or its own tools
|
|
91
|
+
// are intersected away by the first command's narrowing.
|
|
92
|
+
const original = pendingRestore ?? pi.getActiveTools()
|
|
93
|
+
pendingRestore = original
|
|
94
|
+
const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
|
|
95
95
|
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
96
96
|
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
97
97
|
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
@@ -110,11 +110,14 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
|
|
|
110
110
|
return null
|
|
111
111
|
}
|
|
112
112
|
if (seen.has(real)) return null
|
|
113
|
-
seen.add(real)
|
|
114
113
|
if (!isUnder(real, allowedRoots)) return null
|
|
115
114
|
try {
|
|
116
115
|
// real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
|
|
117
|
-
|
|
116
|
+
const body = fs.readFileSync(real, 'utf-8')
|
|
117
|
+
// Only a consumed file dedupes: marking a blocked or unreadable target seen
|
|
118
|
+
// would let one reader's failure suppress the import for a later, allowed one.
|
|
119
|
+
seen.add(real)
|
|
120
|
+
return { real, body }
|
|
118
121
|
} catch {
|
|
119
122
|
return null
|
|
120
123
|
}
|
|
@@ -137,12 +137,25 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
137
137
|
pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
|
|
138
138
|
const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
|
|
139
139
|
if (check.code !== 0) {
|
|
140
|
-
await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
140
|
+
const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
|
|
141
|
+
if (init.code !== 0) {
|
|
142
|
+
// Every later snapshot fails against the missing repo, so without this the
|
|
143
|
+
// user first learns /rewind is dead at the moment they need it.
|
|
144
|
+
ctx.ui.notify(`Checkpoints disabled: ${init.stderr.trim() || 'git init failed'}`, 'warning')
|
|
145
|
+
return
|
|
146
|
+
}
|
|
141
147
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
|
|
142
148
|
await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
|
|
143
149
|
}
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
/** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
|
|
153
|
+
* snapshot restores as a no-op rather than vetoing the whole rewind. */
|
|
154
|
+
async function snapshotIsEmpty(ref: string): Promise<boolean> {
|
|
155
|
+
const files = await gitShadow(['ls-tree', '-r', '--name-only', ref])
|
|
156
|
+
return files.code === 0 && files.stdout.trim() === ''
|
|
157
|
+
}
|
|
158
|
+
|
|
146
159
|
async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
|
|
147
160
|
const createdAt = new Date().toISOString()
|
|
148
161
|
const add = await gitShadow(['add', '-A'])
|
|
@@ -175,6 +188,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
175
188
|
ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
|
|
176
189
|
return true
|
|
177
190
|
}
|
|
191
|
+
if (await snapshotIsEmpty(checkpoint.ref)) {
|
|
192
|
+
ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
178
195
|
const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
|
|
179
196
|
if (result.code !== 0) {
|
|
180
197
|
ctx.ui.notify(`Code restore failed: ${result.stderr.trim()}`, 'warning')
|
|
@@ -248,6 +265,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
|
|
|
248
265
|
|
|
249
266
|
const choice = await ctx.ui.select('Restore code state?', ['Yes, restore code to that point', 'No, keep current code'])
|
|
250
267
|
if (choice?.startsWith('Yes')) {
|
|
268
|
+
if (await snapshotIsEmpty(checkpoint.ref)) {
|
|
269
|
+
ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
251
272
|
const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
|
|
252
273
|
ctx.ui.notify(result.code === 0 ? 'Code restored to checkpoint' : `Restore failed: ${result.stderr.trim()}`, result.code === 0 ? 'info' : 'warning')
|
|
253
274
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface HookRunResult {
|
|
|
78
78
|
stderr: string
|
|
79
79
|
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
80
80
|
timedOut: boolean
|
|
81
|
+
/** The process errored before delivering a verdict (spawn failure, EIO). */
|
|
82
|
+
spawnFailed?: boolean
|
|
81
83
|
}
|
|
82
84
|
export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
|
|
83
85
|
|
|
@@ -265,7 +267,9 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
|
|
|
265
267
|
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
266
268
|
})
|
|
267
269
|
child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
|
|
268
|
-
|
|
270
|
+
// Marked rather than silently read as a clean run: under fd exhaustion a
|
|
271
|
+
// deny-list guard that never spawned would otherwise pass as an allow.
|
|
272
|
+
child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
|
|
269
273
|
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
270
274
|
// so ignore EPIPE on this write rather than crashing the host process.
|
|
271
275
|
child.stdin?.on('error', () => {})
|
|
@@ -294,21 +298,42 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
|
|
|
294
298
|
Object.assign(target, next)
|
|
295
299
|
}
|
|
296
300
|
|
|
297
|
-
/**
|
|
298
|
-
*
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
/** Claude surfaces a hook error notice and the action proceeds; silence would read a
|
|
302
|
+
* guard that never ran as a clean allow. */
|
|
303
|
+
function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
|
|
304
|
+
if (!notify) return
|
|
305
|
+
for (const [i, result] of results.entries()) {
|
|
306
|
+
if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
311
|
+
* verdict in config order wins. For MCP tools the matcher sees both the pi name and
|
|
312
|
+
* the Claude alias, and the payload reports the alias, which is the name a
|
|
313
|
+
* Claude-written hook script expects in tool_name. Every hook sees the original
|
|
314
|
+
* tool input; hookSpecificOutput.updatedInput replaces the input in place as each
|
|
315
|
+
* hook completes, so with several rewrites the last to finish takes effect, which
|
|
316
|
+
* is Claude's documented (non-deterministic) behavior. */
|
|
302
317
|
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
|
|
303
318
|
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
304
|
-
|
|
305
|
-
|
|
319
|
+
const commands = matchingCommands(config.PreToolUse, names)
|
|
320
|
+
const results = await Promise.all(
|
|
321
|
+
commands.map((command) =>
|
|
322
|
+
runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
|
|
323
|
+
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
324
|
+
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
325
|
+
return result
|
|
326
|
+
}),
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
surfaceHookFailures(commands, results, onSystemMessage)
|
|
330
|
+
for (const [i, result] of results.entries()) {
|
|
306
331
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
307
332
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
308
|
-
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
334
|
+
}
|
|
335
|
+
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
336
|
+
for (const result of results) {
|
|
312
337
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
313
338
|
if (decision.block) return decision
|
|
314
339
|
}
|
|
@@ -343,14 +368,19 @@ function promptContext(stdout: string): string {
|
|
|
343
368
|
return stdout.trim()
|
|
344
369
|
}
|
|
345
370
|
|
|
346
|
-
/** Run UserPromptSubmit hooks: the first blocking
|
|
347
|
-
*
|
|
371
|
+
/** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
|
|
372
|
+
* verdict in config order wins; otherwise their additional context is concatenated
|
|
373
|
+
* in config order for injection ahead of the prompt. */
|
|
348
374
|
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
375
|
+
const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
|
|
376
|
+
const results = await Promise.all(commands.map((command) => runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
|
|
377
|
+
surfaceHookFailures(commands, results, onSystemMessage)
|
|
378
|
+
for (const [i, result] of results.entries()) {
|
|
379
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
380
|
+
}
|
|
381
|
+
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
349
382
|
const contexts: string[] = []
|
|
350
|
-
for (const
|
|
351
|
-
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
352
|
-
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
353
|
-
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
383
|
+
for (const result of results) {
|
|
354
384
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
355
385
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
356
386
|
const context = promptContext(result.stdout)
|
|
@@ -148,16 +148,21 @@ export function splitArgs(args: string): string[] {
|
|
|
148
148
|
return out
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}
|
|
152
|
-
* unfilled positional becomes empty rather than leaking its
|
|
151
|
+
/** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`, and `\$`
|
|
152
|
+
* for a literal dollar. An unfilled positional becomes empty rather than leaking its
|
|
153
|
+
* literal token. One pass with a replacer function: sequential string passes both
|
|
154
|
+
* interpreted `$&`-style metacharacters in the arguments and re-scanned substituted
|
|
155
|
+
* text, so `$` sequences the user typed were consumed as tokens. */
|
|
153
156
|
export function substituteArgs(body: string, args: string): string {
|
|
154
157
|
const parts = splitArgs(args)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
const all = args.trim()
|
|
159
|
+
return body.replaceAll(/\\\$|\$\{(\d+):-([^}]*)\}|\$\{ARGUMENTS:-([^}]*)\}|\$ARGUMENTS\b|\$@|\$(\d+)/g, (token, index?: string, fallback?: string, argsFallback?: string, position?: string) => {
|
|
160
|
+
if (token === '\\$') return '$'
|
|
161
|
+
if (index !== undefined) return parts[Number(index) - 1] ?? fallback ?? ''
|
|
162
|
+
if (argsFallback !== undefined) return all || argsFallback
|
|
163
|
+
if (position !== undefined) return parts[Number(position) - 1] ?? ''
|
|
164
|
+
return all
|
|
165
|
+
})
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
/** `a/b/c.md` becomes Claude's `a:b:c`. */
|
|
@@ -239,12 +244,17 @@ export async function expandDynamicContent(body: string, cwd: string, exec: Comm
|
|
|
239
244
|
bashMatch = bashPattern.exec(body)
|
|
240
245
|
}
|
|
241
246
|
|
|
242
|
-
|
|
247
|
+
// Splice by recorded position: a textual replace would interpret `$` sequences in
|
|
248
|
+
// the command's output and could hit an identical fenced copy of the span instead.
|
|
249
|
+
let expanded = ''
|
|
250
|
+
let cursor = 0
|
|
243
251
|
for (const entry of commands) {
|
|
244
252
|
const result = await exec(entry.command)
|
|
245
253
|
const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
|
|
246
|
-
expanded
|
|
254
|
+
expanded += body.slice(cursor, entry.index) + output
|
|
255
|
+
cursor = entry.index + entry.span.length
|
|
247
256
|
}
|
|
257
|
+
expanded += body.slice(cursor)
|
|
248
258
|
|
|
249
259
|
// Ranges are recomputed: command output can change offsets.
|
|
250
260
|
const fencedAfter = fencedRanges(expanded)
|
package/extensions/mcp.ts
CHANGED
|
@@ -482,8 +482,24 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
482
482
|
const count = registerTools(name, config, client, tools)
|
|
483
483
|
subscribeToToolChanges(name, config, client)
|
|
484
484
|
status.set(name, { state: 'connected', tools: count })
|
|
485
|
+
// A server that dies mid-session would otherwise stay "connected" in /mcp
|
|
486
|
+
// while every call fails with the SDK's bare "Not connected"; flip the
|
|
487
|
+
// status and free the name so a later session start can reconnect it.
|
|
488
|
+
client.onclose = () => {
|
|
489
|
+
if (clients.get(name) !== client) return
|
|
490
|
+
clients.delete(name)
|
|
491
|
+
status.set(name, { state: 'disconnected', tools: 0 })
|
|
492
|
+
}
|
|
485
493
|
} catch (error) {
|
|
486
494
|
status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
|
|
495
|
+
// Connected but failed after (tool listing hung or errored): left in the
|
|
496
|
+
// map, the client idles its process for the whole session and the
|
|
497
|
+
// duplicate-name guard blocks the name for every later attempt.
|
|
498
|
+
const leaked = clients.get(name)
|
|
499
|
+
if (leaked) {
|
|
500
|
+
clients.delete(name)
|
|
501
|
+
void leaked.close().catch(() => {})
|
|
502
|
+
}
|
|
487
503
|
}
|
|
488
504
|
}),
|
|
489
505
|
)
|
|
@@ -503,16 +519,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
503
519
|
return true
|
|
504
520
|
}
|
|
505
521
|
|
|
506
|
-
let userConnected = false
|
|
507
522
|
let projectConnected = false
|
|
508
523
|
|
|
509
524
|
pi.on('session_start', async (_event, ctx) => {
|
|
510
525
|
// Connecting spawns processes and opens sockets, so it belongs here rather than in
|
|
511
526
|
// the factory: pi runs the factory for invocations that never start a session.
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
527
|
+
// Names still connected are filtered out, so a later session start only retries
|
|
528
|
+
// servers that failed or whose transport dropped, without duplicate-name warnings.
|
|
529
|
+
const userServers = Object.fromEntries(Object.entries(loadUserScope(os.homedir(), ctx.cwd)).filter(([name]) => !clients.has(name)))
|
|
530
|
+
if (Object.keys(userServers).length > 0) await connectServers(userServers)
|
|
516
531
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once
|
|
517
532
|
// the project is trusted. Per-server settings refine that: disabled servers never
|
|
518
533
|
// connect, servers the user consented to individually connect without the
|
package/extensions/memory.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
|
|
|
93
93
|
}
|
|
94
94
|
fs.mkdirSync(dir, { recursive: true })
|
|
95
95
|
fs.writeFileSync(path.join(dir, `${name}.md`), content)
|
|
96
|
-
|
|
96
|
+
writeIndex(indexPath, upsertIndexLine(index, name, description))
|
|
97
97
|
return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -150,25 +150,45 @@ const MemoryParams = Type.Object({
|
|
|
150
150
|
function readIndex(dir: string): string {
|
|
151
151
|
try {
|
|
152
152
|
return fs.readFileSync(path.join(dir, INDEX_FILE), 'utf-8')
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Only a missing file means an empty index. Treating any other failure as empty
|
|
155
|
+
// lets the next read-modify-write clobber every existing entry.
|
|
156
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
|
157
|
+
throw error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** For display paths, where a transiently unreadable index should not break the
|
|
162
|
+
* session; the mutating paths go through readIndex and refuse instead. */
|
|
163
|
+
function readIndexQuietly(dir: string): string {
|
|
164
|
+
try {
|
|
165
|
+
return readIndex(dir)
|
|
153
166
|
} catch {
|
|
154
167
|
return ''
|
|
155
168
|
}
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
/** Replace the index through a rename so a crash mid-write cannot truncate it. */
|
|
172
|
+
function writeIndex(indexPath: string, content: string): void {
|
|
173
|
+
const tmp = `${indexPath}.${process.pid}.tmp`
|
|
174
|
+
fs.writeFileSync(tmp, content)
|
|
175
|
+
fs.renameSync(tmp, indexPath)
|
|
176
|
+
}
|
|
177
|
+
|
|
158
178
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
159
179
|
let dir = memoryDir(process.cwd())
|
|
160
180
|
|
|
161
181
|
pi.on('session_start', async (_event, ctx) => {
|
|
162
182
|
migrateLegacyStore(ctx.cwd)
|
|
163
183
|
dir = memoryDir(ctx.cwd)
|
|
164
|
-
const count =
|
|
184
|
+
const count = readIndexQuietly(dir)
|
|
165
185
|
.split('\n')
|
|
166
186
|
.filter((l) => l.startsWith('- ')).length
|
|
167
187
|
if (count > 0) ctx.ui.notify(`Memory: ${count} memories loaded`, 'info')
|
|
168
188
|
})
|
|
169
189
|
|
|
170
190
|
pi.on('before_agent_start', async (event) => {
|
|
171
|
-
const index =
|
|
191
|
+
const index = readIndexQuietly(dir)
|
|
172
192
|
if (!index.trim()) return
|
|
173
193
|
return {
|
|
174
194
|
systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
|
|
@@ -185,7 +205,11 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
185
205
|
const indexPath = path.join(dir, INDEX_FILE)
|
|
186
206
|
|
|
187
207
|
if (params.action === 'save') {
|
|
188
|
-
|
|
208
|
+
try {
|
|
209
|
+
return saveMemory(dir, indexPath, name, params.description, params.content)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
|
|
212
|
+
}
|
|
189
213
|
}
|
|
190
214
|
|
|
191
215
|
if (params.action === 'read') {
|
|
@@ -200,14 +224,22 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
200
224
|
|
|
201
225
|
if (params.action === 'delete') {
|
|
202
226
|
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
227
|
+
// The index is read before anything is removed: refusing on a failed read
|
|
228
|
+
// must leave both the memory file and the index as they were.
|
|
229
|
+
let index: string
|
|
230
|
+
try {
|
|
231
|
+
index = readIndex(dir)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
234
|
+
}
|
|
203
235
|
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
204
|
-
const remaining = removeIndexLine(
|
|
205
|
-
if (remaining)
|
|
236
|
+
const remaining = removeIndexLine(index, name)
|
|
237
|
+
if (remaining) writeIndex(indexPath, remaining)
|
|
206
238
|
else fs.rmSync(indexPath, { force: true })
|
|
207
239
|
return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
const index =
|
|
242
|
+
const index = readIndexQuietly(dir)
|
|
211
243
|
return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
|
|
212
244
|
},
|
|
213
245
|
})
|
package/extensions/notify.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - Windows toast: Windows Terminal (WSL)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
11
12
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
12
13
|
|
|
13
14
|
function windowsToastScript(title: string, body: string): string {
|
|
@@ -29,7 +30,6 @@ function notifyOSC99(title: string, body: string): void {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function notifyWindows(title: string, body: string): void {
|
|
32
|
-
const { execFile } = require('node:child_process')
|
|
33
33
|
// Resolve powershell from a fixed system path rather than through PATH, and let the callback
|
|
34
34
|
// capture a spawn failure instead of an unhandled 'error' event crashing the host.
|
|
35
35
|
const root = process.env.SystemRoot ?? String.raw`C:\Windows`
|
|
@@ -24,6 +24,10 @@ import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type
|
|
|
24
24
|
// Tools
|
|
25
25
|
const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
|
|
26
26
|
|
|
27
|
+
/** Agent runs in execution mode with no [DONE:n] progress before execution ends on
|
|
28
|
+
* its own. Kept small: each stalled run re-injects the stale plan into the turn. */
|
|
29
|
+
const STALLED_RUN_LIMIT = 2
|
|
30
|
+
|
|
27
31
|
// Type guard for assistant messages
|
|
28
32
|
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
|
29
33
|
return m.role === 'assistant' && Array.isArray(m.content)
|
|
@@ -60,6 +64,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
60
64
|
let todoItems: TodoItem[] = []
|
|
61
65
|
let planFromTool = false
|
|
62
66
|
let savedTools: string[] = []
|
|
67
|
+
let stalledRuns = 0
|
|
68
|
+
let runProgress = false
|
|
63
69
|
|
|
64
70
|
function enterPlanTools(): void {
|
|
65
71
|
savedTools = pi.getActiveTools()
|
|
@@ -113,6 +119,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
113
119
|
executionMode = false
|
|
114
120
|
todoItems = []
|
|
115
121
|
planFromTool = false
|
|
122
|
+
stalledRuns = 0
|
|
123
|
+
runProgress = false
|
|
116
124
|
|
|
117
125
|
if (planModeEnabled) {
|
|
118
126
|
enterPlanTools()
|
|
@@ -140,11 +148,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
140
148
|
})
|
|
141
149
|
}
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!todoItems.every((t) => t.completed)) return
|
|
146
|
-
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
147
|
-
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
151
|
+
function endExecution(ctx: ExtensionContext, content: string): void {
|
|
152
|
+
pi.sendMessage({ customType: 'plan-complete', content, display: true }, { triggerTurn: false })
|
|
148
153
|
executionMode = false
|
|
149
154
|
todoItems = []
|
|
150
155
|
restoreTools()
|
|
@@ -152,6 +157,23 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
152
157
|
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
// Announce completion and reset once every step is done
|
|
161
|
+
function finalizeCompletedExecution(ctx: ExtensionContext): void {
|
|
162
|
+
if (!todoItems.every((t) => t.completed)) return
|
|
163
|
+
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
164
|
+
endExecution(ctx, `**Plan Complete!** ✓\n\n${completedList}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Models regularly drop or renumber a [DONE:n] marker; without a bounded exit the
|
|
168
|
+
* stale plan would be injected into every later turn until the user finds /plan. */
|
|
169
|
+
function endStalledExecution(ctx: ExtensionContext): void {
|
|
170
|
+
const remaining = todoItems
|
|
171
|
+
.filter((t) => !t.completed)
|
|
172
|
+
.map((t) => `${t.step}. ${t.text}`)
|
|
173
|
+
.join('\n')
|
|
174
|
+
endExecution(ctx, `**Plan execution ended** after ${STALLED_RUN_LIMIT} turns without step progress. Unfinished steps:\n\n${remaining}`)
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
// Fall back to extracting a plan from the last assistant message's prose
|
|
156
178
|
function deriveTodosFromProse(messages: AgentMessage[]): void {
|
|
157
179
|
const lastAssistant = [...messages].reverse().find(isAssistantMessage)
|
|
@@ -170,6 +192,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
170
192
|
planModeEnabled = false
|
|
171
193
|
executionMode = todoItems.length > 0
|
|
172
194
|
planFromTool = false
|
|
195
|
+
stalledRuns = 0
|
|
196
|
+
runProgress = false
|
|
173
197
|
restoreTools()
|
|
174
198
|
publishPlanState()
|
|
175
199
|
updateStatus(ctx)
|
|
@@ -241,9 +265,19 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
241
265
|
handler: async (ctx) => togglePlanMode(ctx),
|
|
242
266
|
})
|
|
243
267
|
|
|
244
|
-
//
|
|
268
|
+
// Enforce plan mode at call time, not only through the active-tool set: pi
|
|
269
|
+
// activates tools registered after the restriction was applied (an MCP server
|
|
270
|
+
// connecting during session_start, or a mid-session list_changed refresh), so the
|
|
271
|
+
// set alone leaks write-capable tools into plan mode.
|
|
245
272
|
pi.on('tool_call', async (event) => {
|
|
246
|
-
if (!planModeEnabled
|
|
273
|
+
if (!planModeEnabled) return
|
|
274
|
+
if (!PLAN_MODE_TOOLS.includes(event.toolName)) {
|
|
275
|
+
return {
|
|
276
|
+
block: true,
|
|
277
|
+
reason: `Plan mode: tool blocked (read-only mode). Use /plan to disable plan mode first.\nTool: ${event.toolName}`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (event.toolName !== 'bash') return
|
|
247
281
|
|
|
248
282
|
const command = event.input.command as string
|
|
249
283
|
if (!isSafeCommand(command)) {
|
|
@@ -332,6 +366,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
332
366
|
|
|
333
367
|
const text = getTextContent(event.message)
|
|
334
368
|
if (markCompletedSteps(text, todoItems) > 0) {
|
|
369
|
+
runProgress = true
|
|
335
370
|
updateStatus(ctx)
|
|
336
371
|
}
|
|
337
372
|
persistState()
|
|
@@ -339,9 +374,18 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
339
374
|
|
|
340
375
|
// Handle plan completion and plan mode UI
|
|
341
376
|
pi.on('agent_end', async (event, ctx) => {
|
|
342
|
-
// Check if execution is complete
|
|
377
|
+
// Check if execution is complete, or has stalled without marker progress
|
|
343
378
|
if (executionMode && todoItems.length > 0) {
|
|
344
|
-
|
|
379
|
+
if (todoItems.every((t) => t.completed)) {
|
|
380
|
+
finalizeCompletedExecution(ctx)
|
|
381
|
+
stalledRuns = 0
|
|
382
|
+
} else if (runProgress) {
|
|
383
|
+
stalledRuns = 0
|
|
384
|
+
} else {
|
|
385
|
+
stalledRuns++
|
|
386
|
+
if (stalledRuns >= STALLED_RUN_LIMIT) endStalledExecution(ctx)
|
|
387
|
+
}
|
|
388
|
+
runProgress = false
|
|
345
389
|
return
|
|
346
390
|
}
|
|
347
391
|
|
|
@@ -376,6 +420,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
376
420
|
executionMode = false
|
|
377
421
|
todoItems = []
|
|
378
422
|
planFromTool = false
|
|
423
|
+
stalledRuns = 0
|
|
424
|
+
runProgress = false
|
|
379
425
|
|
|
380
426
|
if (pi.getFlag('plan') === true) {
|
|
381
427
|
planModeEnabled = true
|
|
@@ -104,6 +104,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
104
104
|
/** The stdin payload per Claude's documented statusline contract. */
|
|
105
105
|
function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
|
|
106
106
|
const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
|
|
107
|
+
const model = ctx.model as { id?: string; name?: string } | undefined
|
|
107
108
|
// Same gate as the config read above: an unapproved project's style is not applied,
|
|
108
109
|
// so reporting it here would describe a style the session is not using.
|
|
109
110
|
const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
|
|
@@ -111,7 +112,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
111
112
|
session_id: ctx.sessionManager.getSessionId(),
|
|
112
113
|
cwd: ctx.cwd,
|
|
113
114
|
workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
|
|
114
|
-
|
|
115
|
+
// Both fields, per Claude's documented contract: published statusline scripts
|
|
116
|
+
// read .model.display_name and render the literal "null" when it is missing.
|
|
117
|
+
model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
|
|
115
118
|
cost: { total_cost_usd: sessionCost(ctx) },
|
|
116
119
|
context_window: { context_window_size: usage.contextWindow, used_percentage: usage.percent, total_input_tokens: usage.tokens },
|
|
117
120
|
permission_mode: permissionMode,
|
|
@@ -19,8 +19,13 @@ export interface BackgroundRun {
|
|
|
19
19
|
exitCode?: number
|
|
20
20
|
output?: string
|
|
21
21
|
turns: number
|
|
22
|
+
/** Last stderr bytes of a failed child; the only diagnostics a boot failure leaves. */
|
|
23
|
+
stderr?: string
|
|
22
24
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
23
25
|
kill?: () => void
|
|
26
|
+
/** True until the child process actually closes: a cancelled child that ignores
|
|
27
|
+
* SIGTERM is still alive and must keep holding its concurrency slot. */
|
|
28
|
+
live?: boolean
|
|
24
29
|
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
25
30
|
sessionId: string
|
|
26
31
|
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
@@ -42,30 +47,66 @@ const runs = new Map<string, BackgroundRun>()
|
|
|
42
47
|
/** Cap on simultaneously running background children. */
|
|
43
48
|
export const MAX_BACKGROUND_RUNS = 8
|
|
44
49
|
|
|
50
|
+
/** Finished runs kept for status listings and resume; older ones are evicted so a
|
|
51
|
+
* long session's registry (each entry holds its final output) cannot grow forever. */
|
|
52
|
+
export const MAX_FINISHED_RUNS = 20
|
|
53
|
+
|
|
54
|
+
/** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
|
|
55
|
+
const CANCEL_KILL_GRACE_MS = 5000
|
|
56
|
+
|
|
57
|
+
/** Bytes of stderr kept per run, enough for the boot error without buffering logs. */
|
|
58
|
+
const STDERR_TAIL_CHARS = 2048
|
|
59
|
+
|
|
45
60
|
export function activeBackgroundRuns(): number {
|
|
46
|
-
return [...runs.values()].filter((run) => run.state === 'running').length
|
|
61
|
+
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
47
62
|
}
|
|
48
63
|
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
function evictFinishedRuns(): void {
|
|
65
|
+
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
66
|
+
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
70
|
+
* long run's JSONL stdout never accumulates whole in the parent's memory. */
|
|
71
|
+
export function createJsonlOutputParser(): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
|
|
72
|
+
let buffer = ''
|
|
51
73
|
let text = ''
|
|
52
74
|
let turns = 0
|
|
53
|
-
|
|
54
|
-
if (!
|
|
75
|
+
const takeLine = (raw: string): void => {
|
|
76
|
+
if (!raw.trim()) return
|
|
55
77
|
let event: { type?: string; message?: { role?: string; content?: Array<{ type: string; text?: string }> } }
|
|
56
78
|
try {
|
|
57
|
-
event = JSON.parse(
|
|
79
|
+
event = JSON.parse(raw)
|
|
58
80
|
} catch {
|
|
59
|
-
|
|
81
|
+
return
|
|
60
82
|
}
|
|
61
|
-
if (event.type !== 'message_end' || event.message?.role !== 'assistant')
|
|
83
|
+
if (event.type !== 'message_end' || event.message?.role !== 'assistant') return
|
|
62
84
|
turns++
|
|
63
85
|
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
64
86
|
// foreground path so a multi-part message reads the same in both.
|
|
65
87
|
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
66
88
|
if (parts.length > 0) text = parts.join('\n')
|
|
67
89
|
}
|
|
68
|
-
return {
|
|
90
|
+
return {
|
|
91
|
+
push(chunk) {
|
|
92
|
+
buffer += chunk
|
|
93
|
+
const lines = buffer.split('\n')
|
|
94
|
+
buffer = lines.pop() ?? ''
|
|
95
|
+
for (const line of lines) takeLine(line)
|
|
96
|
+
},
|
|
97
|
+
flush() {
|
|
98
|
+
takeLine(buffer)
|
|
99
|
+
buffer = ''
|
|
100
|
+
return { text, turns }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
|
|
106
|
+
export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
|
|
107
|
+
const parser = createJsonlOutputParser()
|
|
108
|
+
parser.push(jsonl)
|
|
109
|
+
return parser.flush()
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
export function formatStatus(all: Iterable<Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'exitCode'>>): string {
|
|
@@ -100,15 +141,22 @@ export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
|
100
141
|
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
101
142
|
* same --session-id, so it continues with everything it already saw rather than
|
|
102
143
|
* re-deriving context the parent would have to repeat. */
|
|
103
|
-
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'unknown' {
|
|
144
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'at-capacity' | 'unknown' {
|
|
104
145
|
const run = runs.get(id)
|
|
105
146
|
if (!run) return 'unknown'
|
|
106
|
-
if (run.state === 'running') return 'still-running'
|
|
107
|
-
|
|
147
|
+
if (run.state === 'running' || run.live) return 'still-running'
|
|
148
|
+
// A resume spawns a child like a fresh start does, so it counts against the cap.
|
|
149
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return 'at-capacity'
|
|
150
|
+
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
151
|
+
// prompt dir every follow-up.
|
|
152
|
+
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
153
|
+
run.spawn = { ...run.spawn, args: rebuilt }
|
|
154
|
+
const args = rebuilt.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
108
155
|
run.state = 'running'
|
|
109
156
|
run.task = task
|
|
110
157
|
run.output = undefined
|
|
111
158
|
run.exitCode = undefined
|
|
159
|
+
run.stderr = undefined
|
|
112
160
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
113
161
|
return 'resumed'
|
|
114
162
|
}
|
|
@@ -156,25 +204,41 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
156
204
|
const proc = spawn(invocation.command, invocation.args, {
|
|
157
205
|
cwd: invocation.cwd,
|
|
158
206
|
shell: false,
|
|
159
|
-
stdio: ['ignore', 'pipe', '
|
|
207
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
160
208
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
161
209
|
detached: true,
|
|
162
210
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
163
211
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
164
212
|
})
|
|
165
|
-
run.
|
|
213
|
+
run.live = true
|
|
214
|
+
const killGroup = (signal: NodeJS.Signals): void => {
|
|
166
215
|
try {
|
|
167
|
-
process.kill(-proc.pid!,
|
|
216
|
+
process.kill(-proc.pid!, signal)
|
|
168
217
|
} catch {
|
|
169
|
-
|
|
218
|
+
try {
|
|
219
|
+
proc.kill(signal)
|
|
220
|
+
} catch {
|
|
221
|
+
// already gone
|
|
222
|
+
}
|
|
170
223
|
}
|
|
171
224
|
}
|
|
172
|
-
|
|
225
|
+
run.kill = () => {
|
|
226
|
+
killGroup('SIGTERM')
|
|
227
|
+
// A child ignoring SIGTERM would hold its cap slot and process forever.
|
|
228
|
+
const escalate = setTimeout(() => killGroup('SIGKILL'), CANCEL_KILL_GRACE_MS)
|
|
229
|
+
escalate.unref()
|
|
230
|
+
proc.once('close', () => clearTimeout(escalate))
|
|
231
|
+
}
|
|
232
|
+
// Parsed as it streams: buffering the whole JSONL replays every tool result echoed
|
|
233
|
+
// by the child through the parent's memory for the life of the run.
|
|
234
|
+
const parser = createJsonlOutputParser()
|
|
235
|
+
let stderrTail = ''
|
|
173
236
|
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
174
237
|
let completed = false
|
|
175
238
|
const complete = (): void => {
|
|
176
239
|
if (completed) return
|
|
177
240
|
completed = true
|
|
241
|
+
evictFinishedRuns()
|
|
178
242
|
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
179
243
|
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
180
244
|
// the child's 'close'/'error' listener, where nothing upstream catches: an escaping
|
|
@@ -187,27 +251,33 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
187
251
|
// the session that asked for this run is gone
|
|
188
252
|
}
|
|
189
253
|
}
|
|
190
|
-
proc.stdout.on('data', (data) =>
|
|
191
|
-
stdout += data.toString()
|
|
192
|
-
})
|
|
254
|
+
proc.stdout.on('data', (data) => parser.push(data.toString()))
|
|
193
255
|
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
194
256
|
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
195
257
|
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
196
258
|
proc.stdout.on('error', () => {})
|
|
259
|
+
proc.stderr?.on('data', (data) => {
|
|
260
|
+
stderrTail = (stderrTail + data.toString()).slice(-STDERR_TAIL_CHARS)
|
|
261
|
+
})
|
|
262
|
+
proc.stderr?.on('error', () => {})
|
|
197
263
|
proc.on('close', (code) => {
|
|
198
|
-
const { text, turns } =
|
|
264
|
+
const { text, turns } = parser.flush()
|
|
199
265
|
run.kill = undefined
|
|
266
|
+
run.live = false
|
|
200
267
|
// A cancelled run keeps that state: its non-zero exit is the cancellation.
|
|
201
268
|
if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
|
|
202
269
|
run.exitCode = code ?? 0
|
|
203
270
|
run.output = text
|
|
204
271
|
run.turns = turns
|
|
272
|
+
run.stderr = stderrTail.trim() || undefined
|
|
205
273
|
complete()
|
|
206
274
|
})
|
|
207
|
-
proc.on('error', () => {
|
|
275
|
+
proc.on('error', (error) => {
|
|
208
276
|
run.kill = undefined
|
|
277
|
+
run.live = false
|
|
209
278
|
run.state = 'failed'
|
|
210
279
|
run.exitCode = 1
|
|
280
|
+
run.stderr = stderrTail.trim() || error.message
|
|
211
281
|
complete()
|
|
212
282
|
})
|
|
213
283
|
}
|
|
@@ -28,7 +28,7 @@ import { isProjectApproved, isProjectApprovedSilently } from '../internal/projec
|
|
|
28
28
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
29
29
|
import { skillDirs } from '../skills.js'
|
|
30
30
|
import { type AgentConfig, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
31
|
-
import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
31
|
+
import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
32
32
|
|
|
33
33
|
const MAX_PARALLEL_TASKS = 8
|
|
34
34
|
const MAX_CONCURRENCY = 4
|
|
@@ -343,6 +343,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
343
343
|
cwd: cwd ?? defaultCwd,
|
|
344
344
|
shell: false,
|
|
345
345
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
346
|
+
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
347
|
+
// direct child orphans a build or dev server the agent started.
|
|
348
|
+
detached: true,
|
|
346
349
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
347
350
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
348
351
|
})
|
|
@@ -401,19 +404,24 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
401
404
|
resolve(1)
|
|
402
405
|
})
|
|
403
406
|
|
|
407
|
+
const killGroup = (sig: NodeJS.Signals): void => {
|
|
408
|
+
try {
|
|
409
|
+
process.kill(-proc.pid!, sig)
|
|
410
|
+
} catch {
|
|
411
|
+
try {
|
|
412
|
+
proc.kill(sig)
|
|
413
|
+
} catch {
|
|
414
|
+
/* already gone */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
404
418
|
if (signal) {
|
|
405
419
|
onAbort = () => {
|
|
406
420
|
wasAborted = true
|
|
407
|
-
|
|
421
|
+
killGroup('SIGTERM')
|
|
408
422
|
// proc.killed only reports that the signal was sent, not that the child died. Escalate
|
|
409
423
|
// on a timer that the 'close' handler clears once the child has actually exited.
|
|
410
|
-
killTimer = setTimeout(() =>
|
|
411
|
-
try {
|
|
412
|
-
proc.kill('SIGKILL')
|
|
413
|
-
} catch {
|
|
414
|
-
/* already gone */
|
|
415
|
-
}
|
|
416
|
-
}, 5000)
|
|
424
|
+
killTimer = setTimeout(() => killGroup('SIGKILL'), 5000)
|
|
417
425
|
}
|
|
418
426
|
if (signal.aborted) onAbort()
|
|
419
427
|
else signal.addEventListener('abort', onAbort, { once: true })
|
|
@@ -498,17 +506,25 @@ type ChainStepParam = Static<typeof ChainItem>
|
|
|
498
506
|
type TaskItemParam = Static<typeof TaskItem>
|
|
499
507
|
|
|
500
508
|
/** The completion notice a background run sends when it finishes. */
|
|
501
|
-
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string }): string {
|
|
509
|
+
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): string {
|
|
502
510
|
const output = capForContext(run.output ?? '') || '(no output)'
|
|
503
|
-
|
|
511
|
+
// A child that dies at boot writes its reason only to stderr; without this the
|
|
512
|
+
// notice reads "failed after 0 turns ... (no output)" with nothing to act on.
|
|
513
|
+
const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
|
|
514
|
+
return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}`
|
|
504
515
|
}
|
|
505
516
|
|
|
506
517
|
/** What to tell the model about a resume request. */
|
|
507
|
-
export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string }) => void): string {
|
|
518
|
+
export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }) => void, onResumed?: (run: { id: string; agent: string }) => void): string {
|
|
508
519
|
if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
|
|
509
520
|
const outcome = resumeBackgroundRun(id, task, onComplete)
|
|
510
|
-
if (outcome === 'resumed')
|
|
521
|
+
if (outcome === 'resumed') {
|
|
522
|
+
const run = backgroundRun(id)
|
|
523
|
+
if (run) onResumed?.({ id: run.id, agent: run.agent })
|
|
524
|
+
return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
|
|
525
|
+
}
|
|
511
526
|
if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
|
|
527
|
+
if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
|
|
512
528
|
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
513
529
|
}
|
|
514
530
|
|
|
@@ -1108,8 +1124,10 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1108
1124
|
}
|
|
1109
1125
|
|
|
1110
1126
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1111
|
-
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
|
|
1127
|
+
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
1112
1128
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1129
|
+
// The stop event fires here too, so SubagentStop hooks see resumed runs end.
|
|
1130
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
1113
1131
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
1114
1132
|
}
|
|
1115
1133
|
|
|
@@ -1166,7 +1184,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1166
1184
|
})
|
|
1167
1185
|
|
|
1168
1186
|
if (params.resume) {
|
|
1169
|
-
return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion) }], details: makeDetails('single')([]) }
|
|
1187
|
+
return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, (run) => pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })) }], details: makeDetails('single')([]) }
|
|
1170
1188
|
}
|
|
1171
1189
|
|
|
1172
1190
|
if (params.cancel) {
|
package/extensions/web.ts
CHANGED
|
@@ -194,6 +194,9 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
194
194
|
userAgent: USER_AGENT,
|
|
195
195
|
})
|
|
196
196
|
if (response.status >= 300 && response.status < 400) {
|
|
197
|
+
// The hop's body is never read; without the cancel its socket stays held
|
|
198
|
+
// until the 20s abort timeout, once per hop.
|
|
199
|
+
void response.body?.cancel().catch(() => {})
|
|
197
200
|
const location = response.headers.get('location')
|
|
198
201
|
if (!location) throw new Error(`redirect without location from ${url.hostname}`)
|
|
199
202
|
url = new URL(location, url)
|
|
@@ -202,7 +205,10 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
202
205
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
|
|
203
206
|
continue
|
|
204
207
|
}
|
|
205
|
-
if (!response.ok)
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
void response.body?.cancel().catch(() => {})
|
|
210
|
+
throw new Error(`HTTP ${response.status} for ${url}`)
|
|
211
|
+
}
|
|
206
212
|
return { text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
|
|
207
213
|
}
|
|
208
214
|
throw new Error(`too many redirects for ${rawUrl}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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",
|