pi-code 1.0.9 → 1.0.11

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.
@@ -3,7 +3,14 @@
3
3
  *
4
4
  * Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
5
5
  * a project's existing hooks work under pi:
6
- * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
6
+ * - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
7
+ * pi `user_bash` for a `!`/`!!` command the user runs directly (the
8
+ * model never issues these, so a deny-list guard would otherwise miss
9
+ * them). No pi tool call exists there, so the payload reports the
10
+ * Claude name "Bash"; a deny hands pi a synthetic failed result so
11
+ * the command never runs. UserBashEvent carries no execution result
12
+ * and fires only before the command runs, so it has no PostToolUse
13
+ * counterpart (pi never delivers the output to observe).
7
14
  * - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
8
15
  * appended next to the tool result, as Claude documents)
9
16
  * - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
@@ -62,6 +69,7 @@ import * as path from 'node:path'
62
69
  import type { Api, Model } from '@earendil-works/pi-ai'
63
70
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
64
71
  import { runAgent } from './internal/agent-run.js'
72
+ import { claudeConfigDir } from './internal/config-dir.js'
65
73
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
66
74
  import { readManagedSettings } from './internal/managed-settings.js'
67
75
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
@@ -83,6 +91,9 @@ const DEFAULT_TIMEOUT_S = 60
83
91
  interface HookCommand {
84
92
  type?: string
85
93
  command: string
94
+ /** exec-form: spawn `command` directly with these args and no shell (shell-form when
95
+ * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
96
+ args?: string[]
86
97
  timeout?: number
87
98
  /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
88
99
  url?: string
@@ -122,14 +133,15 @@ export interface HookRunResult {
122
133
  }
123
134
  /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
124
135
  export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
125
- /** The shell path specifically; the statusline reuses it for its own command. */
126
- export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
136
+ /** The shell path specifically; the statusline reuses it for its own command. With an
137
+ * `args` array it becomes the exec path: `command` is spawned directly with those args. */
138
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
127
139
 
128
140
  /** Settings files to read, newest-winning. Project files load only when trusted, each
129
141
  * the nearest of its name at or above cwd (bounded at the repository root, matching
130
142
  * the approval walk), so a subdirectory session reads the settings that gated it. */
131
143
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
132
- const files = [path.join(home, '.claude', 'settings.json')]
144
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
133
145
  if (!trusted) return files
134
146
  for (const name of ['settings.json', 'settings.local.json']) {
135
147
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -472,14 +484,23 @@ function killTree(child: ChildProcess): void {
472
484
  child.kill('SIGKILL')
473
485
  }
474
486
 
475
- export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
487
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
476
488
  new Promise((resolve) => {
477
489
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
478
490
  // `detached` makes the shell its own process group leader so the timeout can kill
479
491
  // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
480
- // reference project files regardless of the shell's cwd.
481
- const env = projectDir ? { ...process.env, CLAUDE_PROJECT_DIR: projectDir } : process.env
482
- const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
492
+ // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
493
+ // subprocess Claude spawns, so it is set on the child unconditionally.
494
+ const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
495
+ if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
496
+ // An exec-form hook (an `args` array) spawns the executable directly with those args
497
+ // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
498
+ // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
499
+ // payload survive verbatim). Without args it stays the shell path. Both share the
500
+ // same detached process group, so killTree reaches the descendants either way.
501
+ const file = Array.isArray(args) ? command : '/bin/sh'
502
+ const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
503
+ const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
483
504
  let stdout = ''
484
505
  let stderr = ''
485
506
  let settled = false
@@ -673,6 +694,18 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
673
694
  return ''
674
695
  }
675
696
 
697
+ /** Claude overrides a Stop hook after it blocks this many times in a row with no user
698
+ * progress, ending the turn with a warning rather than looping forever. */
699
+ const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
700
+
701
+ /** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
702
+ * is a positive integer, else the default. A non-positive or malformed value falls back
703
+ * to the default rather than capping at zero (which would suppress the very first block). */
704
+ export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
705
+ const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
706
+ return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
707
+ }
708
+
676
709
  /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
677
710
  const MAX_TIMEOUT_S = 2_147_483
678
711
 
@@ -833,6 +866,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
833
866
  let allowedHttpHookUrls: string[] | undefined
834
867
  let pendingSessionContext: string[] = []
835
868
  let stopHookActive = false
869
+ /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
870
+ * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
871
+ let stopHookBlockCount = 0
836
872
  let sessionCtx: ExtensionContext | undefined
837
873
  /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
838
874
  let hooksDisabled = false
@@ -856,7 +892,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
856
892
  if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
857
893
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
858
894
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
859
- return runHookCommand(hook.command, merged, ms, projectDir)
895
+ return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
860
896
  }
861
897
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
862
898
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
@@ -992,10 +1028,38 @@ export default function hooksExtension(pi: ExtensionAPI) {
992
1028
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
993
1029
  })
994
1030
 
1031
+ // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
1032
+ // `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
1033
+ // so without this a guard that blocks `git push -f` from the model would not stop the
1034
+ // same command typed by hand. There is no pi tool call, so the matcher sees both pi's
1035
+ // "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
1036
+ // reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
1037
+ // payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
1038
+ // exposes no block flag: a deny is enforced through `result` ("extension handled
1039
+ // execution, use this result"), a synthetic failed BashResult that stands in for the
1040
+ // command so it never runs and its deny reason shows as the output. The event delivers
1041
+ // no execution result and fires only before the command runs, so there is deliberately
1042
+ // no PostToolUse for it.
1043
+ pi.on('user_bash', async (event, ctx) => {
1044
+ const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
1045
+ if (!decision.block) return undefined
1046
+ // Claude's "ask": prompt before running and let the command through on approval; with
1047
+ // no UI (headless) the block stands, the same safe default as the tool_call path.
1048
+ if (decision.ask && ctx.hasUI) {
1049
+ const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
1050
+ if (approved) return undefined
1051
+ }
1052
+ const reason = decision.reason ?? 'Command blocked by hook'
1053
+ return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
1054
+ })
1055
+
995
1056
  pi.on('input', async (event, ctx) => {
996
1057
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
997
1058
  // prompts the user submitted.
998
1059
  if (event.source === 'extension') return { action: 'continue' }
1060
+ // Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
1061
+ // block cap counts only consecutive blocks with nothing from the user in between.
1062
+ stopHookBlockCount = 0
999
1063
  const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
1000
1064
  if (decision.block) {
1001
1065
  // pi's input result has no reason channel, so surface why before consuming it.
@@ -1048,8 +1112,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
1048
1112
  return { block: false, reason: '' }
1049
1113
  })
1050
1114
  .find((verdict) => verdict.block)
1051
- stopHookActive = block !== undefined
1052
- if (block) pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
1115
+ if (!block) {
1116
+ // A non-blocking Stop breaks the streak: the next block starts a fresh count.
1117
+ stopHookActive = false
1118
+ stopHookBlockCount = 0
1119
+ return
1120
+ }
1121
+ stopHookBlockCount += 1
1122
+ const cap = stopHookBlockCap()
1123
+ if (stopHookBlockCount >= cap) {
1124
+ // Claude overrides a Stop hook that has blocked cap times in a row with no user
1125
+ // progress: suppress the continuation, warn, and let the turn end so the loop cannot
1126
+ // run forever. Reset the count so a later run (or user turn) starts clean.
1127
+ stopHookActive = false
1128
+ stopHookBlockCount = 0
1129
+ ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
1130
+ return
1131
+ }
1132
+ stopHookActive = true
1133
+ pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
1053
1134
  })
1054
1135
 
1055
1136
  pi.on('session_before_compact', async (event, ctx) => {
@@ -35,7 +35,16 @@ export interface ParsedCommand {
35
35
  * command's injected spans run (see spanExec). */
36
36
  shell?: string
37
37
  model?: string
38
+ /** `effort:` per-command thinking-level override, one of pi's ThinkingLevel values
39
+ * (off/minimal/low/medium/high/xhigh/max); undefined when absent or unrecognized. */
40
+ effort?: string
41
+ /** `when_to_use:` extra trigger text appended to the slash_command tool listing only,
42
+ * never to the user-facing command description. */
43
+ whenToUse?: string
38
44
  disableModelInvocation: boolean
45
+ /** `user-invocable:` false hides the command from the slash-command surface while
46
+ * keeping it callable by the model through the slash_command tool. Default true. */
47
+ userInvocable: boolean
39
48
  body: string
40
49
  }
41
50
 
@@ -236,6 +245,15 @@ const text = (value: unknown): string => {
236
245
  const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
237
246
  const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
238
247
 
248
+ /** YAML's negative boolean spellings, the mirror of YAML_TRUE. A flag that defaults to
249
+ * true (user-invocable) is turned off only by one of these; any other value, absent
250
+ * included, leaves it on, so an unrelated string never silently hides a command. */
251
+ const YAML_FALSE = new Set(['false', 'no', 'off', 'n', '0'])
252
+ const isFlagDisabled = (value: unknown): boolean => value === false || YAML_FALSE.has(text(value).toLowerCase())
253
+
254
+ /** pi's ThinkingLevel union, the values a command's `effort:` override may name. */
255
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
256
+
239
257
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
240
258
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
241
259
 
@@ -265,6 +283,7 @@ export function parseCommandFile(content: string): ParsedCommand {
265
283
  const disable = frontmatter['disable-model-invocation']
266
284
  const grants = parseToolGrants(frontmatter['allowed-tools'])
267
285
  const shell = text(frontmatter.shell).toLowerCase()
286
+ const effort = text(frontmatter.effort).toLowerCase()
268
287
  return {
269
288
  description: text(frontmatter.description) || firstLine.slice(0, 60),
270
289
  argumentHint: hint(frontmatter['argument-hint']) || undefined,
@@ -276,7 +295,11 @@ export function parseCommandFile(content: string): ParsedCommand {
276
295
  disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
277
296
  shell: SHELLS.has(shell) ? shell : undefined,
278
297
  model: text(frontmatter.model) || undefined,
298
+ // An unrecognized effort is dropped rather than passed to setThinkingLevel.
299
+ effort: THINKING_LEVELS.has(effort) ? effort : undefined,
300
+ whenToUse: text(frontmatter.when_to_use) || undefined,
279
301
  disableModelInvocation: isFlagEnabled(disable),
302
+ userInvocable: !isFlagDisabled(frontmatter['user-invocable']),
280
303
  body,
281
304
  }
282
305
  }
@@ -453,7 +476,9 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
453
476
  if (shell === 'powershell') {
454
477
  const binary = resolveBinary()
455
478
  if (binary !== undefined) {
456
- const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'`
479
+ // CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
480
+ // it is exported in the script alongside CLAUDE_PROJECT_DIR.
481
+ const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
457
482
  // No in-script 2>&1: under pwsh 7 it does not merge a native command's
458
483
  // stderr on a script block, so mergeStreams has the caller append it. The
459
484
  // trailing exit forwards a failed native command's code, which pwsh
@@ -470,7 +495,7 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
470
495
  // comment-only span is a hard sh syntax error (exit 2) that aborted the whole
471
496
  // invocation, and `:` keeps such a span the harmless no-op it was on HEAD
472
497
  // while the group still merges stderr for real spans.
473
- return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
498
+ return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
474
499
  }
475
500
 
476
501
  interface FenceBlock {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CLAUDE_CONFIG_DIR: Claude Code's override for the home configuration directory.
3
+ *
4
+ * Claude relocates the entire ~/.claude configuration tree (settings.json, commands,
5
+ * agents, skills, plugins, output-styles, CLAUDE.md) when CLAUDE_CONFIG_DIR is set,
6
+ * so a user can keep that config outside their home directory. This resolves the
7
+ * home-scope config root for every consumer; a project's own `.claude/` directory is
8
+ * a separate scope and is never affected. A leading `~` expands against `home`, and
9
+ * the result is resolved to an absolute path so a relative value cannot depend on the
10
+ * reader's working directory.
11
+ */
12
+
13
+ import * as path from 'node:path'
14
+
15
+ /** The home-scope Claude config directory: CLAUDE_CONFIG_DIR (expanded, absolute)
16
+ * when set to a non-empty value, otherwise `<home>/.claude`. */
17
+ export function claudeConfigDir(home: string): string {
18
+ const override = process.env.CLAUDE_CONFIG_DIR
19
+ if (override && override.trim().length > 0) {
20
+ const expanded = override.startsWith('~') ? path.join(home, override.slice(1)) : override
21
+ return path.resolve(expanded)
22
+ }
23
+ return path.join(home, '.claude')
24
+ }
@@ -15,6 +15,8 @@
15
15
  import * as fs from 'node:fs'
16
16
  import * as path from 'node:path'
17
17
 
18
+ import { claudeConfigDir } from './config-dir.js'
19
+
18
20
  export interface InstalledPlugin {
19
21
  name: string
20
22
  /** The version directory: ${CLAUDE_PLUGIN_ROOT}. */
@@ -148,8 +150,8 @@ function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
148
150
  * because any settings edit or cache-tree change invalidates it.
149
151
  */
150
152
  export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
151
- const cacheDir = path.join(home, '.claude', 'plugins', 'cache')
152
- const settingsFiles = [path.join(home, '.claude', 'settings.json'), ...extraSettingsFiles]
153
+ const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
154
+ const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
153
155
  const key = [home, ...extraSettingsFiles].join('\n')
154
156
  const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
155
157
  const cached = pluginCache.get(key)
@@ -180,7 +182,7 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
180
182
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
181
183
  const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
182
184
  const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
183
- return { name, root, dataDir: path.join(home, '.claude', 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
185
+ return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
184
186
  }
185
187
 
186
188
  /** The two plugin path variables, textually substituted into plugin-shipped
package/extensions/mcp.ts CHANGED
@@ -48,6 +48,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
48
48
  import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
49
49
  import { Type } from 'typebox'
50
50
  import { splitArgs } from './internal/command-file.js'
51
+ import { claudeConfigDir } from './internal/config-dir.js'
51
52
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
52
53
  import { setMcpToolCaller } from './internal/mcp-call.js'
53
54
  import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
@@ -57,7 +58,14 @@ import { isProjectApproved, isProjectApprovedSilently } from './internal/project
57
58
  import { findNearestFile } from './internal/project-root.js'
58
59
 
59
60
  const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
60
- const DEFAULT_CALL_TIMEOUT_MS = 120_000
61
+ // Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
62
+ // is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
63
+ // so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
64
+ const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
65
+ // The idle timeout: the longest a call may go with no response or progress before it is
66
+ // abandoned. Claude uses a separate idle guard (minutes) rather than the hours-long
67
+ // wall-clock budget; the SDK resets this window on every progress notification.
68
+ const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
61
69
 
62
70
  /** A positive-integer env override, or the default when unset or unparseable. */
63
71
  function envTimeout(name: string, fallback: number): number {
@@ -70,6 +78,33 @@ function envTimeout(name: string, fallback: number): number {
70
78
  // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
71
79
  const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
72
80
  const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
81
+
82
+ /** The idle timeout in ms: the longest a call may go with no response or progress before
83
+ * it is abandoned, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
84
+ * (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
85
+ * "disabled" rather than falling back to the default. */
86
+ function idleTimeoutMs(): number {
87
+ const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
88
+ if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
89
+ const value = Number.parseInt(raw, 10)
90
+ if (value === 0) return 0
91
+ return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
92
+ }
93
+
94
+ /** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
95
+ * under it, an idle timeout the SDK resets on every progress notification. When the idle
96
+ * window is enabled and tighter than the wall budget, `timeout` is that per-quiet-period
97
+ * deadline (resetTimeoutOnProgress), maxTotalTimeout caps the wall clock, and an onprogress
98
+ * handler is required: it makes the server address progress to this request and lets the
99
+ * SDK reset the timer on it. When the idle timeout is disabled, or already looser than the
100
+ * wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
101
+ * backstop and must be raced against `wall`, never the idle window, so a legitimately
102
+ * progressing call is not cut off. */
103
+ function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
104
+ const idle = idleTimeoutMs()
105
+ if (idle === 0 || idle >= wall) return { timeout: wall }
106
+ return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
107
+ }
73
108
  // Tool names an MCP server must never take over. formatToolName always emits
74
109
  // `<server>_<tool>`, so only names containing an underscore are actually reachable:
75
110
  // pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
@@ -125,9 +160,19 @@ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.e
125
160
  })
126
161
  }
127
162
 
128
- /** User-scoped MCP config (the user's own; safe to load without project trust). */
163
+ /** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
164
+ * When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
165
+ * default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
166
+ * blank value is treated as unset, matching claudeConfigDir. */
167
+ function claudeJsonPath(home: string): string {
168
+ const override = process.env.CLAUDE_CONFIG_DIR
169
+ return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
170
+ }
171
+
172
+ /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
173
+ * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
129
174
  export function userConfigPaths(home: string): string[] {
130
- return [path.join(home, '.claude.json'), path.join(home, '.pi', 'agent', 'mcp.json')]
175
+ return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
131
176
  }
132
177
 
133
178
  /** Project-scoped MCP config, each file the nearest of its name at or above cwd
@@ -163,7 +208,7 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
163
208
  }
164
209
  }
165
210
  const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
166
- const userSettings = read(path.join(home, '.claude', 'settings.json'))
211
+ const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
167
212
  const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
168
213
  const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
169
214
  const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
@@ -208,7 +253,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
208
253
  export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
209
254
  const servers = loadConfigFrom(userConfigPaths(home))
210
255
  try {
211
- const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf-8'))
256
+ const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
212
257
  Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
213
258
  } catch {
214
259
  // missing or invalid ~/.claude.json: the top-level user servers already loaded
@@ -321,6 +366,47 @@ export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?
321
366
  }
322
367
  }
323
368
 
369
+ /** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
370
+ * through the same test seam so a test can write both into one temp dir. */
371
+ export function managedMcpPath(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): string {
372
+ return path.join(path.dirname(managedFile), 'managed-mcp.json')
373
+ }
374
+
375
+ /** Claude's managed-mcp.json: when it exists beside managed-settings.json it takes
376
+ * exclusive control of MCP. Only its `mcpServers` load; user, project, and plugin servers
377
+ * are all suppressed (and the project-approval flow with them), and an empty map disables
378
+ * MCP entirely. Returns the managed server map (possibly empty) when the file exists and
379
+ * parses, or null only when the file is absent, in which case MCP loads from the usual
380
+ * scopes exactly as before. A file that parses but carries no `mcpServers` object is an
381
+ * empty managed set, so a deployed-but-bodyless policy locks down rather than silently
382
+ * reopening the other scopes. A file that is PRESENT but not valid JSON fails closed to
383
+ * the same empty set (deny-all) rather than reopening those scopes: the lockdown intent
384
+ * means a corrupt or truncated policy file must not become an allow-all. The allow/deny
385
+ * lists still filter the returned set. */
386
+ export function loadManagedMcpServers(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, ServerConfig> | null {
387
+ const file = managedMcpPath(managedFile)
388
+ let raw: string
389
+ try {
390
+ raw = fs.readFileSync(file, 'utf-8')
391
+ } catch {
392
+ // Absent (or unreadable) managed-mcp.json: no managed MCP control, load normally.
393
+ return null
394
+ }
395
+ let parsed: unknown
396
+ try {
397
+ parsed = JSON.parse(raw)
398
+ } catch (error) {
399
+ // Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
400
+ // rather than reopening the user/project/plugin scopes.
401
+ console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
402
+ return {}
403
+ }
404
+ if (parsed === null || typeof parsed !== 'object') return {}
405
+ const servers = (parsed as { mcpServers?: unknown }).mcpServers
406
+ if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return {}
407
+ return servers as Record<string, ServerConfig>
408
+ }
409
+
324
410
  /** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
325
411
  * a set (even empty) is exclusive, so only its members survive; a deny list removes
326
412
  * servers on top, deny winning over allow. */
@@ -799,7 +885,7 @@ function resourceTemplateEntry(server: string, template: { uriTemplate: string;
799
885
  async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
800
886
  let cursor: string | undefined
801
887
  do {
802
- const page = await withTimeout(client.listResources({ cursor }, { timeout: budget }), budget, `list resources ${name}`)
888
+ const page = await withTimeout(client.listResources({ cursor }, callRequestOptions(budget)), budget, `list resources ${name}`)
803
889
  for (const resource of page.resources) entries.push(resourceEntry(name, resource))
804
890
  cursor = page.nextCursor
805
891
  } while (cursor)
@@ -809,7 +895,7 @@ async function collectResources(entries: Array<Record<string, unknown>>, name: s
809
895
  async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
810
896
  let cursor: string | undefined
811
897
  do {
812
- const page = await withTimeout(client.listResourceTemplates({ cursor }, { timeout: budget }), budget, `list resource templates ${name}`)
898
+ const page = await withTimeout(client.listResourceTemplates({ cursor }, callRequestOptions(budget)), budget, `list resource templates ${name}`)
813
899
  for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
814
900
  cursor = page.nextCursor
815
901
  } while (cursor)
@@ -845,7 +931,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
845
931
  setMcpToolCaller(async (server, tool, input) => {
846
932
  const client = clients.get(server)
847
933
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
848
- const result = await client.callTool({ name: tool, arguments: input }, undefined, { timeout: callTimeoutMs() })
934
+ const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
849
935
  const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
850
936
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
851
937
  .map((part) => part.text)
@@ -883,12 +969,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
883
969
  // and this closure would otherwise keep calling the old, closed client.
884
970
  const current = clients.get(name)
885
971
  if (!current) throw new Error(`MCP server "${name}" is not connected`)
886
- // Pass the timeout to the SDK too: its own default request timeout is 60s and
887
- // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
888
- // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
972
+ // The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
973
+ // wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
974
+ // the SDK enforces (resetting on progress). Pass the options to the SDK too: its
975
+ // own default request timeout is 60s and would otherwise reject first. The outer
976
+ // race uses the wall budget, never the idle window, so a progressing call is not
977
+ // cut off at the idle timeout.
889
978
  const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
890
- const budget = declared ?? callTimeoutMs()
891
- const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
979
+ const wall = declared ?? callTimeoutMs()
980
+ const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
892
981
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
893
982
  const details: { error?: string } = {}
894
983
  if (result.isError) {
@@ -940,8 +1029,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
940
1029
  const promptArgs = mapPromptArguments(prompt.arguments, args)
941
1030
  const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
942
1031
  if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
943
- const budget = callTimeoutMs()
944
- const result = await withTimeout(current.getPrompt(params, { timeout: budget }), budget, commandName)
1032
+ const wall = callTimeoutMs()
1033
+ const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
945
1034
  // The prompt drives a turn exactly the way a custom slash command does
946
1035
  // (see commands.ts), carrying its image blocks through. A prompt that
947
1036
  // yields no content is reported rather than sent as an empty turn.
@@ -1027,8 +1116,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1027
1116
  const { server, uri } = params as { server: string; uri: string }
1028
1117
  const client = clients.get(server)
1029
1118
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
1030
- const budget = callTimeoutMs()
1031
- const result = await withTimeout(client.readResource({ uri }, { timeout: budget }), budget, `read ${uri}`)
1119
+ const wall = callTimeoutMs()
1120
+ const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
1032
1121
  const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
1033
1122
  if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
1034
1123
  if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
@@ -1143,15 +1232,32 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1143
1232
 
1144
1233
  let projectConnected = false
1145
1234
 
1146
- pi.on('session_start', async (_event, ctx) => {
1147
- // Connecting spawns processes and opens sockets, so it belongs here rather than in
1148
- // the factory: pi runs the factory for invocations that never start a session.
1149
- // Names still connected are filtered out, so a later session start only retries
1150
- // servers that failed or whose transport dropped, without duplicate-name warnings.
1235
+ /** managed-mcp.json exclusive mode: a policy deployed mid-process must not leave
1236
+ * already-connected user/project servers running alongside the managed set. Evict every
1237
+ * connected client not in the managed set (delete it from the map first so the onclose
1238
+ * handler's guard sees it gone and does not overwrite the status, then close it
1239
+ * best-effort and mark it disabled), then connect only the managed servers. */
1240
+ async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
1241
+ const managedServers = applyServerPolicy(managed, allowed, denied)
1242
+ const managedNames = new Set(Object.keys(managedServers))
1243
+ for (const [name, client] of Array.from(clients.entries())) {
1244
+ if (managedNames.has(name)) continue
1245
+ clients.delete(name)
1246
+ await client.close().catch(() => {})
1247
+ status.set(name, { state: 'disabled by managed policy', tools: 0 })
1248
+ }
1249
+ await connectServers(managedServers, authUi)
1250
+ }
1251
+
1252
+ /** The normal user + plugin + project scopes, when no managed-mcp.json is present.
1253
+ * Connecting spawns processes and opens sockets, so it belongs here rather than in the
1254
+ * factory: pi runs the factory for invocations that never start a session. Names still
1255
+ * connected are filtered out, so a later session start only retries servers that failed
1256
+ * or whose transport dropped, without duplicate-name warnings. */
1257
+ async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
1151
1258
  // Plugin servers merge under the user scope (plugins are user-installed);
1152
1259
  // the user's own entry wins a name clash with a plugin's.
1153
1260
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
1154
- const { allowed, denied } = mcpAllowDeny()
1155
1261
  const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
1156
1262
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
1157
1263
  // server only outranks the user's own when it will actually connect (the user already
@@ -1165,7 +1271,6 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1165
1271
  const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
1166
1272
  const projectWinners = new Set(Object.keys(consented))
1167
1273
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
1168
- const authUi = authUiFor(ctx)
1169
1274
  // The consented project servers carry no ordering dependency on the user scope:
1170
1275
  // projectWinners already excludes their names from userServers, so the two batches
1171
1276
  // are disjoint and connect concurrently, and startup pays the slower scope rather
@@ -1181,6 +1286,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1181
1286
  // whole-project confirm, and the rest stay behind it, sequentially after both
1182
1287
  // scopes so the confirm dialog never races a connect.
1183
1288
  if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
1289
+ }
1290
+
1291
+ pi.on('session_start', async (_event, ctx) => {
1292
+ const authUi = authUiFor(ctx)
1293
+ // The managed allow/deny lists filter every scope, including a managed-mcp.json set.
1294
+ const { allowed, denied } = mcpAllowDeny()
1295
+ // managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
1296
+ // only its servers load, and the user, project, and plugin scopes plus the whole
1297
+ // project-approval flow below are skipped. An empty map disables MCP entirely. An absent
1298
+ // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
1299
+ // empty set (see loadManagedMcpServers).
1300
+ const managed = loadManagedMcpServers()
1301
+ if (managed !== null) {
1302
+ await connectManagedExclusive(managed, allowed, denied, authUi)
1303
+ } else {
1304
+ await connectNormalScopes(ctx, allowed, denied, authUi)
1305
+ }
1184
1306
 
1185
1307
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1186
1308