pi-code 1.0.4 → 1.0.6

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 +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  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 +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -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 +177 -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 +138 -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 +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -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 +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -9,6 +9,8 @@
9
9
  * analogue, off the shared bus), and on the optional `refreshInterval` timer
10
10
  * (minimum 1s). A project-defined command is arbitrary shell, so project settings
11
11
  * count only once the project is already approved, read without prompting.
12
+ * Claude's `disableAllHooks` setting turns the configured command off too, and
13
+ * the built-in segment stands in.
12
14
  *
13
15
  * Without a configured statusLine, the built-in segment shows turn state plus
14
16
  * running session cost, summed from per-message usage on the current branch so it
@@ -21,9 +23,10 @@
21
23
 
22
24
  import * as fs from 'node:fs'
23
25
  import * as os from 'node:os'
26
+ import * as path from 'node:path'
24
27
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
25
28
 
26
- import { hookFiles, runHookCommand } from './hooks.js'
29
+ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
27
30
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
28
31
  import { isProjectApprovedSilently } from './internal/project-approval.js'
29
32
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -31,6 +34,15 @@ import { readActiveStyleName, settingsFiles } from './output-styles.js'
31
34
  const COMMAND_TIMEOUT_MS = 5_000
32
35
  const DEBOUNCE_MS = 300
33
36
 
37
+ /** Claude sends its CLI version; pi-code's own version is the honest analogue. */
38
+ const PACKAGE_VERSION = (() => {
39
+ try {
40
+ return String(JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8')).version ?? '')
41
+ } catch {
42
+ return ''
43
+ }
44
+ })()
45
+
34
46
  interface UsageEntry {
35
47
  type: string
36
48
  message?: { usage?: { cost?: { total?: number } } }
@@ -84,6 +96,17 @@ export default function statusLine(pi: ExtensionAPI) {
84
96
  let commandLine: string | undefined
85
97
  let permissionMode = 'default'
86
98
  let projectApproved = false
99
+ let sessionStartMs = Date.now()
100
+ // Lines changed, counted from successful edit/write inputs: newText and content
101
+ // lines add, oldText lines remove. An approximation of Claude's counters, which
102
+ // is honest for the tools pi has; bash-side changes are invisible to both.
103
+ let linesAdded = 0
104
+ let linesRemoved = 0
105
+ // API timing and the last message's token usage, from provider/message events:
106
+ // the fields ctx.getContextUsage() does not expose (output/cache tokens, API time).
107
+ let apiDurationMs = 0
108
+ let requestStartMs: number | undefined
109
+ let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
87
110
  let refreshTimer: ReturnType<typeof setInterval> | undefined
88
111
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
89
112
  let running = false
@@ -109,19 +132,52 @@ export default function statusLine(pi: ExtensionAPI) {
109
132
  // so reporting it here would describe a style the session is not using.
110
133
  const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
111
134
  const payload: Record<string, unknown> = {
135
+ hook_event_name: 'Status',
112
136
  session_id: ctx.sessionManager.getSessionId(),
113
137
  cwd: ctx.cwd,
138
+ version: PACKAGE_VERSION,
114
139
  workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
115
140
  // Both fields, per Claude's documented contract: published statusline scripts
116
141
  // read .model.display_name and render the literal "null" when it is missing.
117
142
  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 },
143
+ cost: {
144
+ total_cost_usd: sessionCost(ctx),
145
+ total_duration_ms: Date.now() - sessionStartMs,
146
+ total_api_duration_ms: apiDurationMs,
147
+ total_lines_added: linesAdded,
148
+ total_lines_removed: linesRemoved,
149
+ },
150
+ context_window: {
151
+ context_window_size: usage.contextWindow,
152
+ used_percentage: usage.percent,
153
+ remaining_percentage: usage.percent === null ? null : 100 - usage.percent,
154
+ total_input_tokens: usage.tokens,
155
+ // The per-component breakdown from the last message's usage, which
156
+ // ctx.getContextUsage() (input-side estimate only) cannot provide.
157
+ ...(lastUsage
158
+ ? {
159
+ total_output_tokens: lastUsage.output,
160
+ current_usage: {
161
+ input_tokens: lastUsage.input,
162
+ output_tokens: lastUsage.output,
163
+ cache_read_input_tokens: lastUsage.cacheRead,
164
+ cache_creation_input_tokens: lastUsage.cacheWrite,
165
+ },
166
+ }
167
+ : {}),
168
+ },
169
+ // The true combined total when a message usage is known, else the input-side estimate.
170
+ exceeds_200k_tokens: (lastUsage?.totalTokens ?? usage.tokens ?? 0) > 200_000,
120
171
  permission_mode: permissionMode,
121
172
  }
122
173
  const transcript = ctx.sessionManager.getSessionFile()
123
174
  if (transcript) payload.transcript_path = transcript
124
- if (ctx.thinkingLevel) payload.effort = { level: ctx.thinkingLevel }
175
+ const sessionName = ctx.sessionManager.getSessionName?.()
176
+ if (sessionName) payload.session_name = sessionName
177
+ if (ctx.thinkingLevel) {
178
+ payload.effort = { level: ctx.thinkingLevel }
179
+ payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
180
+ }
125
181
  if (styleName) payload.output_style = { name: styleName }
126
182
  return payload
127
183
  }
@@ -170,18 +226,57 @@ export default function statusLine(pi: ExtensionAPI) {
170
226
  scheduleRefresh()
171
227
  })
172
228
 
229
+ // Counted here rather than in buildPayload so the numbers accumulate across the
230
+ // session the way Claude's counters do.
231
+ pi.on('tool_result', async (event) => {
232
+ if (event.isError) return
233
+ const input = event.input as Record<string, unknown>
234
+ const lines = (text: unknown): number => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
235
+ if (event.toolName === 'write') linesAdded += lines(input.content)
236
+ if (event.toolName === 'edit' && Array.isArray(input.edits)) {
237
+ for (const edit of input.edits as Array<{ oldText?: unknown; newText?: unknown }>) {
238
+ linesAdded += lines(edit.newText)
239
+ linesRemoved += lines(edit.oldText)
240
+ }
241
+ }
242
+ })
243
+
244
+ // API round-trip timing: the window between the request and its response, summed
245
+ // across the session. ctx exposes no API-duration getter, so it is measured here.
246
+ pi.on('before_provider_request', async () => {
247
+ requestStartMs = Date.now()
248
+ })
249
+ pi.on('after_provider_response', async () => {
250
+ if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
251
+ requestStartMs = undefined
252
+ })
253
+ // The last message's token usage, for the breakdown getContextUsage() omits.
254
+ pi.on('message_end', async (event) => {
255
+ const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
256
+ if (usage) lastUsage = usage
257
+ })
258
+
173
259
  pi.on('session_start', async (_event, ctx) => {
174
260
  // One instance serves every session, so a fresh session must not inherit state.
175
261
  turnCount = 0
176
262
  commandLine = undefined
177
263
  sessionCtx = ctx
264
+ sessionStartMs = Date.now()
265
+ linesAdded = 0
266
+ linesRemoved = 0
267
+ apiDurationMs = 0
268
+ requestStartMs = undefined
269
+ lastUsage = undefined
178
270
  clearInterval(refreshTimer)
179
271
  // Reading config must never open a trust dialog: several extensions resolve
180
272
  // approval at session start, and a second prompt stacks over the first and eats
181
273
  // the keys meant for it. An undecided project simply skips project settings.
182
274
  const trusted = isProjectApprovedSilently(ctx)
183
275
  projectApproved = trusted
184
- config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
276
+ const files = hookFiles(ctx.cwd, os.homedir(), trusted)
277
+ // Claude's disableAllHooks also turns off the custom statusLine command; the
278
+ // built-in segment still renders as the fallback.
279
+ config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
185
280
  if (config?.refreshInterval) {
186
281
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
187
282
  }
@@ -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