pi-code 1.0.3 → 1.0.5
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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +377 -53
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- package/package.json +1 -1
|
@@ -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. */
|
|
@@ -35,6 +40,8 @@ export interface BackgroundSpawn {
|
|
|
35
40
|
* completing run deleted. Without it the resumed child is handed a path that no
|
|
36
41
|
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
37
42
|
promptBody?: string
|
|
43
|
+
/** Claude's maxTurns: kill the child once it has produced this many turns. */
|
|
44
|
+
maxTurns?: number
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const runs = new Map<string, BackgroundRun>()
|
|
@@ -42,30 +49,67 @@ const runs = new Map<string, BackgroundRun>()
|
|
|
42
49
|
/** Cap on simultaneously running background children. */
|
|
43
50
|
export const MAX_BACKGROUND_RUNS = 8
|
|
44
51
|
|
|
52
|
+
/** Finished runs kept for status listings and resume; older ones are evicted so a
|
|
53
|
+
* long session's registry (each entry holds its final output) cannot grow forever. */
|
|
54
|
+
export const MAX_FINISHED_RUNS = 20
|
|
55
|
+
|
|
56
|
+
/** Grace between the cancel SIGTERM and the SIGKILL that ends a child ignoring it. */
|
|
57
|
+
const CANCEL_KILL_GRACE_MS = 5000
|
|
58
|
+
|
|
59
|
+
/** Bytes of stderr kept per run, enough for the boot error without buffering logs. */
|
|
60
|
+
const STDERR_TAIL_CHARS = 2048
|
|
61
|
+
|
|
45
62
|
export function activeBackgroundRuns(): number {
|
|
46
|
-
return [...runs.values()].filter((run) => run.state === 'running').length
|
|
63
|
+
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
66
|
+
function evictFinishedRuns(): void {
|
|
67
|
+
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
68
|
+
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
72
|
+
* long run's JSONL stdout never accumulates whole in the parent's memory. */
|
|
73
|
+
export function createJsonlOutputParser(onTurn?: (turns: number) => void): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
|
|
74
|
+
let buffer = ''
|
|
51
75
|
let text = ''
|
|
52
76
|
let turns = 0
|
|
53
|
-
|
|
54
|
-
if (!
|
|
77
|
+
const takeLine = (raw: string): void => {
|
|
78
|
+
if (!raw.trim()) return
|
|
55
79
|
let event: { type?: string; message?: { role?: string; content?: Array<{ type: string; text?: string }> } }
|
|
56
80
|
try {
|
|
57
|
-
event = JSON.parse(
|
|
81
|
+
event = JSON.parse(raw)
|
|
58
82
|
} catch {
|
|
59
|
-
|
|
83
|
+
return
|
|
60
84
|
}
|
|
61
|
-
if (event.type !== 'message_end' || event.message?.role !== 'assistant')
|
|
85
|
+
if (event.type !== 'message_end' || event.message?.role !== 'assistant') return
|
|
62
86
|
turns++
|
|
87
|
+
onTurn?.(turns)
|
|
63
88
|
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
64
89
|
// foreground path so a multi-part message reads the same in both.
|
|
65
90
|
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
66
91
|
if (parts.length > 0) text = parts.join('\n')
|
|
67
92
|
}
|
|
68
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
push(chunk) {
|
|
95
|
+
buffer += chunk
|
|
96
|
+
const lines = buffer.split('\n')
|
|
97
|
+
buffer = lines.pop() ?? ''
|
|
98
|
+
for (const line of lines) takeLine(line)
|
|
99
|
+
},
|
|
100
|
+
flush() {
|
|
101
|
+
takeLine(buffer)
|
|
102
|
+
buffer = ''
|
|
103
|
+
return { text, turns }
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
|
|
109
|
+
export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
|
|
110
|
+
const parser = createJsonlOutputParser()
|
|
111
|
+
parser.push(jsonl)
|
|
112
|
+
return parser.flush()
|
|
69
113
|
}
|
|
70
114
|
|
|
71
115
|
export function formatStatus(all: Iterable<Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'exitCode'>>): string {
|
|
@@ -100,15 +144,22 @@ export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
|
100
144
|
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
101
145
|
* same --session-id, so it continues with everything it already saw rather than
|
|
102
146
|
* 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' {
|
|
147
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'at-capacity' | 'unknown' {
|
|
104
148
|
const run = runs.get(id)
|
|
105
149
|
if (!run) return 'unknown'
|
|
106
|
-
if (run.state === 'running') return 'still-running'
|
|
107
|
-
|
|
150
|
+
if (run.state === 'running' || run.live) return 'still-running'
|
|
151
|
+
// A resume spawns a child like a fresh start does, so it counts against the cap.
|
|
152
|
+
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return 'at-capacity'
|
|
153
|
+
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
154
|
+
// prompt dir every follow-up.
|
|
155
|
+
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
156
|
+
run.spawn = { ...run.spawn, args: rebuilt }
|
|
157
|
+
const args = rebuilt.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
108
158
|
run.state = 'running'
|
|
109
159
|
run.task = task
|
|
110
160
|
run.output = undefined
|
|
111
161
|
run.exitCode = undefined
|
|
162
|
+
run.stderr = undefined
|
|
112
163
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
113
164
|
return 'resumed'
|
|
114
165
|
}
|
|
@@ -156,25 +207,56 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
156
207
|
const proc = spawn(invocation.command, invocation.args, {
|
|
157
208
|
cwd: invocation.cwd,
|
|
158
209
|
shell: false,
|
|
159
|
-
stdio: ['ignore', 'pipe', '
|
|
210
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
160
211
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
161
212
|
detached: true,
|
|
162
213
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
163
214
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
164
215
|
})
|
|
165
|
-
run.
|
|
216
|
+
run.live = true
|
|
217
|
+
const killGroup = (signal: NodeJS.Signals): void => {
|
|
166
218
|
try {
|
|
167
|
-
process.kill(-proc.pid!,
|
|
219
|
+
process.kill(-proc.pid!, signal)
|
|
168
220
|
} catch {
|
|
169
|
-
|
|
221
|
+
try {
|
|
222
|
+
proc.kill(signal)
|
|
223
|
+
} catch {
|
|
224
|
+
// already gone
|
|
225
|
+
}
|
|
170
226
|
}
|
|
171
227
|
}
|
|
172
|
-
|
|
228
|
+
run.kill = () => {
|
|
229
|
+
killGroup('SIGTERM')
|
|
230
|
+
// A child ignoring SIGTERM would hold its cap slot and process forever.
|
|
231
|
+
const escalate = setTimeout(() => killGroup('SIGKILL'), CANCEL_KILL_GRACE_MS)
|
|
232
|
+
escalate.unref()
|
|
233
|
+
proc.once('close', () => clearTimeout(escalate))
|
|
234
|
+
}
|
|
235
|
+
// Parsed as it streams: buffering the whole JSONL replays every tool result echoed
|
|
236
|
+
// by the child through the parent's memory for the life of the run. A maxTurns cap
|
|
237
|
+
// kills the child at the turn boundary after its Nth turn, so the output so far is
|
|
238
|
+
// preserved and no turn is cut mid-flight.
|
|
239
|
+
const maxTurns = invocation.maxTurns
|
|
240
|
+
// A maxTurns cap kills the child with SIGTERM, so its close arrives with a null code.
|
|
241
|
+
// That is a clean boundary end (the foreground path treats the same cap as success),
|
|
242
|
+
// so remember it and do not misreport the run as failed.
|
|
243
|
+
let cappedByMaxTurns = false
|
|
244
|
+
const parser = createJsonlOutputParser(
|
|
245
|
+
maxTurns
|
|
246
|
+
? (turns) => {
|
|
247
|
+
if (turns < maxTurns) return
|
|
248
|
+
cappedByMaxTurns = true
|
|
249
|
+
run.kill?.()
|
|
250
|
+
}
|
|
251
|
+
: undefined,
|
|
252
|
+
)
|
|
253
|
+
let stderrTail = ''
|
|
173
254
|
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
174
255
|
let completed = false
|
|
175
256
|
const complete = (): void => {
|
|
176
257
|
if (completed) return
|
|
177
258
|
completed = true
|
|
259
|
+
evictFinishedRuns()
|
|
178
260
|
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
179
261
|
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
180
262
|
// the child's 'close'/'error' listener, where nothing upstream catches: an escaping
|
|
@@ -187,27 +269,34 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
187
269
|
// the session that asked for this run is gone
|
|
188
270
|
}
|
|
189
271
|
}
|
|
190
|
-
proc.stdout.on('data', (data) =>
|
|
191
|
-
stdout += data.toString()
|
|
192
|
-
})
|
|
272
|
+
proc.stdout.on('data', (data) => parser.push(data.toString()))
|
|
193
273
|
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
194
274
|
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
195
275
|
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
196
276
|
proc.stdout.on('error', () => {})
|
|
277
|
+
proc.stderr?.on('data', (data) => {
|
|
278
|
+
stderrTail = (stderrTail + data.toString()).slice(-STDERR_TAIL_CHARS)
|
|
279
|
+
})
|
|
280
|
+
proc.stderr?.on('error', () => {})
|
|
197
281
|
proc.on('close', (code) => {
|
|
198
|
-
const { text, turns } =
|
|
282
|
+
const { text, turns } = parser.flush()
|
|
199
283
|
run.kill = undefined
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
284
|
+
run.live = false
|
|
285
|
+
// A cancelled run keeps that state: its non-zero exit is the cancellation. A
|
|
286
|
+
// maxTurns cap ends cleanly with output preserved, so it counts as done, not failed.
|
|
287
|
+
if (run.state !== 'cancelled') run.state = code === 0 || cappedByMaxTurns ? 'done' : 'failed'
|
|
288
|
+
run.exitCode = cappedByMaxTurns ? 0 : (code ?? 0)
|
|
203
289
|
run.output = text
|
|
204
290
|
run.turns = turns
|
|
291
|
+
run.stderr = stderrTail.trim() || undefined
|
|
205
292
|
complete()
|
|
206
293
|
})
|
|
207
|
-
proc.on('error', () => {
|
|
294
|
+
proc.on('error', (error) => {
|
|
208
295
|
run.kill = undefined
|
|
296
|
+
run.live = false
|
|
209
297
|
run.state = 'failed'
|
|
210
298
|
run.exitCode = 1
|
|
299
|
+
run.stderr = stderrTail.trim() || error.message
|
|
211
300
|
complete()
|
|
212
301
|
})
|
|
213
302
|
}
|