pi-code 1.0.62 → 1.0.63

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.
@@ -943,22 +943,25 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
943
943
  type: 'string',
944
944
  })
945
945
 
946
- pi.on('session_start', async (_event, ctx) => {
947
- // pi's ctx.reload() rebuilds extension instances, so this set never survives
948
- // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
949
- // which is fine, since a reload re-loads the instruction files.
950
- announced.clear()
951
- envCache = undefined
952
- importMemo = undefined
953
- localContexts = []
954
- userContext = undefined
955
- projectDotClaude = undefined
946
+ /** The session-scope memory session_start loads: the user's own CLAUDE.md, plus
947
+ * CLAUDE.local.md and ./.claude/CLAUDE.md from an approved project. Claude:
948
+ * CLAUDE_CODE_DISABLE_CLAUDE_MDS "prevent[s] loading any CLAUDE.md memory files
949
+ * into context, including user, project, and auto memory files", so a disabled run
950
+ * returns empty state and skips isProjectApproved's trust prompt entirely: there is
951
+ * nothing left for it to gate. Extracted so session_start itself stays a thin
952
+ * dispatcher; before_agent_start's own excluded() check covers pi's native files,
953
+ * which this cannot reach since pi loads those itself. */
954
+ async function loadSessionMemory(ctx: ExtensionContext): Promise<{ userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } }> {
955
+ if (process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1') return { localContexts: [] }
956
956
 
957
957
  // ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
958
958
  // project approval is required; a missing file simply leaves it unset.
959
959
  const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
960
960
  const userContent = readContextFile(userClaudeMd)
961
- if (userContent !== undefined) userContext = { path: userClaudeMd, content: userContent }
961
+ const memory: { userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } } = {
962
+ userContext: userContent !== undefined ? { path: userClaudeMd, content: userContent } : undefined,
963
+ localContexts: [],
964
+ }
962
965
 
963
966
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
964
967
  // skips it. A cloned repo can ship one, so it is gated like other project config.
@@ -969,16 +972,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
969
972
  // ride the one approval decision.
970
973
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
971
974
  const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
972
- if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
973
- for (const candidate of candidates) {
974
- const content = readContextFile(candidate)
975
- if (content !== undefined) localContexts.push({ path: candidate, content })
976
- }
977
- if (dotClaudeMd !== null) {
978
- const content = readContextFile(dotClaudeMd)
979
- if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
980
- }
975
+ if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
976
+
977
+ for (const candidate of candidates) {
978
+ const content = readContextFile(candidate)
979
+ if (content !== undefined) memory.localContexts.push({ path: candidate, content })
981
980
  }
981
+ if (dotClaudeMd !== null) {
982
+ const content = readContextFile(dotClaudeMd)
983
+ if (content !== undefined) memory.projectDotClaude = { path: dotClaudeMd, content }
984
+ }
985
+ return memory
986
+ }
987
+
988
+ pi.on('session_start', async (_event, ctx) => {
989
+ // pi's ctx.reload() rebuilds extension instances, so this set never survives
990
+ // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
991
+ // which is fine, since a reload re-loads the instruction files.
992
+ announced.clear()
993
+ envCache = undefined
994
+ importMemo = undefined
995
+ const memory = await loadSessionMemory(ctx)
996
+ localContexts = memory.localContexts
997
+ userContext = memory.userContext
998
+ projectDotClaude = memory.projectDotClaude
982
999
  // Read after the local-context flow so an approval it just recorded is honored.
983
1000
  projectApproved = isProjectApprovedSilently(ctx)
984
1001
  gatedFilesApproved = isGatedFileApproved(ctx)
@@ -1014,7 +1031,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1014
1031
  envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
1015
1032
  }
1016
1033
  const { managed, excludeGlobs, projectRoot } = envCache
1017
- const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
1034
+ // CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
1035
+ // files ("including... auto memory files"), which session_start's gate cannot reach
1036
+ // since pi loads them itself. Routing through the exclusion path already used for
1037
+ // claudeMdExcludes drops the block, strips the InstructionsLoaded event, and skips
1038
+ // import expansion for it, exactly as an excluded file already does.
1039
+ const excluded = (absPath: string): boolean => process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1' || isExcludedPath(absPath, excludeGlobs, home)
1018
1040
 
1019
1041
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
1020
1042
  // from import expansion; surviving blocks get block-level comments stripped.
@@ -331,6 +331,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
331
331
  pi.on('turn_start', async () => {
332
332
  if (!runNeedsSnapshot) return
333
333
  runNeedsSnapshot = false
334
+ // Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
335
+ // able to restore code changes." No snapshot means turn_end's `if (!snap) return`
336
+ // always fires, so no checkpoint is ever recorded.
337
+ if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
334
338
  pending = await snapshot()
335
339
  })
336
340
 
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
283
283
  if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
284
284
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
285
285
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
286
- return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined })
286
+ return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined, sessionId: merged.session_id as string | undefined })
287
287
  }
288
288
  // Claude's `once` (skill-frontmatter hooks only): removed after the first
289
289
  // successful run; a failure, block, or timeout leaves it in place.
@@ -56,6 +56,10 @@ export interface HookSpawnOptions {
56
56
  shell?: string
57
57
  /** The declaring plugin's paths, exported to the child. */
58
58
  plugin?: { root: string; dataDir: string }
59
+ /** Claude: "set automatically to the current session ID in ... hook command
60
+ * subprocesses ... this matches the session_id field in the hook JSON input and
61
+ * is updated on /clear." Absent in a stub context with no session manager. */
62
+ sessionId?: string
59
63
  }
60
64
 
61
65
  export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, options?: HookSpawnOptions) => Promise<HookRunResult>
@@ -136,7 +140,7 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
136
140
  return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
137
141
  }
138
142
 
139
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin } = {}) =>
143
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
140
144
  new Promise((resolve) => {
141
145
  // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
142
146
  // attacker-controlled PATH; on Windows the resolver follows Claude's documented Git
@@ -150,6 +154,11 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, {
150
154
  // detection cannot see the captured terminal.
151
155
  const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1', CLAUDE_CODE_CHILD_SESSION: '1' }
152
156
  if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
157
+ // Claude: "Claude Code sets this to its own process ID in the subprocesses it
158
+ // spawns: Bash and PowerShell tool commands and hook commands." Set unconditionally,
159
+ // since every hook child qualifies.
160
+ env.CLAUDE_PID = String(process.pid)
161
+ if (sessionId) env.CLAUDE_CODE_SESSION_ID = sessionId
153
162
  // Claude: "All three are exported as environment variables to hook processes and to
154
163
  // MCP and LSP server subprocesses", so a plugin script can read them rather than
155
164
  // depend on inline substitution. The data directory is "created on first reference",
@@ -72,6 +72,14 @@ function storeFileFor(serverName: string, endpoint?: string): string {
72
72
  return path.join(getAgentDir(), 'mcp-oauth', `${safe}-${digest}.json`)
73
73
  }
74
74
 
75
+ /** MCP_OAUTH_CALLBACK_PORT parsed, or undefined when unset or not a plain integer. */
76
+ function envCallbackPort(): number | undefined {
77
+ const raw = process.env.MCP_OAUTH_CALLBACK_PORT
78
+ if (raw === undefined || raw.trim() === '') return undefined
79
+ const parsed = Number(raw)
80
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
81
+ }
82
+
75
83
  export class FileOAuthProvider implements OAuthClientProvider {
76
84
  private readonly storePath: string
77
85
  private readonly data: StoredAuth
@@ -107,15 +115,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
107
115
  }
108
116
 
109
117
  /** The configured callbackPort alone, absent when only a remembered port exists.
110
- * The caller needs the two apart: a configured port is a hard requirement. */
118
+ * The caller needs the two apart: a configured port is a hard requirement. Falls
119
+ * back to MCP_OAUTH_CALLBACK_PORT, Claude's "alternative to --callback-port when
120
+ * adding an MCP server with pre-configured credentials"; pi-code has no `mcp add`
121
+ * command, so the env var applies as a default for any server naming no port of
122
+ * its own rather than only ones added that way. */
111
123
  configuredRedirectPort(): number | undefined {
112
- return this.oauth?.callbackPort
124
+ return this.oauth?.callbackPort ?? envCallbackPort()
113
125
  }
114
126
 
115
127
  /** The configured callbackPort (Claude: for pre-registered redirect URIs), else
116
128
  * the port a prior login registered, so a re-login can bind the same one. */
117
129
  savedRedirectPort(): number | undefined {
118
- return this.oauth?.callbackPort ?? this.data.redirectPort
130
+ return this.oauth?.callbackPort ?? envCallbackPort() ?? this.data.redirectPort
119
131
  }
120
132
 
121
133
  /** Record the loopback port the callback server actually bound; the redirect
@@ -96,10 +96,29 @@ function bracketEnd(pattern: string, start: number): number {
96
96
 
97
97
  /** A bracket expression body as a regex character class, escaping regex-relevant
98
98
  * characters while keeping `-` ranges; a leading `!` (or `^`) negates. */
99
- function bracketClass(body: string): string {
99
+ function bracketClass(body: string): string | null {
100
100
  const negated = body.startsWith('!') || body.startsWith('^')
101
101
  const members = (negated ? body.slice(1) : body).replace(/[\\\]^]/g, (ch) => `\\${ch}`)
102
- return `[${negated ? '^' : ''}${members}]`
102
+ const source = `[${negated ? '^' : ''}${members}]`
103
+ // A range whose endpoints descend, `["- ]` for instance, is not a character class
104
+ // JavaScript will build: RegExp throws "Range out of order in character class". The
105
+ // `-` cannot simply be escaped, since `[a-z]` is the whole point of the syntax, so the
106
+ // class is validated by construction and an unbuildable one is treated exactly as an
107
+ // unterminated `[` already is: the pattern is invalid and matches nothing. Without
108
+ // this the throw escaped compileGlobs, which does not catch, and took the permission
109
+ // check that called it with it.
110
+ return isBuildableClass(source) ? source : null
111
+ }
112
+
113
+ /** Whether JavaScript will build this character class. Asking RegExp is the only
114
+ * faithful test: the invalid forms are its rules, not ones worth re-deriving here. */
115
+ function isBuildableClass(source: string): boolean {
116
+ try {
117
+ new RegExp(source)
118
+ return true
119
+ } catch {
120
+ return false
121
+ }
103
122
  }
104
123
 
105
124
  /** A `*` run starting at `i`: a double star followed by a slash spans whole
@@ -114,39 +133,33 @@ function translateStar(pattern: string, i: number): { source: string; next: numb
114
133
  return { source: '[^/]*', next: i + 1 }
115
134
  }
116
135
 
136
+ /** The regex source for the construct at `i` and the index after it, or null when the
137
+ * pattern is invalid there and so matches nothing. */
138
+ function translateAt(pattern: string, i: number): { source: string; next: number } | null {
139
+ const ch = pattern[i]
140
+ // Claude: to match a literal bracket, escape it; the escape consumes both chars.
141
+ if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) return { source: escapeRegExp(pattern[i + 1]), next: i + 2 }
142
+ // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be read
143
+ // as one, or a body that is not a buildable class, makes the pattern invalid.
144
+ if (ch === '[') {
145
+ const end = bracketEnd(pattern, i)
146
+ if (end === -1) return null
147
+ const cls = bracketClass(pattern.slice(i + 1, end))
148
+ return cls === null ? null : { source: cls, next: end + 1 }
149
+ }
150
+ if (ch === '*') return translateStar(pattern, i)
151
+ if (ch === '?') return { source: '[^/]', next: i + 1 }
152
+ return { source: escapeRegExp(ch), next: i + 1 }
153
+ }
154
+
117
155
  function translateGlob(pattern: string): string | null {
118
156
  let out = ''
119
157
  let i = 0
120
158
  while (i < pattern.length) {
121
- const ch = pattern[i]
122
- // Claude: to match a literal bracket, escape it; the escape consumes both chars.
123
- if (ch === '\\' && (pattern[i + 1] === '[' || pattern[i + 1] === ']')) {
124
- out += escapeRegExp(pattern[i + 1])
125
- i += 2
126
- continue
127
- }
128
- // Claude: `[` starts a bracket expression such as `[abc]`; a `[` that cannot be
129
- // read as one makes the pattern invalid, matching nothing.
130
- if (ch === '[') {
131
- const end = bracketEnd(pattern, i)
132
- if (end === -1) return null
133
- out += bracketClass(pattern.slice(i + 1, end))
134
- i = end + 1
135
- continue
136
- }
137
- if (ch === '*') {
138
- const star = translateStar(pattern, i)
139
- out += star.source
140
- i = star.next
141
- continue
142
- }
143
- if (ch === '?') {
144
- out += '[^/]'
145
- i += 1
146
- continue
147
- }
148
- out += escapeRegExp(ch)
149
- i += 1
159
+ const step = translateAt(pattern, i)
160
+ if (step === null) return null
161
+ out += step.source
162
+ i = step.next
150
163
  }
151
164
  return out
152
165
  }
@@ -37,11 +37,22 @@ const isFile = (file: string): boolean => {
37
37
  * Windows spellings on win32, where powershell.exe ships with the OS. */
38
38
  const powershellCandidates = (platform: string): string[] => (platform === 'win32' ? ['pwsh', 'pwsh.exe', 'powershell.exe'] : ['pwsh'])
39
39
 
40
- /** First PowerShell binary found on PATH, or undefined when none is installed. */
41
- export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string | undefined {
42
- const dirs = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
40
+ /** First PowerShell binary found on PATH, or undefined when none is installed.
41
+ *
42
+ * A PATH entry that is the launch directory, or project tooling below it, is skipped
43
+ * for the same reason resolveGitBash skips one: a repository that ships `pwsh.exe`
44
+ * must not become the shell its own hooks and spans run through. Go made this the
45
+ * default in 1.19 (`os/exec` refuses a program resolved "relative to the current
46
+ * directory", returning ErrDot), and Windows offers NoDefaultCurrentDirectoryInExePath
47
+ * for it; node honors neither (nodejs/node#46264), so the check belongs here. */
48
+ export function resolvePowershellBinary(platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd()): string | undefined {
49
+ // `env.Path` as well as `env.PATH`, matching resolveGitBash: process.env is
50
+ // case-insensitive on Windows, but an env object handed in by a caller or a test is
51
+ // whatever spelling it was built with, and the two resolvers must read it alike.
52
+ const dirs = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean)
43
53
  for (const candidate of powershellCandidates(platform)) {
44
54
  for (const dir of dirs) {
55
+ if (isProjectTooling(dir, cwd)) continue
45
56
  const full = path.join(dir, candidate)
46
57
  try {
47
58
  fs.accessSync(full, fs.constants.X_OK)
@@ -111,11 +122,11 @@ const powershellShell = (file: string): ResolvedShell => ({
111
122
  */
112
123
  export function resolveShell(preferred: string | undefined, platform: string = process.platform, env: Record<string, string | undefined> = process.env, cwd: string = process.cwd(), installRoots?: string[]): ResolvedShell | undefined {
113
124
  if (preferred === 'powershell') {
114
- const powershell = resolvePowershellBinary(platform, env)
125
+ const powershell = resolvePowershellBinary(platform, env, cwd)
115
126
  if (powershell) return powershellShell(powershell)
116
127
  }
117
128
  const bash = bashBinary(platform, env, cwd, installRoots)
118
129
  if (bash) return bashShell(bash)
119
- const powershell = resolvePowershellBinary(platform, env)
130
+ const powershell = resolvePowershellBinary(platform, env, cwd)
120
131
  return powershell ? powershellShell(powershell) : undefined
121
132
  }
@@ -49,7 +49,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
49
49
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
50
50
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
51
51
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
52
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
52
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
53
53
 
54
54
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
55
55
  // Re-exports for consumers: the module split keeps the extension's public surface
@@ -563,6 +563,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
563
563
  if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
564
564
  }
565
565
 
566
+ /** Publishes the current alias set and the connection banner. `'connecting'`
567
+ * servers are excluded from both connected and failed counts: they are neither,
568
+ * and while the non-blocking startup race below is still in flight some legitimately
569
+ * are. Called once right after startup (whether or not every server settled by
570
+ * then) and again when a still-connecting tail finishes, so a server that connects
571
+ * late still gets its aliases published and counted. */
572
+ function publishConnectionSummary(ctx: ExtensionContext): void {
573
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
574
+ const connected = [...status.values()].filter((s) => s.state === 'connected')
575
+ const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
576
+ if (connected.length > 0 || failed.length > 0) {
577
+ const total = connected.reduce((sum, s) => sum + s.tools, 0)
578
+ const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
579
+ ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
580
+ }
581
+ }
582
+
566
583
  pi.on('session_start', async (_event, ctx) => {
567
584
  // Reset the status map so /mcp and the banner reflect only this session's config: a
568
585
  // server present last session but not this one must not linger as "connected". The
@@ -579,7 +596,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
579
596
  projectConnected = false
580
597
  // Claude answers roots/list with the session's launch directory and exports the
581
598
  // project root as CLAUDE_PROJECT_DIR to stdio servers; both derive from ctx.cwd.
582
- sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd }
599
+ sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
583
600
  const authUi = authUiFor(ctx)
584
601
  sessionAuthUi = authUi
585
602
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
@@ -592,21 +609,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
592
609
  // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
593
610
  // empty set (see loadManagedMcpServers).
594
611
  const managed = loadManagedMcpServers()
595
- if (managed !== null) {
596
- await connectManagedExclusive(managed, policy, authUi)
597
- } else {
598
- await connectNormalScopes(ctx, policy, authUi)
599
- }
600
-
601
- pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
602
-
603
- const connected = [...status.values()].filter((s) => s.state === 'connected')
604
- const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
605
- if (connected.length > 0 || failed.length > 0) {
606
- const total = connected.reduce((sum, s) => sum + s.tools, 0)
607
- const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
608
- ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
609
- }
612
+ const connecting = managed !== null ? connectManagedExclusive(managed, policy, authUi) : connectNormalScopes(ctx, policy, authUi)
613
+ // Claude: "MCP startup is non-blocking by default: servers connect in the
614
+ // background and their tools become available as they finish." A slow or
615
+ // unreachable server no longer costs the whole session's startup its own
616
+ // MCP_TIMEOUT x retries; alwaysLoad-style forced waiting and the ToolSearch/
617
+ // WaitForMcpServers mechanism that lets the model itself wait on a pending
618
+ // server's tools are not implemented, so a call to a tool that has not
619
+ // registered yet still just finds no such tool, same as before it connected.
620
+ //
621
+ // Claude also states non-interactive mode "waits for still-pending servers
622
+ // before the first turn regardless of this variable", because a -p run has no
623
+ // later turn to react to late-arriving tools; a headless ctx (no UI) keeps the
624
+ // prior fully-blocking wait for the same reason.
625
+ const settled = !ctx.hasUI
626
+ ? await connecting.then(() => true)
627
+ : await withTimeout(connecting, mcpConnectTimeoutMs(), 'mcp startup').then(
628
+ () => true,
629
+ () => false,
630
+ )
631
+ publishConnectionSummary(ctx)
632
+ if (!settled) void connecting.then(() => publishConnectionSummary(ctx))
610
633
  })
611
634
 
612
635
  pi.on('session_shutdown', async () => {
@@ -55,6 +55,12 @@ function envTimeout(name: string, fallback: number): number {
55
55
  export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
56
56
  export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
57
57
 
58
+ // Claude: "How long blocking MCP startup waits, in milliseconds, for the connection
59
+ // batch before snapshotting the tool list (default: 5000)." Distinct from MCP_TIMEOUT,
60
+ // which bounds one server's own connect attempt, not the whole startup batch.
61
+ const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 5000
62
+ export const mcpConnectTimeoutMs = (): number => envTimeout('MCP_CONNECT_TIMEOUT_MS', DEFAULT_MCP_CONNECT_TIMEOUT_MS)
63
+
58
64
  /** Per-server inputs to the idle-window choice: the transport kind picks the default
59
65
  * tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
60
66
  export interface ServerCallTuning {
@@ -137,6 +143,9 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
137
143
  export interface SessionDirs {
138
144
  projectDir: string
139
145
  launchDir: string
146
+ /** Claude: "An MCP server subprocess retains the ID it was spawned with", so this
147
+ * is captured once here at connect time rather than re-read per call. */
148
+ sessionId?: string
140
149
  }
141
150
 
142
151
  /** A client that, like Claude, declares the roots capability and answers roots/list
@@ -191,6 +200,7 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
191
200
  const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
192
201
  for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
193
202
  if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
203
+ if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
194
204
  if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
195
205
  // The data dir is "created on first reference"; handing the path to a server is that
196
206
  // reference, so the server does not have to mkdir it before using it.
@@ -6,9 +6,14 @@
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
7
  */
8
8
 
9
+ import * as fs from 'node:fs'
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
9
12
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
10
13
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
11
14
  import { Type } from 'typebox'
15
+ import { claudeConfigDir } from './internal/config-dir.js'
16
+ import { readManagedSettings } from './internal/managed-settings.js'
12
17
 
13
18
  interface OptionWithDesc {
14
19
  label: string
@@ -24,6 +29,8 @@ interface QuestionDetails {
24
29
  answer: string | null
25
30
  wasCustom?: boolean
26
31
  multiSelect?: boolean
32
+ /** Auto-continued on askUserQuestionTimeout rather than answered or cancelled. */
33
+ timedOut?: boolean
27
34
  }
28
35
 
29
36
  // Options with labels and optional descriptions
@@ -70,6 +77,33 @@ function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec
70
77
  const HEADER_MAX = 12
71
78
  export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX))
72
79
 
80
+ /** Claude's three accepted askUserQuestionTimeout spellings (`60s`, `5m`, `10m`), as
81
+ * milliseconds. Anything else, including unset, means no auto-continue. */
82
+ export function parseAskUserQuestionTimeout(value: unknown): number | undefined {
83
+ if (typeof value !== 'string') return undefined
84
+ const match = /^(\d+)([sm])$/.exec(value.trim())
85
+ if (!match) return undefined
86
+ const amount = Number(match[1])
87
+ return match[2] === 's' ? amount * 1000 : amount * 60 * 1000
88
+ }
89
+
90
+ /** Claude scopes askUserQuestionTimeout to "User or managed": a project's own
91
+ * settings.json cannot set it, so a checked-out repository can never make the
92
+ * user's own dialogs auto-answer themselves. Managed wins over the user's file, as
93
+ * every managed setting does. `home` defaults to the real one and is a parameter
94
+ * only so a test can point it at a fixture without mocking node:os. */
95
+ export function askUserQuestionTimeoutMs(home: string = os.homedir()): number | undefined {
96
+ const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown }
97
+ const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout)
98
+ if (fromManaged !== undefined) return fromManaged
99
+ try {
100
+ const parsed = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8')) as { askUserQuestionTimeout?: unknown }
101
+ return parseAskUserQuestionTimeout(parsed.askUserQuestionTimeout)
102
+ } catch {
103
+ return undefined
104
+ }
105
+ }
106
+
73
107
  function checkbox(checked: boolean | undefined): string {
74
108
  if (checked === undefined) return ''
75
109
  return checked ? '[x] ' : '[ ] '
@@ -98,10 +132,13 @@ interface QuestionView {
98
132
  checked: boolean[]
99
133
  editor: Editor
100
134
  theme: Theme
135
+ /** Claude: "You see a countdown for the last 20 seconds." Undefined the rest of
136
+ * the idle window, and always when there is no configured timeout at all. */
137
+ countdownSeconds?: number
101
138
  }
102
139
 
103
140
  function buildQuestionLines(view: QuestionView): string[] {
104
- const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
141
+ const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds } = view
105
142
  const lines: string[] = []
106
143
  const add = (s: string) => lines.push(truncateToWidth(s, width))
107
144
 
@@ -129,6 +166,9 @@ function buildQuestionLines(view: QuestionView): string[] {
129
166
 
130
167
  lines.push('')
131
168
  add(theme.fg('dim', navHint(editMode, multiSelect)))
169
+ if (countdownSeconds !== undefined) {
170
+ add(theme.fg('warning', ` Auto-continuing in ${countdownSeconds}s if idle · press any key to stay`))
171
+ }
132
172
  add(theme.fg('accent', '─'.repeat(width)))
133
173
 
134
174
  return lines
@@ -202,6 +242,11 @@ export default function question(pi: ExtensionAPI) {
202
242
  return new Text(theme.fg('warning', 'Cancelled'), 0, 0)
203
243
  }
204
244
 
245
+ if (details.timedOut) {
246
+ const already = details.answer ? theme.fg('muted', ` (already selected: ${details.answer})`) : ''
247
+ return new Text(theme.fg('warning', '⏱ Auto-continued (no response)') + already, 0, 0)
248
+ }
249
+
205
250
  if (details.wasCustom) {
206
251
  return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
207
252
  }
@@ -240,8 +285,10 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
240
285
 
241
286
  // ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
242
287
  // undefined immediately, which would read as a cancel without ever asking. Ask
243
- // through the dialog primitives there instead.
244
- const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect) : await askViaDialogs(params, ctx, allOptions, multiSelect)
288
+ // through the dialog primitives there instead. askUserQuestionTimeout is a TUI
289
+ // concept (a countdown, a keypress resetting it): the dialog-primitive fallback
290
+ // has no keyboard or visible countdown to drive it, so it is not applied there.
291
+ const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
245
292
 
246
293
  // Build simple options list for details; header/multiSelect appear only when set,
247
294
  // so single-select details are unchanged.
@@ -255,6 +302,18 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
255
302
  }
256
303
  }
257
304
 
305
+ if (result.timedOut) {
306
+ // Claude: "tells Claude you may be away from your keyboard, so Claude proceeds
307
+ // on its own judgment and can re-ask later." Not framed as a cancel: `answer` is
308
+ // '' rather than null, so renderResult and a batch's own null-check both read it
309
+ // as "answered nothing, but not declined" rather than the user having said no.
310
+ const already = multiSelect && result.answer ? ` Already selected: ${result.answer}.` : ''
311
+ return {
312
+ content: [{ type: 'text', text: `No response after the configured idle timeout; the user may be away from the keyboard.${already} Proceed on your own judgment; you can ask again later if needed.` }],
313
+ details: { ...base, answer: result.answer, timedOut: true } as QuestionDetails,
314
+ }
315
+ }
316
+
258
317
  if (result.wasCustom) {
259
318
  return {
260
319
  content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
@@ -268,15 +327,82 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
268
327
  }
269
328
  }
270
329
 
271
- /** Terminal path: the full custom overlay (options list, checkboxes, inline editor). */
272
- function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
273
- return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
330
+ /** Claude: "You see a countdown for the last 20 seconds." */
331
+ const COUNTDOWN_WINDOW_MS = 20_000
332
+ /** Granularity of the idle-timer tick: fine enough that the countdown's displayed
333
+ * second changes on time, coarse enough not to re-render needlessly often. */
334
+ const IDLE_TICK_MS = 250
335
+
336
+ /** Terminal path: the full custom overlay (options list, checkboxes, inline editor).
337
+ *
338
+ * `timeoutMs`, when set, is Claude's askUserQuestionTimeout: "After a question sits
339
+ * that long with no input, the dialog closes on its own: it submits any options
340
+ * you'd already selected and tells Claude you may be away from your keyboard, so
341
+ * Claude proceeds on its own judgment and can re-ask later. You see a countdown for
342
+ * the last 20 seconds. Press any key to restart the timer." Terminal focus-in
343
+ * restarting the timer, the other documented reset trigger, is not implemented:
344
+ * pi's TUI input stream is not confirmed to carry the terminal's own focus-report
345
+ * escape sequences, and guessing at that risks misreading ordinary input as a
346
+ * focus event on a terminal that reports it differently. Exported so the timer
347
+ * mechanics are testable directly, independent of where timeoutMs itself is read
348
+ * from (askUserQuestionTimeoutMs, tested separately).
349
+ */
350
+ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
351
+ return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
274
352
  let optionIndex = 0
275
353
  let editMode = false
276
354
  const checked: boolean[] = allOptions.map(() => false)
277
355
  let cachedLines: string[] | undefined
278
356
  let cachedWidth: number | undefined
279
357
 
358
+ // deadline stays undefined for the whole overlay life when no timeout is
359
+ // configured, so every idle-timer branch below is a no-op in that case.
360
+ let deadline: number | undefined = timeoutMs !== undefined ? Date.now() + timeoutMs : undefined
361
+ let idleTimer: ReturnType<typeof setInterval> | undefined
362
+ let lastCountdown: number | undefined
363
+
364
+ function stopIdleTimer(): void {
365
+ if (idleTimer !== undefined) clearInterval(idleTimer)
366
+ idleTimer = undefined
367
+ }
368
+
369
+ /** Every exit path (an answer, a cancel, or the timeout itself) goes through
370
+ * here, so the interval can never outlive the overlay it belongs to. */
371
+ function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
372
+ stopIdleTimer()
373
+ done(value)
374
+ }
375
+
376
+ function resetIdleTimer(): void {
377
+ if (timeoutMs === undefined) return
378
+ deadline = Date.now() + timeoutMs
379
+ }
380
+
381
+ function fireTimeout(): void {
382
+ // Claude: "submits any options you'd already selected". Single-select has
383
+ // nothing pre-committed (a selection only exists once Enter confirms it), so
384
+ // its timeout answer is empty rather than whatever option merely had focus.
385
+ const answer = multiSelect ? selectedLabels(allOptions, checked) : ''
386
+ finish({ answer, wasCustom: false, timedOut: true })
387
+ }
388
+
389
+ if (timeoutMs !== undefined) {
390
+ idleTimer = setInterval(() => {
391
+ if (deadline === undefined) return
392
+ const remainingMs = deadline - Date.now()
393
+ if (remainingMs <= 0) {
394
+ fireTimeout()
395
+ return
396
+ }
397
+ const remainingSeconds = Math.ceil(remainingMs / 1000)
398
+ const nextCountdown = remainingMs <= COUNTDOWN_WINDOW_MS ? remainingSeconds : undefined
399
+ if (nextCountdown !== lastCountdown) {
400
+ lastCountdown = nextCountdown
401
+ refresh()
402
+ }
403
+ }, IDLE_TICK_MS)
404
+ }
405
+
280
406
  const editorTheme: EditorTheme = {
281
407
  borderColor: (s) => theme.fg('accent', s),
282
408
  selectList: {
@@ -292,7 +418,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
292
418
  editor.onSubmit = (value) => {
293
419
  const trimmed = value.trim()
294
420
  if (trimmed) {
295
- done({ answer: trimmed, wasCustom: true })
421
+ finish({ answer: trimmed, wasCustom: true })
296
422
  } else {
297
423
  editMode = false
298
424
  editor.setText('')
@@ -306,6 +432,11 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
306
432
  }
307
433
 
308
434
  function handleInput(data: string) {
435
+ // Claude: "Press any key to restart the timer." Every branch below returns
436
+ // through this function, so resetting unconditionally on entry covers all of
437
+ // them, including the ones (arrow keys, space) that never reach `finish`.
438
+ resetIdleTimer()
439
+
309
440
  if (editMode) {
310
441
  if (matchesKey(data, Key.escape)) {
311
442
  editMode = false
@@ -337,7 +468,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
337
468
 
338
469
  if (matchesKey(data, Key.enter)) {
339
470
  if (multiSelect) {
340
- done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
471
+ finish({ answer: selectedLabels(allOptions, checked), wasCustom: false })
341
472
  return
342
473
  }
343
474
  const selected = allOptions[optionIndex]
@@ -345,20 +476,20 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
345
476
  editMode = true
346
477
  refresh()
347
478
  } else {
348
- done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
479
+ finish({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
349
480
  }
350
481
  return
351
482
  }
352
483
 
353
484
  if (matchesKey(data, Key.escape)) {
354
- done(null)
485
+ finish(null)
355
486
  }
356
487
  }
357
488
 
358
489
  function render(width: number): string[] {
359
490
  if (cachedLines && cachedWidth === width) return cachedLines
360
491
  cachedWidth = width
361
- cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
492
+ cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme, countdownSeconds: lastCountdown })
362
493
  return cachedLines
363
494
  }
364
495
 
@@ -369,6 +500,10 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
369
500
  cachedLines = undefined
370
501
  },
371
502
  handleInput,
503
+ // Belt and suspenders alongside finish()'s own stopIdleTimer: if the host ever
504
+ // tears the overlay down through a path that does not go through `done`
505
+ // (finish's only caller), the interval still gets cleared here.
506
+ dispose: stopIdleTimer,
372
507
  }
373
508
  })
374
509
  }
@@ -376,7 +511,7 @@ function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions:
376
511
  /** Dialog-primitive fallback for UI without a terminal (RPC mode supports
377
512
  * select/input/notify but not custom components). Mirrors the overlay's result
378
513
  * shape; a dismissed dialog reads as a cancel, same as Escape in the overlay. */
379
- async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number } | null> {
514
+ async function askViaDialogs(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
380
515
  const header = shortHeader(params.header)
381
516
  const title = header ? `[${header}] ${params.question}` : params.question
382
517
  // Number the labels: ctx.ui.select returns the chosen label string, so duplicate
@@ -93,6 +93,11 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
93
93
  })
94
94
 
95
95
  pi.on('agent_settled', async (_event, ctx) => {
96
+ // Claude: "Set to 1 to disable automatic terminal title updates based on conversation
97
+ // context. In Agent SDK and claude -p sessions, this also skips the background
98
+ // small/fast-model request that generates the session title." setSessionName is pi's
99
+ // only title sink, so skipping the call here skips both effects at once.
100
+ if (process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE === '1') return
96
101
  if (titled) return
97
102
  // Never clobber an existing name: a user-chosen or resumed name wins.
98
103
  if (pi.getSessionName?.()) return
@@ -50,7 +50,12 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
50
50
  // Claude's precedence: enterprise (the skills directory beside the managed
51
51
  // settings file) overrides personal, and personal overrides project; discovery
52
52
  // here is first-match, so higher precedence goes first.
53
- const candidates = [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills'), path.join(claudeConfigDir(home), 'skills')]
53
+ // Claude: "Set to 1 to skip loading skills from the system-wide managed skills
54
+ // directory. Useful for container or CI sessions that should not load
55
+ // operator-provisioned skills." The enterprise dir beside managed-settings.json is
56
+ // that directory here; personal and project skills are unaffected.
57
+ const enterprise = process.env.CLAUDE_CODE_DISABLE_POLICY_SKILLS === '1' ? [] : [path.join(path.dirname(managedSettingsFile()), '.claude', 'skills')]
58
+ const candidates = [...enterprise, path.join(claudeConfigDir(home), 'skills')]
54
59
  // Enabled plugins contribute their skills directories. pi's loader names a
55
60
  // skill by its directory, so a plugin skill registers without Claude's
56
61
  // /plugin: prefix; a rename-free approximation, disclosed in the README.
@@ -12,6 +12,7 @@ import * as path from 'node:path'
12
12
 
13
13
  import type { AgentRunRequest } from '../internal/agent-run.js'
14
14
  import { claudeConfigDir } from '../internal/config-dir.js'
15
+ import { sliceBytes } from '../internal/output-guard.js'
15
16
  import { repoRoot } from '../internal/project-root.js'
16
17
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
17
18
  import { type AgentConfig, type AgentMemoryScope, expandMcpToolPatterns, withPreloadedSkills } from './agents.js'
@@ -188,10 +189,24 @@ export function agentHooksEnv(agent: AgentConfig, agentId: string): Record<strin
188
189
  return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
189
190
  }
190
191
 
192
+ /** This string becomes the whole of one argv element to the spawned child
193
+ * (run.ts, `spawn(..., { shell: false })`). Linux's MAX_ARG_STRLEN, a per-argument
194
+ * limit distinct from the much larger total ARG_MAX, is 128KiB; confirmed on a real
195
+ * Linux host that a single argv string over it fails execve with E2BIG. Neither the
196
+ * model's task text nor a SubagentStart hook's additionalContext is capped
197
+ * upstream, so this is where the assembled string caps itself. The budget leaves
198
+ * headroom under the hard limit for the "Task: " prefix, the notice below, and
199
+ * platforms whose limit differs from Linux's. */
200
+ const TASK_ARGV_MAX_BYTES = 96 * 1024
201
+
191
202
  /** The task argument with any SubagentStart hook context ahead of it, per Claude:
192
203
  * "added to the subagent's context at the start of its conversation, before its
193
- * first prompt". */
204
+ * first prompt". Capped as one combined string, since either the context or the
205
+ * task alone can already be oversized. */
194
206
  export function taskWithStartContext(task: string, contexts: string[]): string {
195
207
  const context = contexts.filter(Boolean).join('\n')
196
- return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
208
+ const assembled = context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
209
+ if (Buffer.byteLength(assembled, 'utf-8') <= TASK_ARGV_MAX_BYTES) return assembled
210
+ const kept = sliceBytes(assembled, TASK_ARGV_MAX_BYTES)
211
+ return `${kept}\n\n[truncated: too long for the child process to receive]`
197
212
  }
@@ -6,11 +6,12 @@
6
6
  * worktree; everything about how the child was configured lives in child.ts.
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process'
9
+ import { type ChildProcessByStdio, spawn } from 'node:child_process'
10
10
  import { randomUUID } from 'node:crypto'
11
11
  import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
+ import type { Readable } from 'node:stream'
14
15
 
15
16
  import type { AgentToolResult } from '@earendil-works/pi-agent-core'
16
17
  import type { Message } from '@earendil-works/pi-ai'
@@ -123,6 +124,29 @@ function appendPartialNote(result: SingleResult): void {
123
124
  if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
124
125
  }
125
126
 
127
+ /** The pi child, or the error spawning it threw synchronously. Node normally
128
+ * reports a spawn failure through the child's async 'error' event, but some
129
+ * failures (confirmed on Linux: posix_spawn detects E2BIG immediately) throw at
130
+ * the spawn() call site instead; catching it here, in one place with an explicit
131
+ * return type, keeps the caller's non-null stdout/stderr narrowing that a bare
132
+ * try/catch around an inline spawn() call loses. */
133
+ function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
134
+ try {
135
+ return {
136
+ 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
+ }
145
+ } catch (error) {
146
+ return { error: error as Error }
147
+ }
148
+ }
149
+
126
150
  export async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
127
151
  const agent = options.agents.find((a) => a.name === options.agentName)
128
152
  if (!agent) return runSingleAgentInner(options)
@@ -244,16 +268,17 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
244
268
 
245
269
  const exitCode = await new Promise<number>((resolve) => {
246
270
  const invocation = getPiInvocation(args)
247
- const proc = spawn(invocation.command, invocation.args, {
271
+ const spawned = spawnChild(invocation.command, invocation.args, {
248
272
  cwd: worktree?.dir ?? runCwd,
249
- shell: false,
250
- stdio: ['ignore', 'pipe', 'pipe'],
251
- // Its own group, so an abort reaches grandchildren too: killing only the
252
- // direct child orphans a build or dev server the agent started.
253
- detached: true,
254
273
  // The marker lets the child's subagent tool refuse to nest further.
255
274
  env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
256
275
  })
276
+ if ('error' in spawned) {
277
+ if (!currentResult.stderr) currentResult.stderr = spawned.error.message
278
+ resolve(1)
279
+ return
280
+ }
281
+ const proc = spawned.proc
257
282
  let buffer = ''
258
283
  let assistantTurns = 0
259
284
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.62",
3
+ "version": "1.0.63",
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",