pi-code 1.0.4 → 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 (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +171 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +125 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -15
  35. package/package.json +1 -1
@@ -21,6 +21,7 @@
21
21
 
22
22
  import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
+ import * as path from 'node:path'
24
25
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
25
26
 
26
27
  import { hookFiles, runHookCommand } from './hooks.js'
@@ -31,6 +32,15 @@ import { readActiveStyleName, settingsFiles } from './output-styles.js'
31
32
  const COMMAND_TIMEOUT_MS = 5_000
32
33
  const DEBOUNCE_MS = 300
33
34
 
35
+ /** Claude sends its CLI version; pi-code's own version is the honest analogue. */
36
+ const PACKAGE_VERSION = (() => {
37
+ try {
38
+ return String(JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8')).version ?? '')
39
+ } catch {
40
+ return ''
41
+ }
42
+ })()
43
+
34
44
  interface UsageEntry {
35
45
  type: string
36
46
  message?: { usage?: { cost?: { total?: number } } }
@@ -84,6 +94,17 @@ export default function statusLine(pi: ExtensionAPI) {
84
94
  let commandLine: string | undefined
85
95
  let permissionMode = 'default'
86
96
  let projectApproved = false
97
+ let sessionStartMs = Date.now()
98
+ // Lines changed, counted from successful edit/write inputs: newText and content
99
+ // lines add, oldText lines remove. An approximation of Claude's counters, which
100
+ // is honest for the tools pi has; bash-side changes are invisible to both.
101
+ let linesAdded = 0
102
+ let linesRemoved = 0
103
+ // API timing and the last message's token usage, from provider/message events:
104
+ // the fields ctx.getContextUsage() does not expose (output/cache tokens, API time).
105
+ let apiDurationMs = 0
106
+ let requestStartMs: number | undefined
107
+ let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
87
108
  let refreshTimer: ReturnType<typeof setInterval> | undefined
88
109
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
89
110
  let running = false
@@ -109,19 +130,52 @@ export default function statusLine(pi: ExtensionAPI) {
109
130
  // so reporting it here would describe a style the session is not using.
110
131
  const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
111
132
  const payload: Record<string, unknown> = {
133
+ hook_event_name: 'Status',
112
134
  session_id: ctx.sessionManager.getSessionId(),
113
135
  cwd: ctx.cwd,
136
+ version: PACKAGE_VERSION,
114
137
  workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
115
138
  // Both fields, per Claude's documented contract: published statusline scripts
116
139
  // read .model.display_name and render the literal "null" when it is missing.
117
140
  model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
118
- cost: { total_cost_usd: sessionCost(ctx) },
119
- context_window: { context_window_size: usage.contextWindow, used_percentage: usage.percent, total_input_tokens: usage.tokens },
141
+ cost: {
142
+ total_cost_usd: sessionCost(ctx),
143
+ total_duration_ms: Date.now() - sessionStartMs,
144
+ total_api_duration_ms: apiDurationMs,
145
+ total_lines_added: linesAdded,
146
+ total_lines_removed: linesRemoved,
147
+ },
148
+ context_window: {
149
+ context_window_size: usage.contextWindow,
150
+ used_percentage: usage.percent,
151
+ remaining_percentage: usage.percent === null ? null : 100 - usage.percent,
152
+ total_input_tokens: usage.tokens,
153
+ // The per-component breakdown from the last message's usage, which
154
+ // ctx.getContextUsage() (input-side estimate only) cannot provide.
155
+ ...(lastUsage
156
+ ? {
157
+ total_output_tokens: lastUsage.output,
158
+ current_usage: {
159
+ input_tokens: lastUsage.input,
160
+ output_tokens: lastUsage.output,
161
+ cache_read_input_tokens: lastUsage.cacheRead,
162
+ cache_creation_input_tokens: lastUsage.cacheWrite,
163
+ },
164
+ }
165
+ : {}),
166
+ },
167
+ // The true combined total when a message usage is known, else the input-side estimate.
168
+ exceeds_200k_tokens: (lastUsage?.totalTokens ?? usage.tokens ?? 0) > 200_000,
120
169
  permission_mode: permissionMode,
121
170
  }
122
171
  const transcript = ctx.sessionManager.getSessionFile()
123
172
  if (transcript) payload.transcript_path = transcript
124
- if (ctx.thinkingLevel) payload.effort = { level: ctx.thinkingLevel }
173
+ const sessionName = ctx.sessionManager.getSessionName?.()
174
+ if (sessionName) payload.session_name = sessionName
175
+ if (ctx.thinkingLevel) {
176
+ payload.effort = { level: ctx.thinkingLevel }
177
+ payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
178
+ }
125
179
  if (styleName) payload.output_style = { name: styleName }
126
180
  return payload
127
181
  }
@@ -170,11 +224,47 @@ export default function statusLine(pi: ExtensionAPI) {
170
224
  scheduleRefresh()
171
225
  })
172
226
 
227
+ // Counted here rather than in buildPayload so the numbers accumulate across the
228
+ // session the way Claude's counters do.
229
+ pi.on('tool_result', async (event) => {
230
+ if (event.isError) return
231
+ const input = event.input as Record<string, unknown>
232
+ const lines = (text: unknown): number => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
233
+ if (event.toolName === 'write') linesAdded += lines(input.content)
234
+ if (event.toolName === 'edit' && Array.isArray(input.edits)) {
235
+ for (const edit of input.edits as Array<{ oldText?: unknown; newText?: unknown }>) {
236
+ linesAdded += lines(edit.newText)
237
+ linesRemoved += lines(edit.oldText)
238
+ }
239
+ }
240
+ })
241
+
242
+ // API round-trip timing: the window between the request and its response, summed
243
+ // across the session. ctx exposes no API-duration getter, so it is measured here.
244
+ pi.on('before_provider_request', async () => {
245
+ requestStartMs = Date.now()
246
+ })
247
+ pi.on('after_provider_response', async () => {
248
+ if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
249
+ requestStartMs = undefined
250
+ })
251
+ // The last message's token usage, for the breakdown getContextUsage() omits.
252
+ pi.on('message_end', async (event) => {
253
+ const usage = (event as { message?: { usage?: typeof lastUsage } }).message?.usage
254
+ if (usage) lastUsage = usage
255
+ })
256
+
173
257
  pi.on('session_start', async (_event, ctx) => {
174
258
  // One instance serves every session, so a fresh session must not inherit state.
175
259
  turnCount = 0
176
260
  commandLine = undefined
177
261
  sessionCtx = ctx
262
+ sessionStartMs = Date.now()
263
+ linesAdded = 0
264
+ linesRemoved = 0
265
+ apiDurationMs = 0
266
+ requestStartMs = undefined
267
+ lastUsage = undefined
178
268
  clearInterval(refreshTimer)
179
269
  // Reading config must never open a trust dialog: several extensions resolve
180
270
  // approval at session start, and a second prompt stacks over the first and eats
@@ -10,22 +10,31 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
10
10
  // The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
11
11
  // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
12
12
  // is not merely ignored, it narrows the child's registry.
13
- import { parseToolList } from '../internal/command-file.js'
13
+ import { parseToolGrants } from '../internal/command-file.js'
14
+ import { installedPlugins } from '../internal/plugins.js'
15
+ import { findNearestDir } from '../internal/project-root.js'
14
16
 
15
17
  /**
16
18
  * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
17
19
  * list. Anything else returns null: a restriction that failed to parse must not run
18
20
  * the agent unrestricted.
21
+ *
22
+ * An argument-scoped grant (`Bash(git log:*)`) cannot be expressed in the child's
23
+ * --tools allowlist, and the parent cannot reach into the child process to enforce
24
+ * it at call time the way commands.ts does, so on the granting side it is rejected
25
+ * like any other unexpressable restriction. A scoped *disallow* only denies more
26
+ * than the file asked for, which is the safe direction, so it stands.
19
27
  */
20
- function parseToolsField(raw: unknown): string[] | undefined | null {
28
+ function parseToolsField(raw: unknown, granting: boolean): string[] | undefined | null {
21
29
  if (raw === undefined) return undefined
22
30
  // Shares the command parser's splitting, so a comma inside an argument scope stays
23
31
  // inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
24
32
  if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
25
33
  if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
26
- const tools = parseToolList(raw)
27
- if (!tools) return null
28
- return tools.length > 0 ? tools : undefined
34
+ const grants = parseToolGrants(raw)
35
+ if (!grants) return null
36
+ if (granting && grants.scopedEntries.length > 0) return null
37
+ return grants.tools.length > 0 ? grants.tools : undefined
29
38
  }
30
39
 
31
40
  /**
@@ -35,7 +44,7 @@ function parseToolsField(raw: unknown): string[] | undefined | null {
35
44
  * (Claude's `inherit`) is the degradation that works everywhere; users who want a
36
45
  * tier pinned should name a concrete model id.
37
46
  */
38
- const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
47
+ const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable', 'inherit'])
39
48
 
40
49
  function parseModelField(raw: unknown): string | undefined {
41
50
  if (typeof raw !== 'string') return undefined
@@ -70,6 +79,20 @@ function parseEffortField(raw: unknown): string | undefined {
70
79
  return THINKING_LEVELS.has(effort) ? effort : undefined
71
80
  }
72
81
 
82
+ /** Claude's agent `memory:` scopes: a persistent per-agent store for cross-session
83
+ * learning, separate from the parent conversation's auto memory. */
84
+ export type AgentMemoryScope = 'user' | 'project' | 'local'
85
+
86
+ const MEMORY_SCOPES: ReadonlySet<string> = new Set(['user', 'project', 'local'])
87
+
88
+ /** Anything other than the three scopes is ignored, so the agent still runs, just
89
+ * without memory; a typo in an optional enhancement should not drop the agent. */
90
+ function parseMemoryField(raw: unknown): AgentMemoryScope | undefined {
91
+ if (typeof raw !== 'string') return undefined
92
+ const scope = raw.trim().toLowerCase()
93
+ return MEMORY_SCOPES.has(scope) ? (scope as AgentMemoryScope) : undefined
94
+ }
95
+
73
96
  /** Claude's `skills` frontmatter: a comma string or YAML list of skill names. */
74
97
  function parseSkillsField(raw: unknown): string[] | undefined {
75
98
  let names: string[] = []
@@ -145,9 +168,15 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
145
168
  const name = typeof frontmatter.name === 'string' ? frontmatter.name : ''
146
169
  const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
147
170
  if (!name || !description) return null
148
- const tools = parseToolsField(frontmatter.tools)
149
- if (tools === null) return null
150
- const disallowedTools = parseToolsField(frontmatter.disallowedTools)
171
+ const tools = parseToolsField(frontmatter.tools, true)
172
+ if (tools === null) {
173
+ // A silent drop reads as "agent does not exist"; say why, since an
174
+ // argument-scoped grant is a shape Claude's own docs recommend but pi cannot
175
+ // enforce on a child process.
176
+ console.warn(`pi-code-subagent: ignoring agent ${filePath}: its tools: grant could not be applied (an argument scope like Bash(git log:*) cannot be enforced on a subagent; grant the whole tool or drop it)`)
177
+ return null
178
+ }
179
+ const disallowedTools = parseToolsField(frontmatter.disallowedTools, false)
151
180
  if (disallowedTools === null) return null
152
181
  return {
153
182
  name,
@@ -158,12 +187,20 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
158
187
  effort: parseEffortField(frontmatter.effort),
159
188
  modelAlias: parseModelAlias(frontmatter.model),
160
189
  skills: parseSkillsField(frontmatter.skills),
190
+ memory: parseMemoryField(frontmatter.memory),
191
+ maxTurns: parseMaxTurns(frontmatter.maxTurns),
161
192
  systemPrompt: body,
162
193
  source,
163
194
  filePath,
164
195
  }
165
196
  }
166
197
 
198
+ /** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
199
+ * Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
200
+ function parseMaxTurns(raw: unknown): number | undefined {
201
+ return typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : undefined
202
+ }
203
+
167
204
  export type AgentScope = 'user' | 'project' | 'both'
168
205
 
169
206
  export interface AgentConfig {
@@ -177,6 +214,10 @@ export interface AgentConfig {
177
214
  modelAlias?: string
178
215
  /** Skill names to inline into the child's prompt, per Claude's `skills` field. */
179
216
  skills?: string[]
217
+ /** Persistent per-agent memory scope, per Claude's `memory:` field. */
218
+ memory?: AgentMemoryScope
219
+ /** Cap on the child's agentic turns, enforced by killing at the turn boundary. */
220
+ maxTurns?: number
180
221
  systemPrompt: string
181
222
  source: AgentSource
182
223
  filePath: string
@@ -187,25 +228,27 @@ export interface AgentDiscoveryResult {
187
228
  projectAgentsDir: string | null
188
229
  }
189
230
 
231
+ /** Claude scans .claude/agents recursively so agents can be organized into
232
+ * subfolders (agents/review/, agents/research/); the walk mirrors that. */
190
233
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
191
234
  const agents: AgentConfig[] = []
192
235
 
193
- if (!fs.existsSync(dir)) {
194
- return agents
195
- }
196
-
197
236
  let entries: fs.Dirent[]
198
237
  try {
199
238
  entries = fs.readdirSync(dir, { withFileTypes: true })
200
239
  } catch {
201
- return agents
240
+ return agents // a missing or unreadable directory contributes nothing
202
241
  }
203
242
 
204
243
  for (const entry of entries) {
244
+ const filePath = path.join(dir, entry.name)
245
+ if (entry.isDirectory()) {
246
+ agents.push(...loadAgentsFromDir(filePath, source))
247
+ continue
248
+ }
205
249
  if (!entry.name.endsWith('.md')) continue
206
250
  if (!entry.isFile() && !entry.isSymbolicLink()) continue
207
251
 
208
- const filePath = path.join(dir, entry.name)
209
252
  let content: string
210
253
  try {
211
254
  content = fs.readFileSync(filePath, 'utf-8')
@@ -220,49 +263,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
220
263
  return agents
221
264
  }
222
265
 
223
- function isDirectory(p: string): boolean {
224
- try {
225
- return fs.statSync(p).isDirectory()
226
- } catch {
227
- return false
228
- }
229
- }
230
-
231
- /** Project root at or above `from`. `.git` is a file in worktrees and submodules. */
232
- const ROOT_MARKERS = ['.git', 'package.json']
233
-
234
- function repoRoot(from: string): string | undefined {
235
- let currentDir = from
236
- while (true) {
237
- if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
238
- const parentDir = path.dirname(currentDir)
239
- if (parentDir === currentDir) return undefined
240
- currentDir = parentDir
241
- }
242
- }
243
-
244
- /**
245
- * Nearest `relative` directory at or above `cwd`, stopping at the repository root.
246
- *
247
- * Without the boundary the search runs to the filesystem root, so an agent planted in a
248
- * world-writable ancestor such as /tmp is offered as a project agent for every session
249
- * beneath it. With no project marker (.git, package.json) the extent is unknown, so only
250
- * `cwd` is considered.
251
- */
252
- function findNearestDir(cwd: string, relative: string): string | null {
253
- const boundary = repoRoot(cwd) ?? cwd
254
- let currentDir = cwd
255
- while (true) {
256
- const candidate = path.join(currentDir, relative)
257
- if (isDirectory(candidate)) return candidate
258
-
259
- if (currentDir === boundary) return null
260
- const parentDir = path.dirname(currentDir)
261
- if (parentDir === currentDir) return null
262
- currentDir = parentDir
263
- }
264
- }
265
-
266
266
  function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
267
267
  const agentMap = new Map<string, AgentConfig>()
268
268
  const register = (agents: AgentConfig[]): void => {
@@ -274,19 +274,30 @@ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[],
274
274
  return agentMap
275
275
  }
276
276
 
277
- export type AgentSource = 'user' | 'project' | 'builtin'
277
+ export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
278
278
 
279
279
  /** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
280
280
  export const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
281
281
 
282
+ /** Agent directories of every enabled plugin: `agents/` unless the manifest
283
+ * points elsewhere. Plugins are user-installed, so user scope only decides. */
284
+ function pluginAgentDirs(home: string): string[] {
285
+ return installedPlugins(home).flatMap((plugin) => {
286
+ const declared = plugin.manifest.agents
287
+ const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
288
+ return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
289
+ })
290
+ }
291
+
282
292
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
283
293
  const userDir = path.join(getAgentDir(), 'agents')
284
294
  const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
285
295
  const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
286
296
  const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
287
297
 
288
- // ~/.claude/agents loads first so ~/.pi/agent/agents wins on name conflicts
289
- const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
298
+ // Plugins load after builtins and before the user's own dirs, so a user agent
299
+ // wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
300
+ const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
290
301
  // project .claude/agents loads first so project .pi/agents wins on name conflicts
291
302
  const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
292
303
 
@@ -40,6 +40,8 @@ export interface BackgroundSpawn {
40
40
  * completing run deleted. Without it the resumed child is handed a path that no
41
41
  * longer exists, and pi falls back to using that path as the prompt text. */
42
42
  promptBody?: string
43
+ /** Claude's maxTurns: kill the child once it has produced this many turns. */
44
+ maxTurns?: number
43
45
  }
44
46
 
45
47
  const runs = new Map<string, BackgroundRun>()
@@ -68,7 +70,7 @@ function evictFinishedRuns(): void {
68
70
 
69
71
  /** Line-by-line parser keeping only the last assistant text and a turn count, so a
70
72
  * 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 } } {
73
+ export function createJsonlOutputParser(onTurn?: (turns: number) => void): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
72
74
  let buffer = ''
73
75
  let text = ''
74
76
  let turns = 0
@@ -82,6 +84,7 @@ export function createJsonlOutputParser(): { push: (chunk: string) => void; flus
82
84
  }
83
85
  if (event.type !== 'message_end' || event.message?.role !== 'assistant') return
84
86
  turns++
87
+ onTurn?.(turns)
85
88
  // The complete text of the last assistant message, matching getFinalOutput on the
86
89
  // foreground path so a multi-part message reads the same in both.
87
90
  const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
@@ -230,8 +233,23 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
230
233
  proc.once('close', () => clearTimeout(escalate))
231
234
  }
232
235
  // 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()
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
+ )
235
253
  let stderrTail = ''
236
254
  // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
237
255
  let completed = false
@@ -264,9 +282,10 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
264
282
  const { text, turns } = parser.flush()
265
283
  run.kill = undefined
266
284
  run.live = false
267
- // A cancelled run keeps that state: its non-zero exit is the cancellation.
268
- if (run.state !== 'cancelled') run.state = code === 0 ? 'done' : 'failed'
269
- run.exitCode = code ?? 0
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)
270
289
  run.output = text
271
290
  run.turns = turns
272
291
  run.stderr = stderrTail.trim() || undefined