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.
- package/extensions/hooks/index.ts +25 -3
- package/extensions/hooks/runners.ts +22 -2
- package/extensions/internal/plugins.ts +3 -1
- package/extensions/mcp/config.ts +38 -12
- package/extensions/mcp/index.ts +28 -11
- package/extensions/mcp/mapping.ts +12 -13
- package/extensions/mcp/transport.ts +46 -15
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/extensions/mcp/config.ts
CHANGED
|
@@ -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
|
-
|
|
111
|
+
return claudeJson.projects?.[cwd] ?? {}
|
|
105
112
|
} catch {
|
|
106
|
-
|
|
113
|
+
return {}
|
|
107
114
|
}
|
|
108
|
-
|
|
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
|
|
119
|
-
//
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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))) {
|
package/extensions/mcp/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
142
|
-
const wall =
|
|
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
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 =
|
|
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]] =
|
|
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
|
-
|
|
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
|
|
27
|
-
//
|
|
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 =
|
|
35
|
-
return
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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",
|