pi-code 1.0.73 → 1.0.74

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.
@@ -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 {
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.73",
3
+ "version": "1.0.74",
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",