pi-code 1.0.24 → 1.0.26

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.
@@ -95,7 +95,7 @@ import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } fro
95
95
  import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
96
96
  import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
97
97
  import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
98
- import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
98
+ import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
99
99
 
100
100
  export * from './config.js'
101
101
  export * from './decisions.js'
@@ -185,6 +185,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
185
185
  if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
186
186
  return common
187
187
  }
188
+ /** Claude's prompt-hook `model` override, resolved against the models this user
189
+ * can run (exact id first, then a substring match); the session model otherwise. */
190
+ const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => {
191
+ if (!override) return ctx.model
192
+ const available = (ctx as { modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? []
193
+ const needle = override.toLowerCase()
194
+ const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
195
+ return (match as ExtensionContext['model']) ?? ctx.model
196
+ }
197
+
188
198
  /** Kills for background hooks still running; Claude kills async hooks at teardown,
189
199
  * so session_shutdown reaps anything left rather than let a hung hook pin the
190
200
  * event loop past a one-shot run's end. */
@@ -220,7 +230,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
220
230
  const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
221
231
  const dispatch = (onChild?: (kill: () => void) => void): Promise<HookRunResult> => {
222
232
  if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
223
- if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
233
+ if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
224
234
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
225
235
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
226
236
  return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
@@ -483,6 +493,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
483
493
  stopHookActive = false
484
494
  return
485
495
  }
496
+ // Claude: Stop does not run when the stoppage was a user interrupt; pi marks
497
+ // the aborted turn's final assistant message stopReason "aborted".
498
+ const turnMessages = (event as { messages?: Array<{ role: string; stopReason?: string }> }).messages ?? []
499
+ const lastAssistant = [...turnMessages].reverse().find((message) => message.role === 'assistant')
500
+ if (lastAssistant?.stopReason === 'aborted') {
501
+ stopHookActive = false
502
+ return
503
+ }
486
504
  // Claude's Stop payload carries the turn's final assistant text so a hook need
487
505
  // not re-read the transcript; included only when there is one.
488
506
  const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
@@ -543,7 +561,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
543
561
 
544
562
  pi.on('session_shutdown', async (event, ctx) => {
545
563
  const reason = claudeSpelling(SESSION_END_REASON, event.reason)
546
- const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
564
+ // SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
565
+ // slow hook cannot stall session exit, /new or /resume.
566
+ const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
567
+ const runner = boundRunner(ctx)
568
+ const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
547
569
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
548
570
  // Claude kills async hooks still running at teardown; the session that spawned
549
571
  // these is over, and their delivery would target a disposed context anyway.
@@ -20,6 +20,12 @@ import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
20
20
  // raise their own per-hook `timeout`.
21
21
  const DEFAULT_TIMEOUT_S = 60
22
22
 
23
+ /** Claude's per-type defaults where they are safe to mirror: 30s for `prompt`
24
+ * hooks and 60s for `agent` hooks. Command/http/mcp_tool keep the flat 60s
25
+ * documented divergence from Claude's 600 (a gated hook fails closed here, so ten
26
+ * minutes of default budget would wedge the turn). */
27
+ const TYPE_DEFAULT_TIMEOUT_S: Record<string, number> = { prompt: 30, agent: 60 }
28
+
23
29
  export interface HookRunResult {
24
30
  code: number
25
31
  stdout: string
@@ -49,10 +55,20 @@ export function timeoutMs(command: HookCommand): number {
49
55
  // Non-positive values fall back to the default: a 0ms timer would fire before the
50
56
  // hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
51
57
  const declared = command.timeout
52
- const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
58
+ const fallback = TYPE_DEFAULT_TIMEOUT_S[command.type ?? 'command'] ?? DEFAULT_TIMEOUT_S
59
+ const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : fallback
53
60
  return seconds * 1000
54
61
  }
55
62
 
63
+ /** Claude's SessionEnd budget: hooks share 1.5 seconds so session exit (and /new,
64
+ * /resume) cannot stall on a slow hook; a declared per-hook `timeout` raises the
65
+ * budget to match, up to 60 seconds. */
66
+ export function sessionEndTimeoutMs(command: HookCommand): number {
67
+ const declared = command.timeout
68
+ if (typeof declared === 'number' && declared > 0) return Math.min(declared, 60) * 1000
69
+ return 1500
70
+ }
71
+
56
72
  /** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
57
73
  const MAX_HOOK_OUTPUT = 1_000_000
58
74
 
@@ -217,7 +233,11 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
217
233
  if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
218
234
  // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
219
235
  // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
220
- const prompt = substituteArguments(hook.prompt, payload)
236
+ // Claude: when $ARGUMENTS is not present, the input JSON is appended to the
237
+ // prompt, so the model never evaluates blind.
238
+ const template = hook.prompt ?? ''
239
+ const withInput = template.includes('$ARGUMENTS') ? template : `${template}\n\n$ARGUMENTS`
240
+ const prompt = substituteArguments(withInput, payload)
221
241
  const signal = AbortSignal.timeout(timeoutMs)
222
242
  try {
223
243
  const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
@@ -180,7 +180,9 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
180
180
  const root = path.join(cacheDir, marketplace, pluginDir, version)
181
181
  const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
182
182
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
183
- const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
183
+ // Claude: "{id} is the plugin identifier with characters outside a-z, A-Z, 0-9,
184
+ // _, and - replaced by -", one dash per character, underscores kept.
185
+ const id = qualified.replace(/[^A-Za-z0-9_-]/g, '-')
184
186
  const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
185
187
  return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
186
188
  }
@@ -99,13 +99,34 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
99
99
  */
100
100
  export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
101
101
  const servers = loadConfigFrom(userConfigPaths(home))
102
+ Object.assign(servers, projectRecord(home, cwd).mcpServers ?? {})
103
+ return servers
104
+ }
105
+
106
+ /** The per-project record for `cwd` in ~/.claude.json, or an empty one when the file
107
+ * is missing, invalid, or has no entry for this project. */
108
+ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string, ServerConfig>; disabledMcpServers?: unknown } {
102
109
  try {
103
110
  const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
104
- Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
111
+ return claudeJson.projects?.[cwd] ?? {}
105
112
  } catch {
106
- // missing or invalid ~/.claude.json: the top-level user servers already loaded
113
+ return {}
107
114
  }
108
- return servers
115
+ }
116
+
117
+ /** Names the local scope defines for this project. Claude's precedence is local over
118
+ * project over user, so a local name must also outrank a project .mcp.json entry. */
119
+ export function localScopeServerNames(home: string, cwd: string): Set<string> {
120
+ return new Set(Object.keys(projectRecord(home, cwd).mcpServers ?? {}))
121
+ }
122
+
123
+ /** The per-project `disabledMcpServers` toggle list from ~/.claude.json: Claude's /mcp
124
+ * panel records a server toggled off here (an opt-out list for user-configured and
125
+ * plugin servers) and does not connect to it. The `enabledMcpServers` opt-in list
126
+ * covers only default-off built-in servers, which pi-code has none of. */
127
+ export function disabledServerNames(home: string, cwd: string): Set<string> {
128
+ const listed = projectRecord(home, cwd).disabledMcpServers
129
+ return new Set(Array.isArray(listed) ? listed.filter((entry): entry is string => typeof entry === 'string') : [])
109
130
  }
110
131
 
111
132
  /** The mcpServers one plugin declares, parsed WITHOUT substitution: an inline map on
@@ -115,18 +136,22 @@ export function loadUserScope(home: string, cwd: string): Record<string, ServerC
115
136
  * the parse and headersHelper can be shielded. */
116
137
  function rawPluginServerEntries(plugin: InstalledPlugin): Record<string, unknown> {
117
138
  const declared = plugin.manifest.mcpServers
118
- // An inline map of name -> config; an array is not a valid mcpServers map (it
119
- // would register a server named '0'), so it falls through to the path branch.
139
+ // An inline map of name -> config. The manifest field is string|array|object per
140
+ // Claude's plugin reference; an array lists config file paths, merged in order.
120
141
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
121
142
  return { ...(declared as Record<string, unknown>) }
122
143
  }
123
- const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
124
- try {
125
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
126
- return parsed.mcpServers ?? {}
127
- } catch {
128
- return {}
144
+ const paths = Array.isArray(declared) ? declared.filter((entry): entry is string => typeof entry === 'string') : [typeof declared === 'string' ? declared : '.mcp.json']
145
+ const servers: Record<string, unknown> = {}
146
+ for (const entry of paths) {
147
+ try {
148
+ const parsed = JSON.parse(fs.readFileSync(path.resolve(plugin.root, entry), 'utf-8'))
149
+ Object.assign(servers, parsed.mcpServers ?? {})
150
+ } catch {
151
+ // Malformed or missing JSON contributes no entries.
152
+ }
129
153
  }
154
+ return servers
130
155
  }
131
156
 
132
157
  /** Every string in the value mapped through `substitute`, arrays and objects walked. */
@@ -169,7 +194,8 @@ function substitutePathPluginVars(text: string, plugin: InstalledPlugin): string
169
194
  * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
170
195
  * Claude scopes them. */
171
196
  export function loadPluginServers(plugins: InstalledPlugin[], projectDir?: string): Record<string, ServerConfig> {
172
- const fold = (name: string): string => name.replaceAll('-', '_')
197
+ // Claude keeps hyphens in the alias; only characters outside A-Za-z0-9_- fold to _.
198
+ const fold = (name: string): string => name.replace(/[^A-Za-z0-9_-]/g, '_')
173
199
  const servers: Record<string, ServerConfig> = {}
174
200
  for (const plugin of plugins) {
175
201
  for (const [name, config] of Object.entries(rawPluginServerEntries(plugin))) {
@@ -43,11 +43,11 @@ import { installedPlugins } from '../internal/plugins.js'
43
43
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
44
44
  import { repoRoot } from '../internal/project-root.js'
45
45
  import { claudeSettingsChain } from '../internal/settings-chain.js'
46
- import { loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
46
+ import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
47
47
  import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
48
48
  import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
49
49
  import { applyServerPolicy, loadManagedMcpServers, type McpPolicy, mcpAllowDeny, projectServerPolicy, splitByPolicy } from './policy.js'
50
- import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, withTimeout } from './transport.js'
50
+ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeoutMs, type ServerCallTuning, serverCallTuning, withTimeout } from './transport.js'
51
51
 
52
52
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
53
53
  // Re-exports for consumers: the module split keeps the extension's public surface
@@ -84,11 +84,19 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
84
84
  export default async function mcpExtension(pi: ExtensionAPI) {
85
85
  const clients = new Map<string, Client>()
86
86
  const status = new Map<string, { state: string; tools: number }>()
87
+ // Config per server name, kept for call-time timeout tuning: the idle tier follows
88
+ // the transport kind, and a declared per-server timeout governs the wall budget.
89
+ const serverConfigs = new Map<string, ServerConfig>()
90
+ const callTuning = (name: string): ServerCallTuning => {
91
+ const config = serverConfigs.get(name)
92
+ return config ? serverCallTuning(config) : {}
93
+ }
87
94
  // Let other extensions (hooks' mcp_tool type) call a connected server's tool.
88
95
  setMcpToolCaller(async (server, tool, input) => {
89
96
  const client = clients.get(server)
90
97
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
91
- const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
98
+ const tuning = callTuning(server)
99
+ const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(tuning.serverTimeoutMs ?? callTimeoutMs(), tuning))
92
100
  const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
93
101
  .filter((part): part is { type: 'text'; text: string } => part.type === 'text')
94
102
  .map((part) => part.text)
@@ -138,9 +146,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
138
146
  // own default request timeout is 60s and would otherwise reject first. The outer
139
147
  // race uses the wall budget, never the idle window, so a progressing call is not
140
148
  // cut off at the idle timeout.
141
- const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
142
- const wall = declared ?? callTimeoutMs()
143
- const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
149
+ const tuning = serverCallTuning(config)
150
+ const wall = tuning.serverTimeoutMs ?? callTimeoutMs()
151
+ const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall, tuning)), wall, toolName)
144
152
  const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
145
153
  const details: { error?: string } = {}
146
154
  if (result.isError) {
@@ -193,7 +201,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
193
201
  const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
194
202
  if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
195
203
  const wall = callTimeoutMs()
196
- const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
204
+ const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall, callTuning(name))), wall, commandName)
197
205
  // The prompt drives a turn exactly the way a custom slash command does
198
206
  // (see commands.ts), carrying its image blocks through. A prompt that
199
207
  // yields no content is reported rather than sent as an empty turn.
@@ -280,7 +288,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
280
288
  const client = clients.get(server)
281
289
  if (!client) throw new Error(`MCP server "${server}" is not connected`)
282
290
  const wall = callTimeoutMs()
283
- const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
291
+ const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall, callTuning(server))), wall, `read ${uri}`)
284
292
  const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
285
293
  if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
286
294
  if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
@@ -341,6 +349,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
341
349
  // Seed in config order before connecting: parallel connects settle in completion
342
350
  // order, and /mcp plus the session summary iterate the map's insertion order.
343
351
  status.set(name, { state: 'connecting', tools: 0 })
352
+ serverConfigs.set(name, config)
344
353
  pending.push([name, config])
345
354
  }
346
355
  await Promise.all(
@@ -424,9 +433,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
424
433
  * or whose transport dropped, without duplicate-name warnings. */
425
434
  async function connectNormalScopes(ctx: ExtensionContext, policy: McpPolicy, authUi?: AuthUi): Promise<void> {
426
435
  // Plugin servers merge under the user scope (plugins are user-installed);
427
- // the user's own entry wins a name clash with a plugin's.
436
+ // the user's own entry wins a name clash with a plugin's. A server toggled off
437
+ // in ~/.claude.json's per-project disabledMcpServers list never connects.
428
438
  const pluginServers = loadPluginServers(installedPlugins(os.homedir()), repoRoot(ctx.cwd) ?? ctx.cwd)
429
- const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, policy)
439
+ const disabled = disabledServerNames(os.homedir(), ctx.cwd)
440
+ const merged = Object.fromEntries(Object.entries({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }).filter(([name]) => !disabled.has(name)))
441
+ const scoped = applyServerPolicy(merged, policy)
430
442
  // Claude's precedence is project over user for a duplicate name. A project .mcp.json
431
443
  // server only outranks the user's own when it will actually connect (the user already
432
444
  // consented to it, or an approved project's), so a merely-present untrusted project
@@ -436,7 +448,12 @@ export default async function mcpExtension(pi: ExtensionAPI) {
436
448
  // The stored project decision, read without prompting: consent recorded inside
437
449
  // the project only counts once the project itself has been approved.
438
450
  const projectPolicy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
439
- const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
451
+ const { consented: consentedRaw, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy), projectPolicy)
452
+ // Claude's scope precedence is local over project: a name the local scope defines
453
+ // stays with the local (user-side) definition, so the project's entry is dropped
454
+ // here rather than allowed to shadow it.
455
+ const localNames = localScopeServerNames(os.homedir(), ctx.cwd)
456
+ const consented = Object.fromEntries(Object.entries(consentedRaw).filter(([name]) => !localNames.has(name)))
440
457
  const projectWinners = new Set(Object.keys(consented))
441
458
  const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
442
459
  // The consented project servers carry no ordering dependency on the user scope:
@@ -5,19 +5,19 @@
5
5
  */
6
6
 
7
7
  import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent'
8
- import { splitArgs } from '../internal/command-file.js'
9
8
  import { capForContext } from '../internal/output-guard.js'
10
9
 
11
10
  export function formatToolName(server: string, tool: string): string {
12
11
  return `${server}_${tool}`.replaceAll('-', '_')
13
12
  }
14
13
 
15
- /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands. Both
16
- * names normalize like formatToolName, extended to spaces: dashes and spaces each
17
- * become an underscore. */
14
+ /** Claude exposes server prompts as /mcp__<server>__<prompt> slash commands: any
15
+ * character in the server name outside A-Za-z0-9_- becomes an underscore (hyphens
16
+ * stay), and the prompt name is used as the server declares it. Divergence: pi
17
+ * dispatches commands on the first whitespace-delimited token, so whitespace in the
18
+ * prompt name also folds to an underscore or the command would be unreachable. */
18
19
  export function formatPromptCommandName(server: string, prompt: string): string {
19
- const normalize = (name: string): string => name.replace(/[\s-]/g, '_')
20
- return `mcp__${normalize(server)}__${normalize(prompt)}`
20
+ return `mcp__${server.replace(/[^A-Za-z0-9_-]/g, '_')}__${prompt.replace(/\s/g, '_')}`
21
21
  }
22
22
 
23
23
  export interface McpPromptArgumentInfo {
@@ -32,17 +32,16 @@ export interface McpPromptInfo {
32
32
  arguments?: McpPromptArgumentInfo[]
33
33
  }
34
34
 
35
- /** Claude passes prompt arguments space-separated after the command. Tokens map
36
- * positionally onto the declared arguments, split the way slash-command args are
37
- * (quoted runs stay together); the last declared argument absorbs any trailing
38
- * tokens so free text at the end is not silently dropped. Declared arguments with
39
- * no token are omitted, and the server enforces its own `required`. */
35
+ /** Claude passes prompt arguments space-separated after the command and "splits the
36
+ * arguments on whitespace, so each argument is a single token": no quote handling,
37
+ * one token per declared argument, extra trailing tokens dropped. Declared arguments
38
+ * with no token are omitted, and the server enforces its own `required`. */
40
39
  export function mapPromptArguments(declared: ReadonlyArray<{ name: string }> | undefined, args: string): Record<string, string> {
41
- const tokens = splitArgs(args)
40
+ const tokens = args.split(/\s+/).filter((token) => token !== '')
42
41
  const names = (declared ?? []).map((argument) => argument.name)
43
42
  const mapped: Record<string, string> = {}
44
43
  for (let index = 0; index < names.length && index < tokens.length; index++) {
45
- mapped[names[index]] = index === names.length - 1 ? tokens.slice(index).join(' ') : tokens[index]
44
+ mapped[names[index]] = tokens[index]
46
45
  }
47
46
  return mapped
48
47
  }
@@ -17,38 +17,61 @@ import { FileOAuthProvider } from '../internal/mcp-oauth.js'
17
17
  import { expandCwd, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
18
18
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
19
19
 
20
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
20
+ // Claude's MCP_TIMEOUT default: 30 seconds per connect attempt.
21
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
21
22
  // Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
22
23
  // is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
23
24
  // so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
24
25
  const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
25
26
  // The idle timeout: the longest a call may go with no response or progress before it is
26
- // abandoned. Claude uses a separate idle guard (minutes) rather than the hours-long
27
- // wall-clock budget; the SDK resets this window on every progress notification.
27
+ // abandoned. Claude uses a separate idle guard rather than the hours-long wall-clock
28
+ // budget, defaulting to five minutes for remote transports and 30 minutes for stdio
29
+ // servers; the SDK resets this window on every progress notification.
28
30
  const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
31
+ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
32
+
33
+ /** Claude's numeric env vars accept scientific notation and digit-separator spellings
34
+ * (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
35
+ function parseNumericEnv(raw: string): number | undefined {
36
+ const cleaned = raw.replaceAll('_', '')
37
+ if (cleaned.trim() === '') return undefined
38
+ const value = Number(cleaned)
39
+ return Number.isFinite(value) ? Math.floor(value) : undefined
40
+ }
29
41
 
30
42
  /** A positive-integer env override, or the default when unset or unparseable. */
31
43
  function envTimeout(name: string, fallback: number): number {
32
44
  const raw = process.env[name]
33
45
  if (raw === undefined) return fallback
34
- const value = Number.parseInt(raw, 10)
35
- return Number.isInteger(value) && value > 0 ? value : fallback
46
+ const value = parseNumericEnv(raw)
47
+ return value !== undefined && value > 0 ? value : fallback
36
48
  }
37
49
 
38
50
  // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
39
51
  export const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
40
52
  export const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
41
53
 
54
+ /** Per-server inputs to the idle-window choice: the transport kind picks the default
55
+ * tier, and a per-server `timeout` of at least 1000 also floors the idle window. */
56
+ export interface ServerCallTuning {
57
+ stdio?: boolean
58
+ serverTimeoutMs?: number
59
+ }
60
+
42
61
  /** The idle timeout in ms: the longest a call may go with no response or progress before
43
- * it is abandoned, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
44
- * (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
45
- * "disabled" rather than falling back to the default. */
46
- function idleTimeoutMs(): number {
62
+ * it is abandoned. Defaults to Claude's tiers (five minutes remote, 30 minutes stdio),
63
+ * overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it (leaving only
64
+ * the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as "disabled"
65
+ * rather than falling back to the default. A per-server timeout of at least 1000 floors
66
+ * the enabled window, so a server granted a long wall budget is not idled out earlier. */
67
+ function idleTimeoutMs(tuning: ServerCallTuning): number {
47
68
  const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
48
- if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
49
- const value = Number.parseInt(raw, 10)
50
- if (value === 0) return 0
51
- return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
69
+ const override = raw === undefined ? undefined : parseNumericEnv(raw)
70
+ if (override === 0) return 0
71
+ const tierDefault = tuning.stdio ? DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS : DEFAULT_CALL_IDLE_TIMEOUT_MS
72
+ const base = override !== undefined && override > 0 ? override : tierDefault
73
+ const floor = tuning.serverTimeoutMs !== undefined && tuning.serverTimeoutMs >= 1000 ? tuning.serverTimeoutMs : 0
74
+ return Math.max(base, floor)
52
75
  }
53
76
 
54
77
  /** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
@@ -60,12 +83,20 @@ function idleTimeoutMs(): number {
60
83
  * wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
61
84
  * backstop and must be raced against `wall`, never the idle window, so a legitimately
62
85
  * progressing call is not cut off. */
63
- export function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
64
- const idle = idleTimeoutMs()
86
+ export function callRequestOptions(wall: number, tuning: ServerCallTuning = {}): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
87
+ const idle = idleTimeoutMs(tuning)
65
88
  if (idle === 0 || idle >= wall) return { timeout: wall }
66
89
  return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
67
90
  }
68
91
 
92
+ /** The tuning one server's config yields: its transport kind, and its declared
93
+ * per-server timeout. Per Claude, timeout values below 1000 are ignored and fall
94
+ * through to MCP_TOOL_TIMEOUT. */
95
+ export function serverCallTuning(config: ServerConfig): ServerCallTuning {
96
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
97
+ return { stdio: isStdio(config), ...(declared !== undefined ? { serverTimeoutMs: declared } : {}) }
98
+ }
99
+
69
100
  /** Claude reports a config entry that has a url but no type as an error; pi-code
70
101
  * still connects (streamable HTTP with SSE fallback) but says the entry is wrong. */
71
102
  /** An inline bearerToken (interpolated) wins over bearerTokenEnv, which names an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.24",
3
+ "version": "1.0.26",
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, and subagents",
5
5
  "keywords": [
6
6
  "pi",