pi-code 1.0.64 → 1.0.66

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.
@@ -38,7 +38,7 @@ import * as path from 'node:path'
38
38
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
39
39
  import { claudeConfigDir } from './internal/config-dir.js'
40
40
  import { readManagedSettings } from './internal/managed-settings.js'
41
- import { isProjectApprovedSilently } from './internal/project-approval.js'
41
+ import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
42
42
  import { claudeSettingsChain } from './internal/settings-chain.js'
43
43
  import { watchSettingsFiles } from './internal/settings-watch.js'
44
44
  import { isRecord } from './internal/values.js'
@@ -160,7 +160,12 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
160
160
  pi.on('session_start', async (_event, ctx: ExtensionContext) => {
161
161
  const home = os.homedir()
162
162
  const approved = isProjectApprovedSilently(ctx)
163
- const reapply = (): void => apply(home, approved ? projectEnv(ctx.cwd, home) : {})
163
+ // The watcher's reapply closes over the cwd value, never ctx: the poll has no awaiter,
164
+ // and every getter of a replaced session's ctx throws, which would exit pi.
165
+ const cwd = ctx.cwd
166
+ // A reload asks again rather than reusing `approved`: see approvalRecheck.
167
+ const stillApproved = approvalRecheck(ctx)
168
+ const reapply = (): void => apply(home, approved && stillApproved() ? projectEnv(cwd, home) : {})
164
169
  reapply()
165
170
  // Claude: "Claude Code watches your settings files and reloads them when they change,
166
171
  // so it applies most edits to the running session without a restart." `env` is not
@@ -168,11 +173,15 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
168
173
  // edit has to reach process.env now rather than at the next session. applyEnvSettings
169
174
  // tracks what it owns, so a key removed from the file is restored, not left behind.
170
175
  disposeWatch()
171
- disposeWatch = watchSettingsFiles(claudeSettingsChain(ctx.cwd, home, approved), reapply)
176
+ disposeWatch = watchSettingsFiles(claudeSettingsChain(cwd, home, approved), reapply)
172
177
  })
173
178
 
174
179
  pi.on('session_shutdown', async () => {
175
180
  disposeWatch()
176
181
  disposeWatch = () => {}
182
+ // pi's CLI loads a fresh extension instance for every session replacement, and its
183
+ // empty `owned` cannot restore what this one set: everything is handed back here, and
184
+ // the next instance re-applies managed and user env at factory time, as at startup.
185
+ applyEnvSettings({}, process.env, owned)
177
186
  })
178
187
  }
@@ -251,9 +251,12 @@ function withoutUserConfigShellCommands(raw: string, source: string): string {
251
251
  for (const matchers of Object.values(parsed?.hooks ?? {})) {
252
252
  if (!Array.isArray(matchers)) continue
253
253
  for (const entry of matchers) {
254
- const record = entry as { hooks?: unknown }
255
- if (!Array.isArray(record.hooks)) continue
256
- const kept = record.hooks.filter((hook) => {
254
+ // A malformed entry is left for mergeHooksJson to report by name; this pass must
255
+ // not throw on it, because the inline-manifest caller has no catch.
256
+ if (!isRecord(entry) || !Array.isArray(entry.hooks)) continue
257
+ const record = entry
258
+ const kept = (record.hooks as unknown[]).filter((hook) => {
259
+ if (!isRecord(hook)) return true
257
260
  const candidate = hook as { command?: unknown; args?: unknown }
258
261
  if (Array.isArray(candidate.args) || typeof candidate.command !== 'string' || !USER_CONFIG_REF.test(candidate.command)) return true
259
262
  console.warn(`pi-code-hooks: ignoring a hook in ${source}: a shell-form command cannot reference \${user_config.*}; use exec form with "args", or read CLAUDE_PLUGIN_OPTION_<KEY> from the environment`)
@@ -103,7 +103,7 @@ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
103
103
  import { resolveModelOverride } from '../internal/model-lookup.js'
104
104
  import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
105
105
  import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
106
- import { isProjectApproved } from '../internal/project-approval.js'
106
+ import { approvalRecheck, isProjectApproved } from '../internal/project-approval.js'
107
107
  import { repoRoot } from '../internal/project-root.js'
108
108
  import { watchSettingsFiles } from '../internal/settings-watch.js'
109
109
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
@@ -453,9 +453,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
453
453
  // subdirectory session too.
454
454
  projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
455
455
  resolveConfig(ctx.cwd, trusted)
456
- // Claude picks up direct settings edits mid-session via a file watcher.
456
+ // Claude picks up direct settings edits mid-session via a file watcher. The reload
457
+ // closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
458
+ // a replaced session's ctx throws, which would exit pi as an uncaughtException.
459
+ const watchCwd = ctx.cwd
460
+ // The reload asks again rather than reusing `trusted`: see approvalRecheck.
461
+ const stillApproved = approvalRecheck(ctx)
457
462
  disposeSettingsWatch()
458
- disposeSettingsWatch = watchSettingsFiles(hookFiles(ctx.cwd, os.homedir(), trusted), () => resolveConfig(ctx.cwd, trusted))
463
+ disposeSettingsWatch = watchSettingsFiles(hookFiles(watchCwd, os.homedir(), trusted), () => resolveConfig(watchCwd, trusted && stillApproved()))
459
464
  // A disabled or managed-only resolution leaves config empty (or managed-only),
460
465
  // so the SessionStart run below fires exactly what remains active.
461
466
  pendingSessionContext = []
@@ -759,6 +764,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
759
764
  })
760
765
 
761
766
  pi.on('session_shutdown', async (event, ctx) => {
767
+ // pi's CLI loads a fresh extension instance for every session replacement, so no later
768
+ // session_start reaches this watcher: left armed, it polls for the life of the process.
769
+ disposeSettingsWatch()
770
+ disposeSettingsWatch = () => {}
762
771
  const reason = claudeSpelling(SESSION_END_REASON, event.reason)
763
772
  // SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
764
773
  // slow hook cannot stall session exit, /new or /resume.
@@ -135,9 +135,11 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
135
135
  if (raw.spent === true) continue
136
136
  const hook = withCommand(raw)
137
137
  // Claude runs a handler defined in more than one settings file once; a
138
- // plugin's or skill's copy of the same handler stays separate, and http
139
- // handlers with the same URL but different headers are distinct.
140
- const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${hook.headers ? JSON.stringify(hook.headers) : ''}`
138
+ // plugin's or skill's copy of the same handler stays separate. Handlers that
139
+ // share a command but differ in what they run or when (http headers, exec-form
140
+ // args, an `if` filter, an mcp_tool input) are different handlers: `if` is applied
141
+ // after this, so collapsing two filters left the other tool call with no guard.
142
+ const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${JSON.stringify([hook.headers, hook.args, hook.if, hook.input])}`
141
143
  if (seen.has(key)) continue
142
144
  seen.add(key)
143
145
  result.push(hook)
@@ -9,6 +9,8 @@
9
9
  * are skipped rather than failing the event.
10
10
  */
11
11
 
12
+ import { sharedSlot } from './shared-slot.js'
13
+
12
14
  export interface AgentRunRequest {
13
15
  /** The agent's task prompt, with `$ARGUMENTS` already substituted. */
14
16
  prompt: string
@@ -29,20 +31,21 @@ export interface AgentRunRequest {
29
31
  /** Run a subagent to completion and return its final assistant text. */
30
32
  export type AgentRunner = (request: AgentRunRequest) => Promise<string>
31
33
 
32
- let runner: AgentRunner | undefined
34
+ const slot = sharedSlot<AgentRunner>('agent-runner')
33
35
 
34
36
  /** The subagent extension registers its runner here; pass undefined to clear it. */
35
37
  export function setAgentRunner(fn: AgentRunner | undefined): void {
36
- runner = fn
38
+ slot.set(fn)
37
39
  }
38
40
 
39
41
  /** Test seam: whether a runner is registered. */
40
42
  export function hasAgentRunner(): boolean {
41
- return runner !== undefined
43
+ return slot.get() !== undefined
42
44
  }
43
45
 
44
46
  /** Run a subagent for an agent hook. Rejects when no runner is registered. */
45
47
  export function runAgent(request: AgentRunRequest): Promise<string> {
48
+ const runner = slot.get()
46
49
  if (!runner) return Promise.reject(new Error('no subagent runner registered for agent hooks'))
47
50
  return runner(request)
48
51
  }
@@ -7,6 +7,8 @@
7
7
  * callMcpTool. It is the direct-call analogue of the MCP_TOOLS_CHANNEL alias bus.
8
8
  */
9
9
 
10
+ import { sharedSlot } from './shared-slot.js'
11
+
10
12
  export interface McpToolResult {
11
13
  text: string
12
14
  isError: boolean
@@ -14,15 +16,16 @@ export interface McpToolResult {
14
16
 
15
17
  export type McpToolCaller = (server: string, tool: string, input: Record<string, unknown>) => Promise<McpToolResult>
16
18
 
17
- let caller: McpToolCaller | undefined
19
+ const slot = sharedSlot<McpToolCaller>('mcp-tool-caller')
18
20
 
19
21
  /** The mcp extension registers its caller here; pass undefined to clear it. */
20
22
  export function setMcpToolCaller(fn: McpToolCaller | undefined): void {
21
- caller = fn
23
+ slot.set(fn)
22
24
  }
23
25
 
24
26
  /** Invoke a tool on a connected MCP server. Throws when no server is connected. */
25
27
  export function callMcpTool(server: string, tool: string, input: Record<string, unknown>): Promise<McpToolResult> {
28
+ const caller = slot.get()
26
29
  if (!caller) return Promise.reject(new Error(`no MCP server connected to serve ${server}/${tool}`))
27
30
  return caller(server, tool, input)
28
31
  }
@@ -72,5 +72,8 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
72
72
  messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
73
73
  }
74
74
  const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
75
+ // pi-ai reports a provider failure or a fired signal by resolving, never rejecting: the
76
+ // message then has no text, and returned as an answer it is indistinguishable from one.
77
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') throw new Error(message.errorMessage ?? `completion ${message.stopReason}`)
75
78
  return { text: assistantText(message), usage: message.usage }
76
79
  }
@@ -129,6 +129,22 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
129
129
  return deps.savedDecision(ctx.cwd) === true
130
130
  }
131
131
 
132
+ /**
133
+ * A re-check of the approval decision for code that outlives session_start, such as a
134
+ * settings watcher's reload. The answer given at session_start cannot be reused: a
135
+ * repository with nothing Claude-shaped reads as approved without a question, so config
136
+ * that appears later (a branch checkout, an unpacked archive) would inherit an answer
137
+ * nobody gave. ctx is read once, here: the returned function holds values only, because
138
+ * a poll outlives the session and every getter of a replaced session's ctx throws. It
139
+ * never prompts, so a project that turned Claude-shaped mid-session stays out until the
140
+ * next session asks.
141
+ */
142
+ export function approvalRecheck(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): () => boolean {
143
+ const cwd = ctx.cwd
144
+ const piTrusted = ctx.isProjectTrusted?.() === true
145
+ return () => isProjectApprovedSilently({ cwd, isProjectTrusted: () => piTrusted }, deps)
146
+ }
147
+
132
148
  /**
133
149
  * The approval decision for a file that is itself the thing to gate.
134
150
  *
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs'
9
- import { parseNumericEnv } from './values.js'
9
+ import { errorMessage, parseNumericEnv } from './values.js'
10
10
 
11
11
  // Captured at module load: the poll must run on real time even under a test's
12
12
  // fake timers (the stat watcher it replaced lived in libuv and was immune too);
@@ -37,7 +37,13 @@ export function watchSettingsFiles(files: string[], reload: () => void): () => v
37
37
  const next = files.map(snapshot)
38
38
  if (next.some((content, index) => content !== last[index])) {
39
39
  last = next
40
- reload()
40
+ // The poll has no awaiter: a throw here is an uncaughtException, and pi exits on
41
+ // one. A reload that cannot cope with what it read costs that reload, not the session.
42
+ try {
43
+ reload()
44
+ } catch (error) {
45
+ console.warn(`pi-code: settings reload failed: ${errorMessage(error)}`)
46
+ }
41
47
  }
42
48
  }, interval)
43
49
  // A watcher alone must never keep a one-shot run alive.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A process-wide slot for a function one extension registers and another calls.
3
+ *
4
+ * pi loads every extension entry through its own module graph, so a module-level `let`
5
+ * in a file two extensions import is a separate variable in each: the setter and the
6
+ * caller never meet, and the seam reads as "nothing registered" for the life of the
7
+ * process. `globalThis` is the one object the graphs share, and a registered symbol
8
+ * resolves to the same key from every copy of this module. pi's event bus carries the
9
+ * cross-extension data (see mcp-alias); a slot is for a call that returns a result.
10
+ *
11
+ * Each session replacement loads fresh extension instances, which register again and
12
+ * replace the previous instance's function.
13
+ */
14
+ export function sharedSlot<T>(name: string): { get: () => T | undefined; set: (value: T | undefined) => void } {
15
+ const key = Symbol.for(`pi-code.${name}`)
16
+ const holder = globalThis as Record<symbol, unknown>
17
+ return {
18
+ get: () => holder[key] as T | undefined,
19
+ set: (value) => {
20
+ holder[key] = value
21
+ },
22
+ }
23
+ }
@@ -6,17 +6,20 @@
6
6
  * pattern as mcp-call.
7
7
  */
8
8
 
9
+ import { sharedSlot } from './shared-slot.js'
10
+
9
11
  export type SubagentStartHookRunner = (agentType: string, agentId: string) => Promise<string[]>
10
12
 
11
- let runner: SubagentStartHookRunner | undefined
13
+ const slot = sharedSlot<SubagentStartHookRunner>('subagent-start-hook-runner')
12
14
 
13
15
  export function setSubagentStartHookRunner(fn: SubagentStartHookRunner | undefined): void {
14
- runner = fn
16
+ slot.set(fn)
15
17
  }
16
18
 
17
19
  /** Context strings SubagentStart hooks contribute; empty when no runner is
18
20
  * registered or the runner fails (hooks must never block a spawn). */
19
21
  export async function runSubagentStartHooks(agentType: string, agentId: string): Promise<string[]> {
22
+ const runner = slot.get()
20
23
  if (!runner) return []
21
24
  try {
22
25
  return await runner(agentType, agentId)
@@ -11,7 +11,7 @@ import { claudeConfigDir } from '../internal/config-dir.js'
11
11
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
12
12
  import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
13
13
  import { findNearestFile } from '../internal/project-root.js'
14
- import { errorMessage } from '../internal/values.js'
14
+ import { errorMessage, isRecord } from '../internal/values.js'
15
15
 
16
16
  export interface StdioServerConfig {
17
17
  type?: 'stdio'
@@ -105,12 +105,26 @@ export function projectConfigPaths(cwd: string): string[] {
105
105
  return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
106
106
  }
107
107
 
108
+ /** The object entries of a raw `mcpServers` map. A string or null entry (a JSON "comment"
109
+ * key, a nulled-out server) would throw in the connect batch outside its per-server catch
110
+ * and sink every server with it, the project trust prompt included, so it is dropped here
111
+ * by name. */
112
+ function serverEntries(raw: unknown, source: string): Record<string, ServerConfig> {
113
+ const servers: Record<string, ServerConfig> = {}
114
+ for (const [name, entry] of Object.entries(isRecord(raw) ? raw : {})) {
115
+ // Shape only: the fields are validated where a transport is chosen.
116
+ if (isRecord(entry)) servers[name] = entry as unknown as ServerConfig
117
+ else console.warn(`pi-code-mcp: ignoring mcpServers entry "${name}" in ${source}: not an object`)
118
+ }
119
+ return servers
120
+ }
121
+
108
122
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
109
123
  const servers: Record<string, ServerConfig> = {}
110
124
  for (const file of files) {
111
125
  try {
112
126
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
113
- Object.assign(servers, parsed.mcpServers ?? {})
127
+ Object.assign(servers, serverEntries(parsed?.mcpServers, file))
114
128
  } catch (error) {
115
129
  // A missing file is the normal case. A present file that does not parse is
116
130
  // not: one trailing comma silently disabled every server in it.
@@ -128,7 +142,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
128
142
  */
129
143
  export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
130
144
  const servers = loadConfigFrom(userConfigPaths(home))
131
- Object.assign(servers, projectRecord(home, cwd).mcpServers ?? {})
145
+ Object.assign(servers, serverEntries(projectRecord(home, cwd).mcpServers, claudeJsonPath(home)))
132
146
  return servers
133
147
  }
134
148
 
@@ -31,7 +31,7 @@ import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
31
31
  import { isProjectApproved } from './internal/project-approval.js'
32
32
  import { ancestorDirs } from './internal/project-root.js'
33
33
  import { claudeSettingsChain, localSettingsFile, readSettingsChain } from './internal/settings-chain.js'
34
- import { isDirectory } from './internal/values.js'
34
+ import { errorMessage, isDirectory } from './internal/values.js'
35
35
 
36
36
  export interface OutputStyle {
37
37
  name: string
@@ -121,6 +121,25 @@ export function pluginStyleDirs(home: string): string[] {
121
121
  })
122
122
  }
123
123
 
124
+ /** One style file, or undefined when it cannot be used. A directory named *.md or an
125
+ * unreadable file is skipped silently. pi's frontmatter parser is strict YAML and throws:
126
+ * one unparseable file must cost that style, not every style of the session. */
127
+ function readStyleFile(dir: string, entry: string): OutputStyle | undefined {
128
+ const file = path.join(dir, entry)
129
+ let content: string
130
+ try {
131
+ content = fs.readFileSync(file, 'utf-8')
132
+ } catch {
133
+ return undefined
134
+ }
135
+ try {
136
+ return parseStyle(content, entry.replace(/\.md$/, ''))
137
+ } catch (error) {
138
+ console.warn(`pi-code-output-styles: skipping ${file}: ${errorMessage(error).split('\n')[0]}`)
139
+ return undefined
140
+ }
141
+ }
142
+
124
143
  /** All output styles, project entries overriding user entries of the same name. */
125
144
  export function loadStyles(dirs: string[]): OutputStyle[] {
126
145
  const byName = new Map<string, OutputStyle>()
@@ -131,16 +150,9 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
131
150
  } catch {
132
151
  continue
133
152
  }
134
- for (const entry of entries) {
135
- if (!entry.endsWith('.md')) continue
136
- let content: string
137
- try {
138
- content = fs.readFileSync(path.join(dir, entry), 'utf-8')
139
- } catch {
140
- continue // a directory named *.md or an unreadable file must not take down session start
141
- }
142
- const style = parseStyle(content, entry.replace(/\.md$/, ''))
143
- byName.set(style.name, style)
153
+ for (const entry of entries.filter((name) => name.endsWith('.md'))) {
154
+ const style = readStyleFile(dir, entry)
155
+ if (style) byName.set(style.name, style)
144
156
  }
145
157
  }
146
158
  return [...byName.values()]
@@ -190,6 +190,9 @@ export default function question(pi: ExtensionAPI) {
190
190
  description:
191
191
  'Ask the user a question and let them pick from options. Use when you need user input to proceed. Pass question and options, for example {"question": "Which one?", "options": [{"label": "alpha"}, {"label": "beta"}]}. To ask 2-4 questions at once, pass questions instead, with the same fields per entry.',
192
192
  parameters: QuestionParams,
193
+ // The host shows a custom dialog in one editor slot, so two questions running in
194
+ // parallel leave the first unanswerable and the run unable to finish.
195
+ executionMode: 'sequential',
193
196
 
194
197
  async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
195
198
  const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
@@ -35,7 +35,7 @@ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js
35
35
  import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
- import { isProjectApprovedSilently } from './internal/project-approval.js'
38
+ import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
39
39
  import { gitRoot, repoRoot } from './internal/project-root.js'
40
40
  import { readSettingsChain } from './internal/settings-chain.js'
41
41
  import { watchSettingsFiles } from './internal/settings-watch.js'
@@ -503,10 +503,15 @@ export default function statusLine(pi: ExtensionAPI) {
503
503
  armRefresh()
504
504
  // Claude re-runs the script when the statusLine settings change mid-session; a
505
505
  // command change re-resolves and re-runs.
506
+ // The reload asks again rather than reusing `trusted` (see approvalRecheck), and reads
507
+ // the chain that answer allows: a statusLine command is executed, not just displayed.
508
+ const watchCwd = ctx.cwd
509
+ const stillApproved = approvalRecheck(ctx)
506
510
  disposeSettingsWatch()
507
511
  disposeSettingsWatch = watchSettingsFiles(files, () => {
508
512
  const previousCommand = config?.command
509
- config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
513
+ const liveFiles = hookFiles(watchCwd, os.homedir(), trusted && stillApproved())
514
+ config = readDisableAllHooks(liveFiles) ? undefined : readStatusLineConfig(liveFiles)
510
515
  armRefresh()
511
516
  // The debounce batches rapid triggers, but a command the user just edited has
512
517
  // nothing to batch with: run it now so the result of the edit is immediate.
@@ -559,5 +564,9 @@ export default function statusLine(pi: ExtensionAPI) {
559
564
  pi.on('session_shutdown', async () => {
560
565
  clearInterval(refreshTimer)
561
566
  clearTimeout(debounceTimer)
567
+ // pi's CLI loads a fresh extension instance for every session replacement, so no later
568
+ // session_start reaches this watcher: left armed, it polls for the life of the process.
569
+ disposeSettingsWatch()
570
+ disposeSettingsWatch = () => {}
562
571
  })
563
572
  }
@@ -133,16 +133,22 @@ function appendPartialNote(result: SingleResult): void {
133
133
  * try/catch around an inline spawn() call loses. */
134
134
  export function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
135
135
  try {
136
- return {
137
- proc: spawn(command, args, {
138
- ...options,
139
- shell: false,
140
- stdio: ['ignore', 'pipe', 'pipe'],
141
- // Its own group, so an abort reaches grandchildren too: killing only the
142
- // direct child orphans a build or dev server the agent started.
143
- detached: true,
144
- }),
136
+ const proc = spawn(command, args, {
137
+ ...options,
138
+ shell: false,
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ // Its own group, so an abort reaches grandchildren too: killing only the
141
+ // direct child orphans a build or dev server the agent started.
142
+ detached: true,
143
+ })
144
+ // A third failure shape: at the descriptor limit (EMFILE, ENFILE) node returns a child
145
+ // with no stdio and emits 'error' on the next tick. The callers wire stdout first, so
146
+ // the deferred event would find no listener, and an unhandled 'error' exits pi.
147
+ if (!proc.stdout) {
148
+ proc.on('error', () => {})
149
+ return { error: new Error('spawn returned no stdio: the file descriptor limit is likely exhausted (EMFILE)') }
145
150
  }
151
+ return { proc }
146
152
  } catch (error) {
147
153
  return { error: error as Error }
148
154
  }
@@ -338,7 +344,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
338
344
  proc.on('close', (code) => {
339
345
  cleanup()
340
346
  if (buffer.trim()) processLine(buffer)
341
- resolve(code ?? 0)
347
+ // A capped run was stopped by this module's own SIGTERM, which pi's print mode
348
+ // answers with exit 143: that is not the agent failing, as background.ts also holds.
349
+ resolve(currentResult.partial ? 0 : (code ?? 0))
342
350
  })
343
351
 
344
352
  proc.on('error', (error: Error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",
@@ -63,15 +63,15 @@
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "^2.5.4",
66
- "@earendil-works/pi-agent-core": "^0.84.2",
67
- "@earendil-works/pi-ai": "^0.84.2",
68
- "@earendil-works/pi-coding-agent": "^0.84.2",
69
- "@earendil-works/pi-tui": "^0.84.2",
66
+ "@earendil-works/pi-agent-core": "^0.85.1",
67
+ "@earendil-works/pi-ai": "^0.85.1",
68
+ "@earendil-works/pi-coding-agent": "^0.85.1",
69
+ "@earendil-works/pi-tui": "^0.85.1",
70
70
  "@types/node": "^26.1.1",
71
- "@vitest/coverage-v8": "^4.1.10",
71
+ "@vitest/coverage-v8": "^5.0.0",
72
72
  "fast-check": "^4.9.0",
73
73
  "knip": "^6.34.0",
74
74
  "typescript": "^7.0.2",
75
- "vitest": "^4.1.10"
75
+ "vitest": "^5.0.0"
76
76
  }
77
77
  }