pi-code 1.0.8 → 1.0.10

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.
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. */
@@ -649,6 +735,23 @@ type HttpFamilyTransport = SSEClientTransport | StreamableHTTPClientTransport //
649
735
 
650
736
  type MakeTransport = (authProvider?: OAuthClientProvider) => HttpFamilyTransport
651
737
 
738
+ /** Interactive OAuth logins block on a confirm dialog and open a browser tab, so two
739
+ * at once (a user-scope and a consented project-scope server both 401ing, connecting in
740
+ * parallel) would stack dialogs and browser tabs. This chains them so a second
741
+ * interactive login waits for the first to settle; the tail is reset to a resolved
742
+ * promise regardless of outcome, so a failed login never poisons the queue. Silent
743
+ * (stored-token) connects do not pass through here and stay fully parallel. */
744
+ let oauthQueue: Promise<unknown> = Promise.resolve()
745
+
746
+ function serializeInteractiveOAuth<T>(run: () => Promise<T>): Promise<T> {
747
+ const result = oauthQueue.then(run, run)
748
+ oauthQueue = result.then(
749
+ () => {},
750
+ () => {},
751
+ )
752
+ return result
753
+ }
754
+
652
755
  /**
653
756
  * Connect an http-family server, running Claude's OAuth login when the server
654
757
  * demands one. Stored tokens ride the first attempt so the SDK refreshes
@@ -671,7 +774,7 @@ async function connectHttpFamily(name: string, config: { url: string }, makeTran
671
774
  } catch (error) {
672
775
  if (bearerToken || !isUnauthorized(error)) throw error
673
776
  if (!authUi) throw new OAuthRequiredError(`${name} requires a login; run pi interactively to authenticate`)
674
- return await runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient)
777
+ return await serializeInteractiveOAuth(() => runInteractiveOAuth(name, config, makeTransport, label, authUi, newClient))
675
778
  }
676
779
  }
677
780
 
@@ -782,7 +885,7 @@ function resourceTemplateEntry(server: string, template: { uriTemplate: string;
782
885
  async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
783
886
  let cursor: string | undefined
784
887
  do {
785
- 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}`)
786
889
  for (const resource of page.resources) entries.push(resourceEntry(name, resource))
787
890
  cursor = page.nextCursor
788
891
  } while (cursor)
@@ -792,7 +895,7 @@ async function collectResources(entries: Array<Record<string, unknown>>, name: s
792
895
  async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
793
896
  let cursor: string | undefined
794
897
  do {
795
- 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}`)
796
899
  for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
797
900
  cursor = page.nextCursor
798
901
  } while (cursor)
@@ -828,7 +931,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
828
931
  setMcpToolCaller(async (server, tool, input) => {
829
932
  const client = clients.get(server)
830
933
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
831
- 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()))
832
935
  const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
833
936
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
834
937
  .map((part) => part.text)
@@ -866,12 +969,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
866
969
  // and this closure would otherwise keep calling the old, closed client.
867
970
  const current = clients.get(name)
868
971
  if (!current) throw new Error(`MCP server "${name}" is not connected`)
869
- // Pass the timeout to the SDK too: its own default request timeout is 60s and
870
- // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
871
- // 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.
872
978
  const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
873
- const budget = declared ?? callTimeoutMs()
874
- 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)
875
981
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
876
982
  const details: { error?: string } = {}
877
983
  if (result.isError) {
@@ -923,8 +1029,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
923
1029
  const promptArgs = mapPromptArguments(prompt.arguments, args)
924
1030
  const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
925
1031
  if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
926
- const budget = callTimeoutMs()
927
- 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)
928
1034
  // The prompt drives a turn exactly the way a custom slash command does
929
1035
  // (see commands.ts), carrying its image blocks through. A prompt that
930
1036
  // yields no content is reported rather than sent as an empty turn.
@@ -1010,8 +1116,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1010
1116
  const { server, uri } = params as { server: string; uri: string }
1011
1117
  const client = clients.get(server)
1012
1118
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
1013
- const budget = callTimeoutMs()
1014
- 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}`)
1015
1121
  const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
1016
1122
  if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
1017
1123
  if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
@@ -1113,17 +1219,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1113
1219
  )
1114
1220
  }
1115
1221
 
1116
- /** Connect the project scope under the per-server policy. Returns whether the scope
1117
- * is settled, so a refused confirm can be retried on a later session start. */
1118
- async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
1119
- // The stored decision, read without prompting: consent recorded inside the
1120
- // project only counts once the project itself has been approved.
1121
- const approved = isProjectApprovedSilently(ctx)
1122
- const policy = projectServerPolicy(ctx.cwd, os.homedir(), approved)
1123
- const { allowed, denied } = mcpAllowDeny()
1124
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), policy)
1125
- const authUi = authUiFor(ctx)
1126
- if (Object.keys(consented).length > 0) await connectServers(consented, authUi)
1222
+ /** Connect the approval-gated project servers, behind the whole-project confirm.
1223
+ * Returns whether the scope is settled, so a refused confirm can be retried on a
1224
+ * later session start. The consented half of the project scope connects earlier,
1225
+ * concurrently with the user scope, from session_start itself. */
1226
+ async function connectGatedProjectServers(ctx: ExtensionContext, gated: Record<string, ServerConfig>, authUi?: AuthUi): Promise<boolean> {
1127
1227
  if (Object.keys(gated).length === 0) return true
1128
1228
  if (!(await isProjectApproved(ctx))) return false
1129
1229
  await connectServers(gated, authUi)
@@ -1132,15 +1232,32 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1132
1232
 
1133
1233
  let projectConnected = false
1134
1234
 
1135
- pi.on('session_start', async (_event, ctx) => {
1136
- // Connecting spawns processes and opens sockets, so it belongs here rather than in
1137
- // the factory: pi runs the factory for invocations that never start a session.
1138
- // Names still connected are filtered out, so a later session start only retries
1139
- // 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> {
1140
1258
  // Plugin servers merge under the user scope (plugins are user-installed);
1141
1259
  // the user's own entry wins a name clash with a plugin's.
1142
1260
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
1143
- const { allowed, denied } = mcpAllowDeny()
1144
1261
  const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
1145
1262
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
1146
1263
  // server only outranks the user's own when it will actually connect (the user already
@@ -1148,16 +1265,44 @@ export default async function mcpExtension(pi: ExtensionAPI) {
1148
1265
  // entry cannot shadow a trusted user server by reusing its name. A gated project
1149
1266
  // server still awaiting the approval prompt does not preempt the user server: that is
1150
1267
  // a deliberate narrowing of Claude's rule to keep the safe default.
1268
+ // The stored project decision, read without prompting: consent recorded inside
1269
+ // the project only counts once the project itself has been approved.
1151
1270
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1152
- const projectWinners = new Set(Object.keys(splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy).consented))
1271
+ const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
1272
+ const projectWinners = new Set(Object.keys(consented))
1153
1273
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
1154
- if (Object.keys(userServers).length > 0) await connectServers(userServers, authUiFor(ctx))
1274
+ // The consented project servers carry no ordering dependency on the user scope:
1275
+ // projectWinners already excludes their names from userServers, so the two batches
1276
+ // are disjoint and connect concurrently, and startup pays the slower scope rather
1277
+ // than the sum of both. Reconnect attempts after a refused confirm are safe:
1278
+ // connectServers skips names that already connected.
1279
+ const connects: Promise<void>[] = []
1280
+ if (Object.keys(userServers).length > 0) connects.push(connectServers(userServers, authUi))
1281
+ if (!projectConnected && Object.keys(consented).length > 0) connects.push(connectServers(consented, authUi))
1282
+ await Promise.all(connects)
1155
1283
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
1156
1284
  // the project is trusted. Per-server settings refine that: disabled servers never
1157
- // connect, servers the user consented to individually connect without the
1158
- // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
1159
- // refusal are safe: connectServers skips names that already connected.
1160
- if (!projectConnected) projectConnected = await connectProjectScope(ctx)
1285
+ // connect, servers the user consented to individually connected above without the
1286
+ // whole-project confirm, and the rest stay behind it, sequentially after both
1287
+ // scopes so the confirm dialog never races a connect.
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
+ }
1161
1306
 
1162
1307
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
1163
1308
 
@@ -14,11 +14,12 @@ import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
15
  import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
+ import { claudeConfigDir } from './internal/config-dir.js'
17
18
  import { capForContext } from './internal/output-guard.js'
18
19
  import { isProjectApprovedSilently } from './internal/project-approval.js'
19
20
  import { findNearestFile, repoRoot } from './internal/project-root.js'
20
21
 
21
- const INDEX_FILE = 'MEMORY.md'
22
+ export const INDEX_FILE = 'MEMORY.md'
22
23
 
23
24
  /** Claude loads the first 200 lines or 25KB of the memory index at startup. */
24
25
  export const INDEX_MAX_LINES = 200
@@ -282,7 +283,7 @@ function writeIndex(indexPath: string, content: string): void {
282
283
  * approved, since a project's `autoMemoryDirectory` is honored under the same trust
283
284
  * rule as hooks in settings files. Later files win. */
284
285
  export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
285
- const files = [path.join(home, '.claude', 'settings.json')]
286
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
286
287
  if (!approved) return files
287
288
  for (const name of ['settings.json', 'settings.local.json']) {
288
289
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -306,10 +307,65 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
306
307
  return merged
307
308
  }
308
309
 
310
+ /** Write `autoMemoryEnabled` into the user settings file, preserving every other key
311
+ * and creating the file and its config directory when absent. Claude's /memory toggle
312
+ * writes to the user scope (relocated by CLAUDE_CONFIG_DIR); the value takes effect from
313
+ * the next session start, which is where autoMemoryEnabled is read.
314
+ *
315
+ * An absent file starts from an empty object so the toggle still lands. A file that is
316
+ * PRESENT but unparseable is refused, not overwritten: clobbering it would destroy the
317
+ * user's hooks, env and permissions config. The caller surfaces the returned failure. */
318
+ export function setAutoMemoryEnabledSetting(home: string, value: boolean): { ok: true } | { ok: false; error: string } {
319
+ const dir = claudeConfigDir(home)
320
+ const file = path.join(dir, 'settings.json')
321
+ let current: Record<string, unknown> = {}
322
+ let raw: string | undefined
323
+ try {
324
+ raw = fs.readFileSync(file, 'utf-8')
325
+ } catch (error) {
326
+ // Only a missing file means start fresh; any other read failure propagates.
327
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
328
+ }
329
+ if (raw !== undefined) {
330
+ try {
331
+ const parsed = JSON.parse(raw)
332
+ if (parsed !== null && typeof parsed === 'object') current = parsed as Record<string, unknown>
333
+ } catch {
334
+ // Present but unparseable: refuse rather than overwrite the user's config.
335
+ return { ok: false, error: 'settings.json is not valid JSON; not modified' }
336
+ }
337
+ }
338
+ current.autoMemoryEnabled = value
339
+ fs.mkdirSync(dir, { recursive: true })
340
+ fs.writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`)
341
+ return { ok: true }
342
+ }
343
+
309
344
  export default function memoryExtension(pi: ExtensionAPI) {
310
345
  let dir = memoryDir(process.cwd())
311
346
  let enabled = true
312
347
 
348
+ // The index is injected every turn but changes only through the tool or an external
349
+ // edit, so a turn costs one stat instead of a full read. The stat token (mtime plus
350
+ // size) catches external edits; save and delete drop the cache outright, since a
351
+ // rename landing within one mtime tick at the same size would slip past the token.
352
+ let indexCache: { token: string; index: string } | null = null
353
+
354
+ const indexStatToken = (): string => {
355
+ try {
356
+ const stat = fs.statSync(path.join(dir, INDEX_FILE))
357
+ return `${stat.mtimeMs}:${stat.size}`
358
+ } catch {
359
+ return 'missing'
360
+ }
361
+ }
362
+
363
+ const readIndexCached = (): string => {
364
+ const token = indexStatToken()
365
+ if (indexCache?.token !== token) indexCache = { token, index: readIndexQuietly(dir) }
366
+ return indexCache.index
367
+ }
368
+
313
369
  // These extensions also load inside spawned subagent processes, which carry the
314
370
  // PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
315
371
  // into subagents (they get their own store through the agent `memory:` field), so
@@ -325,6 +381,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
325
381
  enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
326
382
  const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
327
383
  dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
384
+ indexCache = null
328
385
  if (!enabled) return
329
386
  const count = readIndexQuietly(dir)
330
387
  .split('\n')
@@ -334,7 +391,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
334
391
 
335
392
  pi.on('before_agent_start', async (event) => {
336
393
  if (inSubagent() || !enabled) return
337
- const index = readIndexQuietly(dir)
394
+ const index = readIndexCached()
338
395
  if (!index.trim()) return
339
396
  return {
340
397
  systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
@@ -362,6 +419,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
362
419
  return await saveMemory(dir, indexPath, name, params.description, params.content)
363
420
  } catch (error) {
364
421
  return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
422
+ } finally {
423
+ indexCache = null
365
424
  }
366
425
  }
367
426
 
@@ -372,11 +431,66 @@ export default function memoryExtension(pi: ExtensionAPI) {
372
431
 
373
432
  if (params.action === 'delete') {
374
433
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
375
- return deleteMemory(dir, indexPath, name)
434
+ // In a finally like the save path: a delete that throws mid-write must still
435
+ // drop the cache, or the next turn injects a stale index.
436
+ try {
437
+ return await deleteMemory(dir, indexPath, name)
438
+ } finally {
439
+ indexCache = null
440
+ }
376
441
  }
377
442
 
378
443
  const index = readIndexQuietly(dir)
379
444
  return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
380
445
  },
381
446
  })
447
+
448
+ // Claude's /memory lists the memory locations and toggles auto memory. pi has no
449
+ // editor seam, so the paths are printed rather than opened. The listing reads the
450
+ // settings chain live so it reflects a toggle written in the same session.
451
+ pi.registerCommand('memory', {
452
+ description: 'Show memory file locations and toggle auto memory (/memory [on|off])',
453
+ handler: async (args, ctx) => {
454
+ const home = os.homedir()
455
+ const arg = args.trim().toLowerCase()
456
+
457
+ if (arg === 'on' || arg === 'off') {
458
+ const next = arg === 'on'
459
+ let result: { ok: true } | { ok: false; error: string }
460
+ try {
461
+ result = setAutoMemoryEnabledSetting(home, next)
462
+ } catch (error) {
463
+ ctx.ui.notify(`Could not update auto memory: ${error instanceof Error ? error.message : String(error)}`, 'error')
464
+ return
465
+ }
466
+ if (!result.ok) {
467
+ ctx.ui.notify(result.error, 'error')
468
+ return
469
+ }
470
+ ctx.ui.notify(`Auto memory ${next ? 'enabled' : 'disabled'} in ${path.join(claudeConfigDir(home), 'settings.json')} (applies next session).`, 'info')
471
+ return
472
+ }
473
+
474
+ if (arg.length > 0) {
475
+ ctx.ui.notify('Usage: /memory [on|off]', 'error')
476
+ return
477
+ }
478
+
479
+ const approved = isProjectApprovedSilently(ctx)
480
+ const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, home, approved))
481
+ const isEnabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
482
+ const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
483
+ const store = resolveMemoryDir(ctx.cwd, override)
484
+ const lines = [
485
+ 'Memory',
486
+ ` Auto memory: ${isEnabled ? 'on' : 'off'}`,
487
+ ` Store: ${store}`,
488
+ ` Index: ${path.join(store, INDEX_FILE)}`,
489
+ ` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
490
+ ` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
491
+ 'Toggle with /memory on or /memory off.',
492
+ ]
493
+ ctx.ui.notify(lines.join('\n'), 'info')
494
+ },
495
+ })
382
496
  }
@@ -21,6 +21,8 @@ import * as os from 'node:os'
21
21
  import * as path from 'node:path'
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
23
 
24
+ import { claudeConfigDir } from './internal/config-dir.js'
25
+
24
26
  /** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
25
27
  export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
26
28
 
@@ -55,7 +57,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
55
57
  * or change your notifications. */
56
58
  function readPreferredNotifChannel(home: string): unknown {
57
59
  try {
58
- const settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf-8'))
60
+ const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
59
61
  return settings?.preferredNotifChannel
60
62
  } catch {
61
63
  return undefined
@@ -24,6 +24,7 @@ import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
26
 
27
+ import { claudeConfigDir } from './internal/config-dir.js'
27
28
  import { installedPlugins } from './internal/plugins.js'
28
29
  import { isProjectApproved } from './internal/project-approval.js'
29
30
  import { findNearestDir, findNearestFile } from './internal/project-root.js'
@@ -82,7 +83,7 @@ function isDirectory(target: string): boolean {
82
83
  * verbatim into the system prompt.
83
84
  */
84
85
  export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
85
- const dirs = [path.join(home, '.claude', 'output-styles')]
86
+ const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
86
87
  if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
87
88
  return dirs.filter((dir) => isDirectory(dir))
88
89
  }
@@ -129,7 +130,7 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
129
130
  /** Settings files that carry `outputStyle`. Project settings apply only when trusted,
130
131
  * each the nearest of its name at or above cwd, as the hooks settings chain reads. */
131
132
  export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
132
- const files = [path.join(home, '.claude', 'settings.json')]
133
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
133
134
  if (!trusted) return files
134
135
  for (const name of ['settings.json', 'settings.local.json']) {
135
136
  files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
@@ -15,6 +15,7 @@ import * as os from 'node:os'
15
15
  import * as path from 'node:path'
16
16
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
17
17
 
18
+ import { claudeConfigDir } from './internal/config-dir.js'
18
19
  import { installedPlugins } from './internal/plugins.js'
19
20
  import { isProjectApprovedSilently } from './internal/project-approval.js'
20
21
  import { findNearestDir } from './internal/project-root.js'
@@ -33,7 +34,7 @@ function isDirectory(target: string): boolean {
33
34
  * name and description to the model, so an untrusted repository would otherwise get
34
35
  * text into the prompt without the user ever agreeing to load its config. */
35
36
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
36
- const candidates = [path.join(home, '.claude', 'skills')]
37
+ const candidates = [path.join(claudeConfigDir(home), 'skills')]
37
38
  // Enabled plugins contribute their skills directories. pi's loader names a
38
39
  // skill by its directory, so a plugin skill registers without Claude's
39
40
  // /plugin: prefix; a rename-free approximation, disclosed in the README.