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.
Files changed (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. 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
- /** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
50
- export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
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
- for (const line of jsonl.split('\n')) {
54
- if (!line.trim()) continue
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(line)
81
+ event = JSON.parse(raw)
58
82
  } catch {
59
- continue
83
+ return
60
84
  }
61
- if (event.type !== 'message_end' || event.message?.role !== 'assistant') continue
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 { text, turns }
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
- const args = withRebuiltPrompt(run.spawn).map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
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', 'ignore'],
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.kill = () => {
216
+ run.live = true
217
+ const killGroup = (signal: NodeJS.Signals): void => {
166
218
  try {
167
- process.kill(-proc.pid!, 'SIGTERM')
219
+ process.kill(-proc.pid!, signal)
168
220
  } catch {
169
- proc.kill('SIGTERM')
221
+ try {
222
+ proc.kill(signal)
223
+ } catch {
224
+ // already gone
225
+ }
170
226
  }
171
227
  }
172
- let stdout = ''
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 } = parseFinalOutputFromJsonl(stdout)
282
+ const { text, turns } = parser.flush()
199
283
  run.kill = undefined
200
- // A cancelled run keeps that state: its non-zero exit is the cancellation.
201
- if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
202
- run.exitCode = code ?? 0
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
  }