pi-code 1.0.73 → 1.0.75

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.
@@ -289,6 +289,9 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
289
289
  // Scoped rules still awaiting a matching touch. An attached rule leaves the
290
290
  // list, so each attaches at most once and the per-tool-result scan shrinks.
291
291
  let attachTargets: AttachTarget[] = []
292
+ // Every scoped rule of the session, the list attachTargets is rebuilt from when what was
293
+ // attached leaves the context (see rearm below).
294
+ let scopedTargets: AttachTarget[] = []
292
295
 
293
296
  pi.on('session_start', async (_event, ctx) => {
294
297
  // Project rules are repository text landing in the system prompt, so they load
@@ -318,6 +321,7 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
318
321
  ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(ctx.cwd), file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
319
322
  ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
320
323
  ]
324
+ scopedTargets = attachTargets
321
325
  pendingScopedRules = attachTargets.length
322
326
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
323
327
  // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
@@ -340,6 +344,16 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
340
344
  return { systemPrompt: event.systemPrompt + addition }
341
345
  })
342
346
 
347
+ // A rule's body sits in the tool result that attached it. Compaction folds that result into
348
+ // a summary and /tree moves to a branch that never had it, and Claude reloads the rule "as
349
+ // Claude reads files they apply to", so every rule is armed again.
350
+ const rearm = (): void => {
351
+ attachTargets = scopedTargets
352
+ pendingScopedRules = attachTargets.length
353
+ }
354
+ pi.on('session_compact', rearm)
355
+ pi.on('session_tree', rearm)
356
+
343
357
  // Lazy attach: when a file tool touches a path a scoped rule covers, append the
344
358
  // rule body to that tool's result so it enters context, once per rule per session.
345
359
  // This mirrors Claude Code, which attaches a scoped rule when a matching file is
@@ -145,12 +145,15 @@ export interface ImportBudget {
145
145
  files: number
146
146
  bytes: number
147
147
  dropped: number
148
- /** Existing files an @import named that resolve outside the importer's allowed
149
- * roots. Collected so the refusal can be reported rather than left silent. */
148
+ /** Every @import target that resolves outside the importer's allowed roots, whether or not
149
+ * it exists: the notice to the model must not reveal which of a repo's guesses do. */
150
150
  refused: Set<string>
151
+ /** The subset of `refused` that is an existing file, which is what an approval would
152
+ * load and so what the user's dialog is about. */
153
+ refusedPresent: Set<string>
151
154
  }
152
155
 
153
- export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
156
+ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set(), refusedPresent: new Set() })
154
157
 
155
158
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
156
159
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
@@ -171,16 +174,25 @@ function importTargets(content: string): string[] {
171
174
  return targets
172
175
  }
173
176
 
177
+ function isRegularFile(target: string): boolean {
178
+ try {
179
+ return fs.statSync(target).isFile()
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
174
185
  /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
175
- function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refused: Set<string>): { real: string; body: string } | null {
186
+ function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refusals: Pick<ImportBudget, 'refused' | 'refusedPresent'>): { real: string; body: string } | null {
176
187
  const resolved = path.resolve(fromDir, expandHome(target, home))
177
188
  // Always the path the importing file named, never where a symlink pointed: the
178
189
  // notice would otherwise hand a repo the real name of whatever the link reaches,
179
190
  // which is the disclosure the refusal exists to prevent. An excluded file is not
180
191
  // named either, since exclusion removes it from every other surface too.
181
- const refuse = (): null => {
182
- if (isExcluded?.(resolved) !== true) refused.add(resolved)
183
- return null
192
+ const refuse = (isFile: boolean): void => {
193
+ if (isExcluded?.(resolved) === true) return
194
+ refusals.refused.add(resolved)
195
+ if (isFile) refusals.refusedPresent.add(resolved)
184
196
  }
185
197
  let real: string
186
198
  try {
@@ -190,10 +202,14 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
190
202
  // reported never depends on whether it exists: a notice that named only the
191
203
  // existing ones would enumerate the filesystem for any repo-controlled file
192
204
  // willing to write one @line per guess.
193
- return isUnder(resolved, allowedRoots) ? null : refuse()
205
+ if (!isUnder(resolved, allowedRoots)) refuse(false)
206
+ return null
194
207
  }
195
208
  if (seen.has(real)) return null
196
- if (!isUnder(real, allowedRoots)) return refuse()
209
+ if (!isUnder(real, allowedRoots)) {
210
+ refuse(isRegularFile(real))
211
+ return null
212
+ }
197
213
  // Checked before the read so an excluded file contributes nothing: no body, no
198
214
  // transitive imports, no budget spend, no announce. A post-collection filter
199
215
  // would drop the file itself but keep its children.
@@ -206,7 +222,8 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
206
222
  seen.add(real)
207
223
  // A file another importer already refused is in context after all; the notice
208
224
  // must not claim otherwise.
209
- refused.delete(resolved)
225
+ refusals.refused.delete(resolved)
226
+ refusals.refusedPresent.delete(resolved)
210
227
  return { real, body }
211
228
  } catch {
212
229
  return null
@@ -243,7 +260,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
243
260
  scan.budget.dropped += 1
244
261
  continue
245
262
  }
246
- const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
263
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget)
247
264
  if (!file) continue
248
265
  scan.budget.files -= 1
249
266
  // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
@@ -272,16 +289,21 @@ export function collectImports(content: string, fromDir: string, home: string, a
272
289
  /**
273
290
  * Roots an importing file may pull from.
274
291
  *
275
- * A context file under the user's own config may reach the whole config; a project
276
- * file may not. `~/.claude` holds `.credentials.json`, global settings and every
277
- * project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
278
- * would let it read them into the system prompt.
292
+ * A file under the user's own config may import from anywhere: Claude's user-scope memory
293
+ * files "are files you wrote yourself", and it loads their imports without the dialog and
294
+ * trusts them like the rest of the user's configuration. A project file may not, so a cloned
295
+ * repo's `CLAUDE.md` cannot read `~/.claude` (`.credentials.json`, global settings, every
296
+ * project's transcripts) into the system prompt.
297
+ *
298
+ * "Under the user's config" is judged by the path pi reported as well as its realpath: a
299
+ * dotfiles manager such as GNU stow links `~/.claude/CLAUDE.md` to a repository, so the
300
+ * real path is outside `~/.claude` although the user put the link there.
279
301
  */
280
302
  export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
281
- const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
303
+ const userConfig = [claudeConfigDir(home), path.join(home, '.pi')]
282
304
  const [real] = realRoots([importer])
283
- const fromUserConfig = real !== undefined && isUnder(real, userRoots)
284
- if (fromUserConfig) return realRoots([cwd, ...userRoots])
305
+ const fromUserConfig = isUnder(path.resolve(importer), userConfig) || (real !== undefined && isUnder(real, realRoots(userConfig)))
306
+ if (fromUserConfig) return [ANY_ROOT]
285
307
  // The project was asked about its external imports and allowed them, so a project
286
308
  // file may reach outside, as Claude's dialog grants. The widening is deliberately
287
309
  // only for project files: a user-scope file's roots are its own config, and an
@@ -552,7 +574,9 @@ export const EXTERNAL_IMPORT_PROMPT_TITLE = 'Load imports from outside this proj
552
574
  *
553
575
  * The list is the refusals the enforcing path produced, not a second enumeration of
554
576
  * what it might refuse: same files, same depth, same resolution, same exclusions. That
555
- * is the only way the dialog can promise it names everything the approval lets in.
577
+ * is the only way the dialog can promise it names everything the approval lets in. Only
578
+ * files that exist are listed, as in Claude, which asks about the external files it read:
579
+ * a prose `@/components/ui` names nothing an approval could load.
556
580
  */
557
581
  async function askExternalImports(ctx: ExtensionContext, root: string, refused: ReadonlySet<string>): Promise<boolean> {
558
582
  const listed = [...refused]
@@ -853,8 +877,15 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
853
877
  // @import cannot pay for a body that is already there.
854
878
  let launchLoadedPaths: string[] = []
855
879
  // Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
856
- // read in the same subtree does not repeat them.
880
+ // read in the same subtree does not repeat them. Emptied when compaction or /tree takes
881
+ // the tool results that carried them out of context: Claude reloads them "as Claude
882
+ // reads files they apply to".
857
883
  const nestedLoaded = new Set<string>()
884
+ const forgetNested = (): void => {
885
+ nestedLoaded.clear()
886
+ }
887
+ pi.on('session_compact', forgetNested)
888
+ pi.on('session_tree', forgetNested)
858
889
  // Instruction loads already announced on the shared bus, keyed reason:path.
859
890
  // before_agent_start fires every turn, so without this a configured
860
891
  // InstructionsLoaded hook would fire once per file per turn.
@@ -1023,8 +1054,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1023
1054
  const key = externalImportKey(cwd)
1024
1055
  const decided = externalImportDecision(key)
1025
1056
  const result = expandWith(decided === true)
1026
- if (decided !== null || result.budget.refused.size === 0 || ctx?.hasUI !== true) return result
1027
- const approved = await askExternalImports(ctx, key, result.budget.refused)
1057
+ if (decided !== null || result.budget.refusedPresent.size === 0 || ctx?.hasUI !== true) return result
1058
+ const approved = await askExternalImports(ctx, key, result.budget.refusedPresent)
1028
1059
  rememberExternalImportDecision(key, approved)
1029
1060
  return approved ? expandWith(true) : result
1030
1061
  }
@@ -188,6 +188,19 @@ export class FileOAuthProvider implements OAuthClientProvider {
188
188
  return this.data.tokens !== undefined
189
189
  }
190
190
 
191
+ /** Called by the SDK when the server rejects what is stored: `invalid_grant` for an expired
192
+ * or revoked refresh token, `invalid_client` for a forgotten registration. It restarts
193
+ * authorization once this returns, which reaches the login prompt. Without it the
194
+ * rejection propagated and the dead credentials stayed on disk, so the server failed the
195
+ * same way on every connect. Discovery state is not stored, and the redirect port is kept
196
+ * so a re-login can still bind the port the client was registered with. */
197
+ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void {
198
+ if (scope === 'all' || scope === 'client') delete this.data.client
199
+ if (scope === 'all' || scope === 'tokens') delete this.data.tokens
200
+ if (scope === 'all' || scope === 'verifier') delete this.data.verifier
201
+ this.persist()
202
+ }
203
+
191
204
  /** The CSRF token the SDK adds to the authorization URL as `state`; waitForAuthCode
192
205
  * verifies the redirect echoes exactly this value. */
193
206
  state(): string {
@@ -6,8 +6,10 @@
6
6
  * active only when `enabledPlugins` in the settings chain says true, under the
7
7
  * bare name or the marketplace-qualified `name@marketplace`. Only an explicit
8
8
  * true enables: Claude writes the entry on install, so a cached plugin with no
9
- * entry is not one the user turned on. With no version index on disk, the
10
- * newest version directory wins, matching the update-then-grace-period layout.
9
+ * entry is not one the user turned on. The version that loads is the one Claude's
10
+ * installed_plugins.json records; without a usable record, the newest directory
11
+ * not marked `.orphaned_at`, since an update leaves the old version in the cache
12
+ * for a grace period and some directories are named by commit sha.
11
13
  * The persistent data directory (${CLAUDE_PLUGIN_DATA}) survives updates at
12
14
  * ~/.claude/plugins/data/<id>, id being the qualified name folded to dashes.
13
15
  */
@@ -59,6 +61,12 @@ function listDirs(dir: string): string[] {
59
61
  }
60
62
  }
61
63
 
64
+ /** The plugin directories of a marketplace. A dot-directory is never one: Claude clones a
65
+ * marketplace into `temp_git_*` first, and its `.git` was loaded as a plugin. */
66
+ function listPluginDirs(marketplaceDir: string): string[] {
67
+ return listDirs(marketplaceDir).filter((name) => !name.startsWith('.'))
68
+ }
69
+
62
70
  /** One version string split for comparison: optional v prefix dropped, numeric
63
71
  * base segments, and whatever follows a dash as the prerelease tag. */
64
72
  function parseVersion(version: string): { base: number[]; pre: string | undefined } {
@@ -88,6 +96,39 @@ function newestVersion(versions: string[]): string | undefined {
88
96
  return [...versions].sort(compareVersions).at(-1)
89
97
  }
90
98
 
99
+ /** Claude's record of what is installed, `plugins/installed_plugins.json`: per plugin id a
100
+ * list of installs (an object in the older layout), each with its scope and `installPath`.
101
+ * Maps each id to its recorded directories, user scope first: enablement here is the
102
+ * user's own, and a project or local install is another project's copy. Undefined when the
103
+ * file is absent or unusable, which leaves the directory walk to decide. */
104
+ function readInstallIndex(home: string): Map<string, string[]> | undefined {
105
+ const file = path.join(claudeConfigDir(home), 'plugins', 'installed_plugins.json')
106
+ const plugins = readJson(file).plugins
107
+ if (plugins === null || typeof plugins !== 'object') return undefined
108
+ const installs = new Map<string, string[]>()
109
+ for (const [id, entry] of Object.entries(plugins)) {
110
+ const recorded = (Array.isArray(entry) ? entry : [entry]).filter((one): one is { scope?: unknown; installPath: string } => typeof one?.installPath === 'string')
111
+ recorded.sort((a, b) => Number(b.scope === 'user') - Number(a.scope === 'user'))
112
+ installs.set(
113
+ id,
114
+ recorded.map((one) => one.installPath),
115
+ )
116
+ }
117
+ return installs
118
+ }
119
+
120
+ /** The version directory of one cached plugin that loads: a recorded install that still
121
+ * exists and sits directly under the plugin's cache directory (the record is a file, and a
122
+ * path into another plugin's tree would load that plugin's code under this one's name),
123
+ * else the newest version not marked orphaned. */
124
+ function versionDir(pluginPath: string, recorded: string[] | undefined): string | undefined {
125
+ const installed = recorded?.find((dir) => path.dirname(path.resolve(dir)) === pluginPath && listDirs(pluginPath).includes(path.basename(dir)))
126
+ if (installed) return installed
127
+ const live = listDirs(pluginPath).filter((version) => !fs.existsSync(path.join(pluginPath, version, '.orphaned_at')))
128
+ const newest = newestVersion(live)
129
+ return newest === undefined ? undefined : path.join(pluginPath, newest)
130
+ }
131
+
91
132
  /** The enablement map, later files winning per key, as settings scopes merge. */
92
133
  function enabledMap(settingsFiles: string[]): Record<string, boolean> {
93
134
  const merged: Record<string, boolean> = {}
@@ -159,26 +200,26 @@ function contentToken(target: string): string {
159
200
  /**
160
201
  * A cheap change signature for one home's plugin config: the settings files'
161
202
  * content hashes plus the cache tree's directory names and mtimes down through each plugin's
162
- * version directories, and the stat token of the resolved (newest) version's manifest
203
+ * version directories, and the stat token of the resolved version's manifest (which moves when Claude's install record selects another)
163
204
  * so an in-place edit of it invalidates the cache. Costs a few stats where the full
164
205
  * walk reads and parses the settings and every manifest.
165
206
  */
166
- function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
207
+ function pluginFingerprint(cacheDir: string, settingsFiles: string[], index: Map<string, string[]> | undefined): string {
167
208
  const parts = settingsFiles.map(contentToken)
168
209
  for (const marketplace of listDirs(cacheDir)) {
169
210
  const marketplaceDir = path.join(cacheDir, marketplace)
170
211
  parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
171
- for (const pluginDir of listDirs(marketplaceDir)) {
212
+ for (const pluginDir of listPluginDirs(marketplaceDir)) {
172
213
  const pluginPath = path.join(marketplaceDir, pluginDir)
173
214
  parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
174
215
  const versions = listDirs(pluginPath)
175
216
  for (const version of versions) {
176
217
  parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
177
218
  }
178
- // resolvePlugin reads only the newest version's manifest, so its stat token is
219
+ // resolvePlugin reads only the resolved version's manifest, so its stat token is
179
220
  // what an in-place edit (no directory entry changing) must move.
180
- const newest = newestVersion(versions)
181
- if (newest) parts.push(`${marketplace}/${pluginDir}/${newest}/manifest:${statToken(path.join(pluginPath, newest, '.claude-plugin', 'plugin.json'))}`)
221
+ const resolved = versionDir(pluginPath, index?.get(`${pluginDir}@${marketplace}`))
222
+ if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${statToken(path.join(resolved, '.claude-plugin', 'plugin.json'))}`)
182
223
  }
183
224
  }
184
225
  return parts.join('\n')
@@ -199,15 +240,16 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
199
240
  const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
200
241
  const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
201
242
  const key = [home, ...extraSettingsFiles].join('\n')
202
- const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
243
+ const index = readInstallIndex(home)
244
+ const fingerprint = pluginFingerprint(cacheDir, settingsFiles, index)
203
245
  const cached = pluginCache.get(key)
204
246
  if (cached?.fingerprint === fingerprint) return cached.plugins
205
247
  const enabled = enabledMap(settingsFiles)
206
248
  const configs = pluginConfigsMap(settingsFiles)
207
249
  const plugins: InstalledPlugin[] = []
208
250
  for (const marketplace of listDirs(cacheDir)) {
209
- for (const pluginDir of listDirs(path.join(cacheDir, marketplace))) {
210
- const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs)
251
+ for (const pluginDir of listPluginDirs(path.join(cacheDir, marketplace))) {
252
+ const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs, index)
211
253
  if (plugin) plugins.push(plugin)
212
254
  }
213
255
  }
@@ -243,11 +285,10 @@ function pluginEnabled(qualified: string, pluginDir: string, enabled: Record<str
243
285
 
244
286
  /** Resolve one cached plugin directory into an enabled InstalledPlugin, or null to skip
245
287
  * it: turned off by managed/user settings or defaultEnabled, or no version yet. */
246
- function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>): InstalledPlugin | null {
288
+ function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>, installs: Map<string, string[]> | undefined): InstalledPlugin | null {
247
289
  const qualified = `${pluginDir}@${marketplace}`
248
- const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
249
- if (!version) return null
250
- const root = path.join(cacheDir, marketplace, pluginDir, version)
290
+ const root = versionDir(path.join(cacheDir, marketplace, pluginDir), installs?.get(qualified))
291
+ if (!root) return null
251
292
  const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
252
293
  if (!pluginEnabled(qualified, pluginDir, enabled, manifest)) return null
253
294
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
@@ -49,7 +49,7 @@ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope,
49
49
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
50
50
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
51
51
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
52
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isUnauthorized, mcpConnectTimeoutMs, type ServerCallTuning, type SessionDirs, serverCallTuning, withTimeout } from './transport.js'
52
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, connectWithRetries, isConnectionLost, 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
@@ -189,6 +189,19 @@ export default async function mcpExtension(pi: ExtensionAPI) {
189
189
  return count
190
190
  }
191
191
 
192
+ /** A client that is gone: flip the status and free the name so a reconnect can take it.
193
+ * Claude reconnects a dropped remote server with exponential backoff; stdio servers are
194
+ * local processes and are not reconnected. Shutdown closes clients while they are still
195
+ * in the map, so the flag guards against scheduling a reconnect for a deliberate
196
+ * teardown. Called by the transport's onclose and by a call that found the connection
197
+ * dead (isConnectionLost), so several calls failing together schedule one reconnect. */
198
+ function dropClient(name: string, client: Client, config: ServerConfig): void {
199
+ if (clients.get(name) !== client) return
200
+ clients.delete(name)
201
+ status.set(name, { state: 'disconnected', tools: 0 })
202
+ if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
203
+ }
204
+
192
205
  /** Claude's mid-session reconnect for a dropped remote server: five attempts with
193
206
  * a delay doubling from one second. connectServers redoes the full bring-up
194
207
  * (tools, prompts, subscriptions, a fresh onclose) and its duplicate guard skips
@@ -205,17 +218,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
205
218
  }
206
219
  }
207
220
 
221
+ // One auth recovery per server at a time, shared by every call that met the same 401.
222
+ const authReconnects = new Map<string, Promise<void>>()
223
+
208
224
  /** Claude's 401/403 tool-call recovery: drop the client and reconnect once, so the
209
225
  * headersHelper re-runs (fresh credential) or the OAuth tokens refresh, then the
210
226
  * caller retries the call once. The map delete precedes the close so the onclose
211
- * guard does not also schedule a backoff reconnect. */
212
- async function reconnectForAuth(name: string, config: ServerConfig): Promise<void> {
213
- const old = clients.get(name)
214
- if (old) {
227
+ * guard does not also schedule a backoff reconnect. Parallel calls that all met the
228
+ * expired credential share the one reconnect: `failed` is the client a call was made
229
+ * on, and a map holding another one means a sibling already replaced it, so closing
230
+ * "the client in the map" would tear down the fresh connection under that sibling. */
231
+ function reconnectForAuth(name: string, config: ServerConfig, failed: Client | undefined): Promise<void> {
232
+ const inFlight = authReconnects.get(name)
233
+ if (inFlight !== undefined) return inFlight
234
+ if (clients.get(name) !== failed) return Promise.resolve()
235
+ const recovery = (async () => {
215
236
  clients.delete(name)
216
- await withTimeout(old.close(), 3000, 'close').catch(() => {})
217
- }
218
- await connectServers({ [name]: config }, sessionAuthUi, true)
237
+ if (failed) await withTimeout(failed.close(), 3000, 'close').catch(() => {})
238
+ await connectServers({ [name]: config }, sessionAuthUi, true)
239
+ })().finally(() => authReconnects.delete(name))
240
+ authReconnects.set(name, recovery)
241
+ return recovery
219
242
  }
220
243
 
221
244
  /** A tool call with the auth retry: on a 401/403 rejection, reconnect once and
@@ -223,16 +246,26 @@ export default async function mcpExtension(pi: ExtensionAPI) {
223
246
  async function callToolWithAuthRetry(name: string, config: ServerConfig, args: { name: string; arguments: Record<string, unknown> }, label: string): Promise<Awaited<ReturnType<Client['callTool']>>> {
224
247
  const tuning = serverCallTuning(config)
225
248
  const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
249
+ let used: Client | undefined
226
250
  const callOnce = async () => {
227
251
  const current = clients.get(name)
228
252
  if (!current) throw new Error(`MCP server "${name}" is not connected`)
229
- return await withTimeout(current.callTool(args, undefined, callRequestOptions(wall, tuning)), wall, label)
253
+ used = current
254
+ try {
255
+ return await withTimeout(current.callTool(args, undefined, callRequestOptions(wall, tuning)), wall, label)
256
+ } catch (error) {
257
+ if (isConnectionLost(error)) {
258
+ dropClient(name, current, config)
259
+ void withTimeout(current.close(), 3000, 'close').catch(() => {})
260
+ }
261
+ throw error
262
+ }
230
263
  }
231
264
  try {
232
265
  return await callOnce()
233
266
  } catch (error) {
234
267
  if (!isUnauthorized(error)) throw error
235
- await reconnectForAuth(name, config)
268
+ await reconnectForAuth(name, config, used)
236
269
  return await callOnce()
237
270
  }
238
271
  }
@@ -463,16 +496,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
463
496
  // A server that dies mid-session would otherwise stay "connected" in /mcp
464
497
  // while every call fails with the SDK's bare "Not connected"; flip the
465
498
  // status and free the name so a later session start can reconnect it.
466
- client.onclose = () => {
467
- if (clients.get(name) !== client) return
468
- clients.delete(name)
469
- status.set(name, { state: 'disconnected', tools: 0 })
470
- // Claude reconnects a dropped remote server with exponential backoff;
471
- // stdio servers are local processes and are not reconnected. Shutdown
472
- // closes clients while they are still in the map, so the flag guards
473
- // against scheduling a reconnect for a deliberate teardown.
474
- if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
475
- }
499
+ client.onclose = () => dropClient(name, client, config)
476
500
  } catch (error) {
477
501
  status.set(name, { state: `failed: ${errorMessage(error)}`, tools: 0 })
478
502
  // Connected but failed after (tool listing hung or errored): left in the
@@ -4,11 +4,25 @@
4
4
  * blocks mapped into pi's output budget.
5
5
  */
6
6
 
7
+ import { createHash } from 'node:crypto'
8
+
7
9
  import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
8
10
  import { capForContext } from '../internal/output-guard.js'
9
11
 
12
+ /** Providers take a tool name only as `^[a-zA-Z0-9_-]{1,64}$` (64 is OpenAI's cap, the
13
+ * tighter of the two) and reject the whole request otherwise, so one bad name failed every
14
+ * turn of the session. */
15
+ const MAX_TOOL_NAME = 64
16
+
17
+ /** The pi tool name for a server's tool: `server_tool` with every character a provider
18
+ * rejects (the MCP spec allows dots; a server name is whatever the user typed) as an
19
+ * underscore. A name over the limit is cut and ends in a hash of the whole, so two long
20
+ * tools of one server stay apart and a tool keeps its name from one session to the next. */
10
21
  export function formatToolName(server: string, tool: string): string {
11
- return `${server}_${tool}`.replaceAll('-', '_')
22
+ const name = `${server}_${tool}`.replace(/\W/g, '_')
23
+ if (name.length <= MAX_TOOL_NAME) return name
24
+ const digest = createHash('sha256').update(name).digest('hex').slice(0, 8)
25
+ return `${name.slice(0, MAX_TOOL_NAME - digest.length - 1)}_${digest}`
12
26
  }
13
27
 
14
28
  /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands: any
@@ -339,6 +339,21 @@ export function isTransientConnectError(error: unknown): boolean {
339
339
  return /ECONNREFUSED|ECONNRESET|ETIMEDOUT|timed out after/.test(String(error))
340
340
  }
341
341
 
342
+ /** Whether a failed call means the server is gone, not that the call was wrong: a refused,
343
+ * reset or unreachable connection, the 404 a restarted stateful server answers for a
344
+ * session it forgot (the spec has the client start a new one), or the SDK's own
345
+ * closed-connection errors. The SDK fires `Client.onclose` only from `close()`, never when
346
+ * an HTTP or SSE server disappears, so a call is the one place a dead server shows.
347
+ * Narrower than isTransientConnectError on purpose: a tool timing out, or a tool's own
348
+ * 5xx, says nothing about the connection and must not drop a healthy server. The closed
349
+ * connection is matched by the SDK's message, not its -32000 code, which servers also use
350
+ * for errors of their own. */
351
+ export function isConnectionLost(error: unknown): boolean {
352
+ const code = typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined
353
+ if (code === 404 || errorCodes(error).some((one) => TRANSIENT_CODES.has(one))) return true
354
+ return /\(HTTP 404\)|^Not connected$|^MCP error -32000: Connection closed$/.test(error instanceof Error ? error.message : '')
355
+ }
356
+
342
357
  const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
343
358
 
344
359
  /** Connect with Claude's first-connection retry: an HTTP or SSE server's transient
@@ -29,6 +29,9 @@ export interface BackgroundRun {
29
29
  partial?: boolean
30
30
  /** Temp dir holding a prompt file rebuilt for a resume; removed with the run. */
31
31
  rebuiltPromptDir?: string
32
+ /** Temp dir the child persists its session in, apart from the project's session dir;
33
+ * removed with the run. */
34
+ sessionDir?: string
32
35
  /** Set while running so the run can be cancelled; cleared on completion. */
33
36
  kill?: () => void
34
37
  /** True until the child process actually closes: a cancelled child that ignores
@@ -90,7 +93,7 @@ export function activeBackgroundRuns(): number {
90
93
  /** Test seam: the registry is module state, so tests reset it between cases to
91
94
  * stay order-independent. */
92
95
  export function resetBackgroundRuns(): void {
93
- for (const run of runs.values()) removeRebuiltPrompt(run)
96
+ for (const run of runs.values()) removeRunFiles(run)
94
97
  runs.clear()
95
98
  state.finishSequence = 0
96
99
  }
@@ -99,7 +102,7 @@ function evictFinishedRuns(): void {
99
102
  const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
100
103
  finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
101
104
  for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) {
102
- removeRebuiltPrompt(stale)
105
+ removeRunFiles(stale)
103
106
  runs.delete(stale.id)
104
107
  }
105
108
  }
@@ -178,7 +181,7 @@ export function cancelAllBackgroundRuns(): number {
178
181
  if (cancelBackgroundRun(id) === 'cancelled') count++
179
182
  }
180
183
  // Called at quit: nothing in this registry is resumable once pi exits.
181
- for (const run of runs.values()) removeRebuiltPrompt(run)
184
+ for (const run of runs.values()) removeRunFiles(run)
182
185
  return count
183
186
  }
184
187
 
@@ -259,12 +262,14 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: s
259
262
  }
260
263
  }
261
264
 
262
- /** The rebuilt prompt lives as long as its run can be resumed, so it goes when the run
263
- * leaves the registry: eviction, quit, or the test reset. */
264
- function removeRebuiltPrompt(run: BackgroundRun): void {
265
- if (!run.rebuiltPromptDir) return
266
- fs.rmSync(run.rebuiltPromptDir, { recursive: true, force: true })
265
+ /** A run's temp files (the rebuilt prompt, its session) live as long as the run can be
266
+ * resumed, so they go when it leaves the registry: eviction, quit, or the test reset. */
267
+ function removeRunFiles(run: BackgroundRun): void {
268
+ for (const dir of [run.rebuiltPromptDir, run.sessionDir]) {
269
+ if (dir) fs.rmSync(dir, { recursive: true, force: true })
270
+ }
267
271
  run.rebuiltPromptDir = undefined
272
+ run.sessionDir = undefined
268
273
  }
269
274
 
270
275
  export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
@@ -276,12 +281,15 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
276
281
  // id the run will carry.
277
282
  const id = presetId ?? `bg-${randomUUID().slice(0, 8)}`
278
283
  // A stable session id per run: the child persists its session, so a follow-up can
279
- // resume it instead of starting cold.
284
+ // resume it instead of starting cold. It persists into a directory of its own: in the
285
+ // project's session dir it would be the newest session there, which `pi -c` and /resume
286
+ // offer as the user's previous one. The same args serve the resume, so it finds the file.
280
287
  const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
288
+ const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-code-bg-session-'))
281
289
  const args = invocation.args.map((arg) => (arg === '--no-session' ? '--session-id' : arg))
282
- const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId] : [arg])) : args
290
+ const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId, '--session-dir', sessionDir] : [arg])) : args
283
291
  const spawnSpec: BackgroundSpawn = { ...invocation, args: withSession }
284
- const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, spawn: spawnSpec }
292
+ const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, sessionDir, spawn: spawnSpec }
285
293
  runs.set(id, run)
286
294
  driveRun(run, spawnSpec, onComplete)
287
295
  return id
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.73",
3
+ "version": "1.0.75",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",