pi-code 1.0.62 → 1.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +46 -24
  4. package/extensions/git-checkpoint.ts +141 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +14 -10
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +17 -29
  10. package/extensions/internal/agent-run.ts +1 -1
  11. package/extensions/internal/bash-rules.ts +1 -2
  12. package/extensions/internal/claude-tool-names.ts +4 -7
  13. package/extensions/internal/command-file.ts +2 -3
  14. package/extensions/internal/external-imports.ts +4 -5
  15. package/extensions/internal/goal-evaluator.ts +4 -3
  16. package/extensions/internal/instruction-events.ts +5 -5
  17. package/extensions/internal/managed-settings.ts +1 -1
  18. package/extensions/internal/mcp-oauth.ts +28 -6
  19. package/extensions/internal/model-complete.ts +7 -1
  20. package/extensions/internal/model-lookup.ts +10 -3
  21. package/extensions/internal/path-rules.ts +51 -41
  22. package/extensions/internal/plugins.ts +5 -5
  23. package/extensions/internal/process-tree.ts +39 -0
  24. package/extensions/internal/project-approval.ts +7 -7
  25. package/extensions/internal/project-root.ts +13 -13
  26. package/extensions/internal/settings-chain.ts +19 -0
  27. package/extensions/internal/settings-watch.ts +3 -1
  28. package/extensions/internal/shell-resolve.ts +19 -6
  29. package/extensions/internal/tool-target.ts +4 -5
  30. package/extensions/internal/values.ts +20 -4
  31. package/extensions/mcp/config.ts +11 -4
  32. package/extensions/mcp/index.ts +78 -19
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +12 -8
  35. package/extensions/memory.ts +4 -4
  36. package/extensions/notify.ts +2 -7
  37. package/extensions/output-styles.ts +22 -17
  38. package/extensions/question.ts +142 -12
  39. package/extensions/session-title.ts +5 -0
  40. package/extensions/skills.ts +11 -6
  41. package/extensions/subagent/agents.ts +4 -4
  42. package/extensions/subagent/background.ts +35 -41
  43. package/extensions/subagent/child.ts +19 -4
  44. package/extensions/subagent/modes.ts +18 -13
  45. package/extensions/subagent/params.ts +1 -2
  46. package/extensions/subagent/run.ts +34 -20
  47. package/extensions/thinking.ts +3 -3
  48. package/extensions/web.ts +3 -4
  49. package/package.json +1 -1
@@ -16,11 +16,10 @@ import * as path from 'node:path'
16
16
  /** The project root marker. `.git` is a file in worktrees and submodules, a directory
17
17
  * in an ordinary clone.
18
18
  *
19
- * `package.json` used to count too, which made every package of a monorepo its own
20
- * project: its own memory directory, its own settings.local.json, its own
21
- * CLAUDE_PROJECT_DIR, its own trust decision. Claude's project is the repository, and
22
- * a repository can add a package.json wherever it likes, so a marker it controls was
23
- * also a marker it could move. */
19
+ * Only `.git`: a package.json marker would make every package of a monorepo its own
20
+ * project (its own memory directory, settings.local.json, CLAUDE_PROJECT_DIR and trust
21
+ * decision), and a repository can add a package.json wherever it likes, so a marker it
22
+ * controls is a marker it can move. Claude's project is the repository. */
24
23
  export const ROOT_MARKERS = ['.git']
25
24
 
26
25
  /** Project root at or above `from`, or undefined outside a repository. */
@@ -32,11 +31,12 @@ export function repoRoot(from: string): string | undefined {
32
31
 
33
32
  /** The git checkout at or above `from`, or undefined outside one.
34
33
  *
35
- * Narrower than repoRoot on purpose, and used where a key must be stable rather than
36
- * merely near: repoRoot also stops at package.json, which every package of a monorepo
37
- * ships, so a decision keyed on it changes the moment the session starts one directory
38
- * deeper. `.git` cannot be committed into a repository, so it is not a marker the
39
- * repository can add to move its own key.
34
+ * Narrower than repoRoot on purpose: repoRoot resolves a worktree to its main checkout,
35
+ * which is the right key for shared state (settings.local.json, auto memory) but is a
36
+ * sibling of the worktree, never an ancestor. The upward walks above bound themselves
37
+ * here instead, since a boundary that is not on the path from cwd to / is never reached
38
+ * and the walk would run on to the filesystem root. `.git` cannot be committed into a
39
+ * repository, so it is not a marker the repository can add to move its own key.
40
40
  */
41
41
  export function gitRoot(from: string): string | undefined {
42
42
  let currentDir = from
@@ -89,7 +89,7 @@ function statOf(target: string): fs.Stats | null {
89
89
  }
90
90
 
91
91
  function findNearest(cwd: string, relative: string, wantDir: boolean): string | null {
92
- const boundary = repoRoot(cwd) ?? cwd
92
+ const boundary = gitRoot(cwd) ?? cwd
93
93
  let currentDir = cwd
94
94
  while (true) {
95
95
  const candidate = path.join(currentDir, relative)
@@ -117,7 +117,7 @@ export function findNearestFile(cwd: string, relative: string): string | null {
117
117
  * matching Claude's "every .claude/<kind> between the working directory and the
118
118
  * repository root" discovery where the entry closest to cwd wins a name clash. */
119
119
  export function ancestorDirs(cwd: string, relative: string): string[] {
120
- const boundary = repoRoot(cwd) ?? cwd
120
+ const boundary = gitRoot(cwd) ?? cwd
121
121
  const found: string[] = []
122
122
  let currentDir = cwd
123
123
  while (true) {
@@ -134,7 +134,7 @@ export function ancestorDirs(cwd: string, relative: string): string[] {
134
134
  /** Every `relative` file between the repository root and cwd, ordered root first,
135
135
  * matching Claude's root-down ordering for hierarchy-loaded context. */
136
136
  export function ancestorFiles(cwd: string, relative: string): string[] {
137
- const boundary = repoRoot(cwd) ?? cwd
137
+ const boundary = gitRoot(cwd) ?? cwd
138
138
  const found: string[] = []
139
139
  let currentDir = cwd
140
140
  while (true) {
@@ -52,12 +52,31 @@ export function claudeSettingsChain(cwd: string, home: string, includeProject: b
52
52
  const files = [path.join(claudeConfigDir(home), 'settings.json')]
53
53
  if (!includeProject) return files
54
54
  files.push(path.join(cwd, '.claude', 'settings.json'))
55
+ // Compared as the directory the placement rule returned, not re-derived from a
56
+ // joined path: path.join normalizes separators, so a cwd given POSIX-style on
57
+ // Windows would never equal its own joined form and the legacy entry would repeat.
55
58
  const localDir = localSettingsDir(cwd, home, platform, owned)
56
59
  if (localDir !== cwd) files.push(path.join(cwd, '.claude', 'settings.local.json'))
57
60
  files.push(path.join(localDir, '.claude', 'settings.local.json'))
58
61
  return files
59
62
  }
60
63
 
64
+ /** The settings.local.json the chain reads last, which is also where a setting a
65
+ * command persists (an output-style choice, an MCP consent) must be written for the
66
+ * chain to read it back: a file at any other level is never consulted. */
67
+ export function localSettingsFile(cwd: string, home: string, platform: NodeJS.Platform = process.platform, owned: (paths: string[]) => boolean = ownedByUser): string {
68
+ return path.join(localSettingsDir(cwd, home, platform, owned), '.claude', 'settings.local.json')
69
+ }
70
+
71
+ /** One settings file as a JSON object, or undefined when missing, unparseable or not
72
+ * an object: the single-file case of the chain, for the user-only settings a
73
+ * repository must not influence (a notification channel, a question timeout, a
74
+ * retention period). */
75
+ export function readSettingsFile(file: string): Record<string, unknown> | undefined {
76
+ const first = readSettingsChain([file]).next()
77
+ return first.done ? undefined : first.value
78
+ }
79
+
61
80
  /** Every readable settings object in the chain, in order, so the last one a caller
62
81
  * sees for a key is the one that wins. A file that is missing, unparseable, or not a
63
82
  * JSON object is skipped: a corrupt settings.json must not end the chain, or the
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs'
9
+ import { parseNumericEnv } from './values.js'
9
10
 
10
11
  // Captured at module load: the poll must run on real time even under a test's
11
12
  // fake timers (the stat watcher it replaced lived in libuv and was immune too);
@@ -29,7 +30,8 @@ function snapshot(file: string): string | undefined {
29
30
  * small, so re-reading them on the poll is negligible. The interval is
30
31
  * env-tunable for tests. */
31
32
  export function watchSettingsFiles(files: string[], reload: () => void): () => void {
32
- const interval = Number(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS) || 2000
33
+ const configured = parseNumericEnv(process.env.PI_CODE_SETTINGS_WATCH_INTERVAL_MS)
34
+ const interval = configured !== undefined && configured > 0 ? configured : 2000
33
35
  let last = files.map(snapshot)
34
36
  const timer = realSetInterval(() => {
35
37
  const next = files.map(snapshot)
@@ -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)
@@ -59,7 +70,9 @@ export function resolvePowershellBinary(platform: string = process.platform, env
59
70
  function isProjectTooling(dir: string, cwd: string): boolean {
60
71
  const relative = path.relative(cwd, dir)
61
72
  if (relative === '') return true
62
- if (relative.startsWith('..') || path.isAbsolute(relative)) return false
73
+ // Lexical containment: `..tools/...` is a directory inside cwd, only `..` itself or
74
+ // `../...` leaves it (the shape claude-rules.ts's containment check spells out).
75
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false
63
76
  return relative.split(path.sep).some((segment) => PROJECT_TOOLING_DIRS.has(segment))
64
77
  }
65
78
 
@@ -111,11 +124,11 @@ const powershellShell = (file: string): ResolvedShell => ({
111
124
  */
112
125
  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
126
  if (preferred === 'powershell') {
114
- const powershell = resolvePowershellBinary(platform, env)
127
+ const powershell = resolvePowershellBinary(platform, env, cwd)
115
128
  if (powershell) return powershellShell(powershell)
116
129
  }
117
130
  const bash = bashBinary(platform, env, cwd, installRoots)
118
131
  if (bash) return bashShell(bash)
119
- const powershell = resolvePowershellBinary(platform, env)
132
+ const powershell = resolvePowershellBinary(platform, env, cwd)
120
133
  return powershell ? powershellShell(powershell) : undefined
121
134
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Which file a tool call touched.
3
3
  *
4
- * Two extensions attach instruction files when a file tool touches a path they cover
5
- * (claude-rules for a path-scoped rule, context-imports for a nested CLAUDE.md), and
6
- * both got the same detail wrong: pi's edit and write tools accept `file_path` as an
7
- * alias for `path`, so a handler reading only `path` did nothing for a model that used
8
- * the alias. One reader, one place to be wrong.
4
+ * pi's read, edit and write tools accept `file_path` as an alias for `path`, so every
5
+ * reader of a file tool's target must accept both, and a handler reading only `path`
6
+ * does nothing for a model that used the alias. This is the one reader (claude-rules,
7
+ * context-imports and the command path-scope guard all go through it).
9
8
  */
10
9
 
11
10
  /** The tools that name a file pi-code acts on. */
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The shapes every extension here needed its own copy of: an error's message, a plain
3
- * object check, whether a path is a directory, and the text of a message content. Each
4
- * was written three to five times with the same body, and the error one appeared in
5
- * seventeen files.
2
+ * The small shared shapes: an error's message, a plain-object check, whether a path
3
+ * is a directory, the text of a message content, a regex escape, a numeric env
4
+ * value. One copy each; a private copy in an extension is the drift to look for.
6
5
  */
7
6
 
8
7
  import * as fs from 'node:fs'
@@ -36,3 +35,20 @@ export function contentText(content: unknown, separator = ''): string {
36
35
  .map((part) => part.text)
37
36
  .join(separator)
38
37
  }
38
+
39
+ /** Escape a string for literal use inside a RegExp. One copy: five private ones had
40
+ * the same body and would have drifted the first time one of them was fixed. */
41
+ export function escapeRegExp(text: string): string {
42
+ return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
43
+ }
44
+
45
+ /** A numeric environment value, or undefined when blank or not a number. Accepts the
46
+ * spellings docs/mcp.md promises (`2e3`, `64_000`); the caller decides the range and
47
+ * whether fractions are meaningful, so no flooring here. */
48
+ export function parseNumericEnv(raw: string | undefined): number | undefined {
49
+ if (raw === undefined) return undefined
50
+ const cleaned = raw.replaceAll('_', '')
51
+ if (cleaned.trim() === '') return undefined
52
+ const value = Number(cleaned)
53
+ return Number.isFinite(value) ? value : undefined
54
+ }
@@ -6,10 +6,12 @@
6
6
  import * as fs from 'node:fs'
7
7
  import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
9
10
  import { claudeConfigDir } from '../internal/config-dir.js'
10
11
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
11
12
  import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
12
13
  import { findNearestFile } from '../internal/project-root.js'
14
+ import { errorMessage } from '../internal/values.js'
13
15
 
14
16
  export interface StdioServerConfig {
15
17
  type?: 'stdio'
@@ -91,7 +93,9 @@ function claudeJsonPath(home: string): string {
91
93
  /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
92
94
  * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
93
95
  export function userConfigPaths(home: string): string[] {
94
- return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
96
+ // mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
97
+ // other agent-directory readers (trust store, OAuth tokens) already follow it.
98
+ return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
95
99
  }
96
100
 
97
101
  /** Project-scoped MCP config, each file the nearest of its name at or above cwd
@@ -107,8 +111,10 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
107
111
  try {
108
112
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
109
113
  Object.assign(servers, parsed.mcpServers ?? {})
110
- } catch {
111
- // missing or invalid file: skip silently, /mcp reports what loaded
114
+ } catch (error) {
115
+ // A missing file is the normal case. A present file that does not parse is
116
+ // not: one trailing comma silently disabled every server in it.
117
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${file}: ${errorMessage(error)}`)
112
118
  }
113
119
  }
114
120
  return servers
@@ -132,7 +138,8 @@ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string,
132
138
  try {
133
139
  const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
134
140
  return claudeJson.projects?.[cwd] ?? {}
135
- } catch {
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${claudeJsonPath(home)}: ${errorMessage(error)}`)
136
143
  return {}
137
144
  }
138
145
  }
@@ -49,12 +49,11 @@ 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
56
- // (imported by the test suite) reachable from this entry point unchanged. The managed
57
- // settings path helpers now live in the shared internal module.
56
+ // (imported by the test suite) reachable from this entry point unchanged.
58
57
  export type { HttpServerConfig, ServerConfig, StdioServerConfig } from './config.js'
59
58
  export { expandCwd, interpolateEnv, loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, userConfigPaths, warnOnTypelessUrl } from './config.js'
60
59
  export type { McpToolInfo } from './listing.js'
@@ -85,6 +84,12 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
85
84
 
86
85
  export default async function mcpExtension(pi: ExtensionAPI) {
87
86
  const clients = new Map<string, Client>()
87
+ // Names with a connect in flight. A name enters `clients` only once its connect
88
+ // resolves, and shutdown clears that map without awaiting anything in flight, so
89
+ // without this set a session switch during a slow connect (or a backoff sleep) let
90
+ // the next session_start connect the same name again; the first client then resolved
91
+ // into an entry the second overwrote and was never closed.
92
+ const connecting = new Set<string>()
88
93
  // The session's OAuth UI seams, captured at session_start. The reconnect paths below
89
94
  // run outside that handler, and passing undefined there made an INTERACTIVE session
90
95
  // report the headless "cannot log in" advice on a re-auth it could actually perform.
@@ -99,6 +104,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
99
104
  // Shutdown closes clients while they are still in the map; the onclose handlers
100
105
  // must not schedule reconnects for that deliberate teardown.
101
106
  let shuttingDown = false
107
+ // Bumped by every session_start and session_shutdown. A late-connecting server's
108
+ // deferred summary captured the ctx of the session that started it; pi disposes that
109
+ // ctx on /new, /resume, /fork and reload (any use of it throws), so the publish must
110
+ // check it still belongs to the live session before touching ctx.
111
+ let sessionGeneration = 0
102
112
  const callTuning = (name: string): ServerCallTuning => {
103
113
  const config = serverConfigs.get(name)
104
114
  return config ? serverCallTuning(config) : {}
@@ -189,7 +199,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
189
199
  async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
190
200
  for (let attempt = 0; attempt < 5; attempt++) {
191
201
  await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
192
- if (shuttingDown || clients.has(name)) return
202
+ if (shuttingDown || clients.has(name) || connecting.has(name)) return
193
203
  await connectServers({ [name]: config }, sessionAuthUi, true)
194
204
  if (clients.has(name)) return
195
205
  }
@@ -410,10 +420,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
410
420
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
411
421
  continue
412
422
  }
423
+ // The one guard for every connect path (session_start scopes, backoff, auth
424
+ // reconnect): a name still connecting from the previous session is adopted when
425
+ // that connect resolves, not connected again.
426
+ if (connecting.has(name)) continue
413
427
  // Seed in config order before connecting: parallel connects settle in completion
414
428
  // order, and /mcp plus the session summary iterate the map's insertion order.
415
429
  status.set(name, { state: 'connecting', tools: 0 })
416
430
  serverConfigs.set(name, config)
431
+ connecting.add(name)
417
432
  pending.push([name, config])
418
433
  }
419
434
  await Promise.all(
@@ -423,6 +438,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
423
438
  // First connections retry transient failures (Claude: up to three times for
424
439
  // HTTP/SSE); the backoff reconnect below carries its own schedule instead.
425
440
  const client = noRetry ? await connect(name, config, authUi, sessionDirs) : await connectWithRetries(name, config, authUi, sessionDirs)
441
+ // A session switch resets shuttingDown before this resolves and adopts the
442
+ // client; still set, pi is exiting and the shutdown handler already closed
443
+ // every client it could see, so this late one must close itself.
444
+ if (shuttingDown) {
445
+ await withTimeout(client.close(), 3000, 'close').catch(() => {})
446
+ status.delete(name)
447
+ return
448
+ }
426
449
  clients.set(name, client)
427
450
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
428
451
  registerTools(name, config, tools)
@@ -460,6 +483,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
460
483
  clients.delete(name)
461
484
  void leaked.close().catch(() => {})
462
485
  }
486
+ } finally {
487
+ connecting.delete(name)
463
488
  }
464
489
  }),
465
490
  )
@@ -563,6 +588,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
563
588
  if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
564
589
  }
565
590
 
591
+ /** Publishes the current alias set and the connection banner. `'connecting'`
592
+ * servers are excluded from both connected and failed counts: they are neither,
593
+ * and while the non-blocking startup race below is still in flight some legitimately
594
+ * are. Called once right after startup (whether or not every server settled by
595
+ * then) and again when a still-connecting tail finishes, so a server that connects
596
+ * late still gets its aliases published and counted. */
597
+ function publishConnectionSummary(ctx: ExtensionContext): void {
598
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
599
+ const connected = [...status.values()].filter((s) => s.state === 'connected')
600
+ const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected' && s.state !== 'connecting')
601
+ if (connected.length > 0 || failed.length > 0) {
602
+ const total = connected.reduce((sum, s) => sum + s.tools, 0)
603
+ const failNote = failed.length > 0 ? `, ${failed.length} failed` : ''
604
+ ctx.ui.notify(`MCP: ${total} tools from ${connected.length} servers${failNote}`, failed.length > 0 ? 'warning' : 'info')
605
+ }
606
+ }
607
+
566
608
  pi.on('session_start', async (_event, ctx) => {
567
609
  // Reset the status map so /mcp and the banner reflect only this session's config: a
568
610
  // server present last session but not this one must not linger as "connected". The
@@ -570,6 +612,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
570
612
  // withdrawn tool keeps its registration and surfaces the server's own error), which
571
613
  // is why serverToolCount reads from `registered` to recover the true count here.
572
614
  status.clear()
615
+ const generation = ++sessionGeneration
573
616
  // A same-process session switch (/new, /resume) shut the last session down;
574
617
  // this one may reconnect again. projectConnected guards against connecting the project
575
618
  // scope twice within one session, so it belongs to the session that set it: leaving it
@@ -579,7 +622,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
579
622
  projectConnected = false
580
623
  // Claude answers roots/list with the session's launch directory and exports the
581
624
  // 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 }
625
+ sessionDirs = { projectDir: repoRoot(ctx.cwd) ?? ctx.cwd, launchDir: ctx.cwd, sessionId: ctx.sessionManager?.getSessionId?.() }
583
626
  const authUi = authUiFor(ctx)
584
627
  sessionAuthUi = authUi
585
628
  // The allow/deny lists filter every scope, including a managed-mcp.json set. They
@@ -592,20 +635,35 @@ export default async function mcpExtension(pi: ExtensionAPI) {
592
635
  // file leaves the normal scopes untouched; a present but corrupt file fails closed to an
593
636
  // empty set (see loadManagedMcpServers).
594
637
  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')
638
+ const connecting = managed !== null ? connectManagedExclusive(managed, policy, authUi) : connectNormalScopes(ctx, policy, authUi)
639
+ // Claude: "MCP startup is non-blocking by default: servers connect in the
640
+ // background and their tools become available as they finish." A slow or
641
+ // unreachable server no longer costs the whole session's startup its own
642
+ // MCP_TIMEOUT x retries; alwaysLoad-style forced waiting and the ToolSearch/
643
+ // WaitForMcpServers mechanism that lets the model itself wait on a pending
644
+ // server's tools are not implemented, so a call to a tool that has not
645
+ // registered yet still just finds no such tool, same as before it connected.
646
+ //
647
+ // Claude also states non-interactive mode "waits for still-pending servers
648
+ // before the first turn regardless of this variable", because a -p run has no
649
+ // later turn to react to late-arriving tools; a headless ctx (no UI) keeps the
650
+ // prior fully-blocking wait for the same reason.
651
+ const settled = !ctx.hasUI
652
+ ? await connecting.then(() => true)
653
+ : await withTimeout(connecting, mcpConnectTimeoutMs(), 'mcp startup').then(
654
+ () => true,
655
+ () => false,
656
+ )
657
+ publishConnectionSummary(ctx)
658
+ if (!settled) {
659
+ // The rejection path exists for the throw a disposed ctx would raise if the
660
+ // generation check were ever wrong: from a bare `.then` it would be an unhandled
661
+ // rejection, and pi installs no handler, so Node would take the process down.
662
+ void connecting
663
+ .then(() => {
664
+ if (generation === sessionGeneration) publishConnectionSummary(ctx)
665
+ })
666
+ .catch(() => {})
609
667
  }
610
668
  })
611
669
 
@@ -613,6 +671,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
613
671
  // Closing fires each client's onclose while it is still in the map; the flag
614
672
  // stops those handlers (and any in-flight backoff loop) from reconnecting.
615
673
  shuttingDown = true
674
+ sessionGeneration++
616
675
  // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
617
676
  await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
618
677
  // Drop the closed clients and their status now rather than waiting on each client's
@@ -6,10 +6,9 @@
6
6
 
7
7
  import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
- import { claudeConfigDir } from '../internal/config-dir.js'
10
9
  import { managedSettingsFile } from '../internal/managed-settings.js'
11
- import { findNearestFile } from '../internal/project-root.js'
12
- import { errorMessage } from '../internal/values.js'
10
+ import { claudeSettingsChain } from '../internal/settings-chain.js'
11
+ import { errorMessage, escapeRegExp } from '../internal/values.js'
13
12
  import { interpolateEnv, type ServerConfig } from './config.js'
14
13
 
15
14
  export interface ProjectServerPolicy {
@@ -38,11 +37,17 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
38
37
  }
39
38
  }
40
39
  const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
41
- const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
42
- const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
43
- const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
44
- const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
45
- const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
40
+ // The files come from the one settings chain, so placement follows Claude's rules
41
+ // everywhere: the project's settings.json is read from cwd only (never an ancestor),
42
+ // and settings.local.json from the main checkout, with the chain's legacy cwd copy
43
+ // read too. Resolving them by nearest-file here gave a subdirectory session an
44
+ // ancestor's project file and a worktree session its own local file instead.
45
+ const [userFile, projectFile, ...localFiles] = claudeSettingsChain(cwd, home, true)
46
+ const userSettings = read(userFile)
47
+ const projectSettings = read(projectFile)
48
+ const localSettings = localFiles.map(read)
49
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...localSettings.flatMap((settings) => names(settings.disabledMcpjsonServers))])
50
+ const consentSources = projectApproved ? [userSettings, ...localSettings] : [userSettings]
46
51
  const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
47
52
  const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
48
53
  return { disabled, consented, consentAll }
@@ -127,10 +132,7 @@ export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = ma
127
132
 
128
133
  /** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
129
134
  function wildcardRegExp(pattern: string): RegExp {
130
- const source = pattern
131
- .split('*')
132
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
133
- .join('.*')
135
+ const source = pattern.split('*').map(escapeRegExp).join('.*')
134
136
  return new RegExp(`^${source}$`)
135
137
  }
136
138
 
@@ -18,6 +18,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
18
18
  import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
19
19
  import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
20
20
  import { resolveShell } from '../internal/shell-resolve.js'
21
+ import { parseNumericEnv } from '../internal/values.js'
21
22
  import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
22
23
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
23
24
 
@@ -36,25 +37,24 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
36
37
 
37
38
  /** Claude's numeric env vars accept scientific notation and digit-separator spellings
38
39
  * (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
39
- function parseNumericEnv(raw: string): number | undefined {
40
- const cleaned = raw.replaceAll('_', '')
41
- if (cleaned.trim() === '') return undefined
42
- const value = Number(cleaned)
43
- return Number.isFinite(value) ? Math.floor(value) : undefined
44
- }
45
-
46
40
  /** A positive-integer env override, or the default when unset or unparseable. */
47
41
  function envTimeout(name: string, fallback: number): number {
48
42
  const raw = process.env[name]
49
43
  if (raw === undefined) return fallback
50
44
  const value = parseNumericEnv(raw)
51
- return value !== undefined && value > 0 ? value : fallback
45
+ return value !== undefined && value > 0 ? Math.floor(value) : fallback
52
46
  }
53
47
 
54
48
  // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
55
49
  export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
56
50
  export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
57
51
 
52
+ // Claude: "How long blocking MCP startup waits, in milliseconds, for the connection
53
+ // batch before snapshotting the tool list (default: 5000)." Distinct from MCP_TIMEOUT,
54
+ // which bounds one server's own connect attempt, not the whole startup batch.
55
+ const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 5000
56
+ export const mcpConnectTimeoutMs = (): number => envTimeout('MCP_CONNECT_TIMEOUT_MS', DEFAULT_MCP_CONNECT_TIMEOUT_MS)
57
+
58
58
  /** Per-server inputs to the idle-window choice: the transport kind picks the default
59
59
  * tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
60
60
  export interface ServerCallTuning {
@@ -137,6 +137,9 @@ function isStdio(config: ServerConfig): config is StdioServerConfig {
137
137
  export interface SessionDirs {
138
138
  projectDir: string
139
139
  launchDir: string
140
+ /** Claude: "An MCP server subprocess retains the ID it was spawned with", so this
141
+ * is captured once here at connect time rather than re-read per call. */
142
+ sessionId?: string
140
143
  }
141
144
 
142
145
  /** A client that, like Claude, declares the roots capability and answers roots/list
@@ -191,6 +194,7 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
191
194
  const env: Record<string, string> = { ...getDefaultEnvironment(), CLAUDECODE: '1' }
192
195
  for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
193
196
  if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
197
+ if (session?.sessionId) env.CLAUDE_CODE_SESSION_ID = session.sessionId
194
198
  if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
195
199
  // The data dir is "created on first reference"; handing the path to a server is that
196
200
  // reference, so the server does not have to mkdir it before using it.
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
- import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
15
+ import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
17
  import { atomicWriteFile } from './internal/atomic-write.js'
18
18
  import { claudeConfigDir } from './internal/config-dir.js'
@@ -63,7 +63,7 @@ function memoryProject(cwd: string): string {
63
63
  }
64
64
 
65
65
  export function memoryDir(cwd: string): string {
66
- return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(memoryProject(cwd)))
66
+ return path.join(getAgentDir(), 'memory', projectSlug(memoryProject(cwd)))
67
67
  }
68
68
 
69
69
  /** The store location, honoring an `autoMemoryDirectory` override. Claude requires
@@ -142,7 +142,7 @@ export function stripNonLoaded(text: string): string {
142
142
  export function migrateLegacyStore(cwd: string): void {
143
143
  const current = memoryDir(cwd)
144
144
  if (fs.existsSync(current)) return
145
- const base = path.join(os.homedir(), '.pi', 'agent', 'memory')
145
+ const base = path.join(getAgentDir(), 'memory')
146
146
  // projectSlug(cwd) differs from current only for a subdirectory session (current is
147
147
  // keyed on the repo root); for a repo-root session it equals current and is skipped.
148
148
  const candidates = [path.join(base, projectSlug(cwd)), path.join(base, legacySlug(cwd))]
@@ -177,7 +177,7 @@ export function indexWouldOverflow(index: string, name: string, description: str
177
177
  /** Where the index stands against the read limits, measured on the loaded content
178
178
  * (frontmatter and comments stripped): 'over' past either bound, 'near' within
179
179
  * 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
180
- export function indexReadState(index: string): 'ok' | 'near' | 'over' {
180
+ function indexReadState(index: string): 'ok' | 'near' | 'over' {
181
181
  const loaded = stripNonLoaded(index)
182
182
  const lines = loaded.split('\n').length
183
183
  const bytes = Buffer.byteLength(loaded, 'utf-8')
@@ -16,12 +16,12 @@
16
16
  */
17
17
 
18
18
  import { execFile } from 'node:child_process'
19
- import * as fs from 'node:fs'
20
19
  import * as os from 'node:os'
21
20
  import * as path from 'node:path'
22
21
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
22
 
24
23
  import { claudeConfigDir } from './internal/config-dir.js'
24
+ import { readSettingsFile } from './internal/settings-chain.js'
25
25
 
26
26
  /** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
27
27
  export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
@@ -56,12 +56,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
56
56
  * preference, so only user scope is read; a checked-out repo does not get to silence
57
57
  * or change your notifications. */
58
58
  function readPreferredNotifChannel(home: string): unknown {
59
- try {
60
- const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
61
- return settings?.preferredNotifChannel
62
- } catch {
63
- return undefined
64
- }
59
+ return readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.preferredNotifChannel
65
60
  }
66
61
 
67
62
  function windowsToastScript(title: string, body: string): string {