pi-code 1.0.13 → 1.0.14

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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The hook runners: one per hook type (shell command incl. exec-form, http POST,
3
+ * in-process prompt, mcp_tool call, agent subagent), plus the timeout budget and
4
+ * the process-group kill used by the shell runner.
5
+ */
6
+
7
+ import { type ChildProcess, spawn } from 'node:child_process'
8
+ import type { Api, Model } from '@earendil-works/pi-ai'
9
+ import { runAgent } from '../internal/agent-run.js'
10
+ import { callMcpTool } from '../internal/mcp-call.js'
11
+ import { completeText } from '../internal/model-complete.js'
12
+ import { type HookCommand, httpUrlAllowed } from './config.js'
13
+
14
+ // Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
15
+ // PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
16
+ // to fall back on), so ten minutes of default budget would wedge the turn for
17
+ // ten minutes on a hung hook. Hooks that legitimately run long can raise their
18
+ // own per-hook `timeout`.
19
+ const DEFAULT_TIMEOUT_S = 60
20
+
21
+ export interface HookRunResult {
22
+ code: number
23
+ stdout: string
24
+ stderr: string
25
+ /** The hook was killed at its timeout, so its exit code carries no verdict. */
26
+ timedOut: boolean
27
+ /** The process errored before delivering a verdict (spawn failure, EIO). */
28
+ spawnFailed?: boolean
29
+ }
30
+ /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
31
+ export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
32
+ /** The shell path specifically; the statusline reuses it for its own command. With an
33
+ * `args` array it becomes the exec path: `command` is spawned directly with those args. */
34
+ export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
35
+
36
+ /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
37
+ const MAX_TIMEOUT_S = 2_147_483
38
+
39
+ export function timeoutMs(command: HookCommand): number {
40
+ // Non-positive values fall back to the default: a 0ms timer would fire before the
41
+ // hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
42
+ const declared = command.timeout
43
+ const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
44
+ return seconds * 1000
45
+ }
46
+
47
+ /** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
48
+ const MAX_HOOK_OUTPUT = 1_000_000
49
+
50
+ /** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
51
+ const TIMEOUT_EXIT_CODE = 124
52
+
53
+ /**
54
+ * Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
55
+ * direct child alone leaves a grandchild alive holding stdout/stderr.
56
+ */
57
+ function killTree(child: ChildProcess): void {
58
+ try {
59
+ // Negative pid targets the whole process group, which `detached` gave the shell.
60
+ if (child.pid) {
61
+ process.kill(-child.pid, 'SIGKILL')
62
+ return
63
+ }
64
+ } catch {
65
+ // Group already reaped, or the platform refused it; fall through to the direct kill.
66
+ }
67
+ child.kill('SIGKILL')
68
+ }
69
+
70
+ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
71
+ new Promise((resolve) => {
72
+ // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
73
+ // `detached` makes the shell its own process group leader so the timeout can kill
74
+ // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
75
+ // reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
76
+ // subprocess Claude spawns, so it is set on the child unconditionally.
77
+ const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
78
+ if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
79
+ // An exec-form hook (an `args` array) spawns the executable directly with those args
80
+ // and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
81
+ // each arg is replaced with the event JSON by a replacer function (so $$/$& in the
82
+ // payload survive verbatim). Without args it stays the shell path. Both share the
83
+ // same detached process group, so killTree reaches the descendants either way.
84
+ const file = Array.isArray(args) ? command : '/bin/sh'
85
+ const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
86
+ const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
87
+ let stdout = ''
88
+ let stderr = ''
89
+ let settled = false
90
+ const finish = (result: HookRunResult): void => {
91
+ if (settled) return
92
+ settled = true
93
+ clearTimeout(timer)
94
+ resolve(result)
95
+ }
96
+ // Resolve from the timer itself rather than waiting for `close`: `close` fires only
97
+ // once every stdio pipe is closed, and a grandchild that inherited them can hold the
98
+ // promise pending long past the timeout, stalling the tool call that awaits it.
99
+ const timer = setTimeout(() => {
100
+ killTree(child)
101
+ finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
102
+ }, timeoutMs)
103
+ // Decode on the stream: concatenating Buffers as strings mangles a multi-byte
104
+ // character split across chunks, and a mangled byte in a hook's deny decision makes
105
+ // it unparseable, which reads as an allow.
106
+ child.stdout?.setEncoding('utf8')
107
+ child.stderr?.setEncoding('utf8')
108
+ child.stdout?.on('data', (chunk: string) => {
109
+ if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
110
+ })
111
+ child.stderr?.on('data', (chunk: string) => {
112
+ if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
113
+ })
114
+ child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
115
+ // Marked rather than silently read as a clean run: under fd exhaustion a
116
+ // deny-list guard that never spawned would otherwise pass as an allow.
117
+ child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
118
+ // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
119
+ // so ignore EPIPE on this write rather than crashing the host process.
120
+ child.stdin?.on('error', () => {})
121
+ child.stdin?.end(JSON.stringify(payload))
122
+ })
123
+
124
+ /** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
125
+ * to an unlisted variable becomes an empty string, as Claude documents. */
126
+ function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
127
+ const allowedSet = new Set(allowed ?? [])
128
+ const out: Record<string, string> = {}
129
+ for (const [key, value] of Object.entries(headers ?? {})) {
130
+ out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
131
+ const name = braced ?? bare ?? ''
132
+ return allowedSet.has(name) ? (process.env[name] ?? '') : ''
133
+ })
134
+ }
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
140
+ * with a valid JSON body renders a decision, read exactly like command stdout.
141
+ * Everything else, including non-2xx statuses, connection failures and timeouts,
142
+ * is a non-blocking error by contract, so none of these outcomes ever reports
143
+ * `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
144
+ * allowlist gates the fetch itself: a URL matching no entry is never contacted,
145
+ * so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
146
+ * the payload; when the setting is absent there are no restrictions, as Claude
147
+ * documents. A blocked hook renders no decision, like every other http failure.
148
+ */
149
+ export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
150
+ const url = hook.url ?? hook.command
151
+ if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
152
+ try {
153
+ const response = await fetch(url, {
154
+ method: 'POST',
155
+ headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
156
+ body: JSON.stringify(payload),
157
+ signal: AbortSignal.timeout(timeoutMs),
158
+ })
159
+ const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
160
+ if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
161
+ if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
162
+ try {
163
+ JSON.parse(body)
164
+ } catch {
165
+ return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
166
+ }
167
+ return { code: 0, stdout: body, stderr: '', timedOut: false }
168
+ } catch (error) {
169
+ return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
170
+ }
171
+ }
172
+
173
+ /** System prompt turning a prompt hook into a structured decision, so its reply
174
+ * flows through interpretHookResult exactly like a command hook's stdout. */
175
+ const PROMPT_HOOK_SYSTEM = [
176
+ 'You are a Claude Code hook evaluating whether an action should proceed.',
177
+ 'Respond with ONLY a JSON object and nothing else:',
178
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
179
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
180
+ ].join('\n')
181
+
182
+ /**
183
+ * Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
184
+ * event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
185
+ * in-process via completeText and returns the reply as stdout so the existing
186
+ * decision parser handles it. No model (headless) or a provider error is
187
+ * non-blocking; only an abort at the timeout fails closed, like the other hooks.
188
+ */
189
+ /** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
190
+ * in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
191
+ * `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
192
+ function substituteArguments(prompt: string | undefined, payload: unknown): string {
193
+ const json = JSON.stringify(payload)
194
+ return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
195
+ }
196
+
197
+ /** Classify a model/agent failure: the deadline is authoritative via the signal (the
198
+ * subagent runner rejects with a plain Error on abort, so an error-name check alone
199
+ * fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
200
+ * produced no verdict and is non-blocking. */
201
+ function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
202
+ const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
203
+ return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
204
+ }
205
+
206
+ export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
207
+ if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
208
+ // A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
209
+ // verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
210
+ const prompt = substituteArguments(hook.prompt, payload)
211
+ const signal = AbortSignal.timeout(timeoutMs)
212
+ try {
213
+ const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
214
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
215
+ } catch (error) {
216
+ return abortAwareFailure(signal, error)
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
222
+ * and treat its text output like command stdout. pi reaches the server through the
223
+ * mcp-call seam the mcp extension registers. Like http, it never fails closed: a
224
+ * missing server, a tool error, or the deadline is non-blocking.
225
+ */
226
+ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
227
+ if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
228
+ const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
229
+ let timer: ReturnType<typeof setTimeout> | undefined
230
+ const deadline = new Promise<HookRunResult>((resolve) => {
231
+ timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
232
+ })
233
+ const call = callMcpTool(hook.server, hook.tool, input)
234
+ .then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
235
+ .catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
236
+ try {
237
+ return await Promise.race([call, deadline])
238
+ } finally {
239
+ // Left running, the deadline timer pins the event loop for the full timeout
240
+ // after the call resolves, delaying exit in a one-shot headless run.
241
+ clearTimeout(timer)
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
247
+ * verify a condition, then return its final text as a JSON decision, parsed by the
248
+ * same interpreter as a command hook. pi reaches the subagent through the agent-run
249
+ * seam the subagent extension registers. Like the prompt hook, only an abort at the
250
+ * deadline fails closed; a missing runner or a crashed agent is non-blocking.
251
+ */
252
+ export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
253
+ const prompt = substituteArguments(hook.prompt, payload)
254
+ const signal = AbortSignal.timeout(timeoutMs)
255
+ try {
256
+ const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
257
+ return { code: 0, stdout: answer, stderr: '', timedOut: false }
258
+ } catch (error) {
259
+ return abortAwareFailure(signal, error)
260
+ }
261
+ }
@@ -107,7 +107,7 @@ export function normalizeToolName(name: string): string {
107
107
  * Scanned rather than matched with a regex: the pattern form is quadratic on an input
108
108
  * of unclosed parens, and a command file comes from the repository.
109
109
  */
110
- export function toolEntries(raw: string): string[] {
110
+ function toolEntries(raw: string): string[] {
111
111
  const entries: string[] = []
112
112
  let current = ''
113
113
  let depth = 0
@@ -26,6 +26,11 @@ export function setManagedSettingsPath(file?: string): void {
26
26
  managedSettingsFileOverride = file
27
27
  }
28
28
 
29
+ /** The managed-settings.json path in effect, honoring the test-seam override. */
30
+ export function managedSettingsFile(): string {
31
+ return managedSettingsFileOverride ?? managedSettingsPath()
32
+ }
33
+
29
34
  /** The parsed managed settings object, or {} when absent or malformed. */
30
35
  export function readManagedSettings(file: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, unknown> {
31
36
  try {
@@ -19,7 +19,7 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
19
19
  * Shorter input comes back whole and a negative budget yields nothing, so callers
20
20
  * need no length check of their own.
21
21
  */
22
- export function sliceBytes(text: string, maxBytes: number): string {
22
+ function sliceBytes(text: string, maxBytes: number): string {
23
23
  return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)).toString('utf-8')
24
24
  }
25
25
 
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The shared Claude settings chain: the ordered settings.json files a home-and-project
3
+ * setting is read from, newest winning. User settings always lead; the project's
4
+ * settings.json and settings.local.json (each the nearest of its name at or above cwd,
5
+ * falling back to cwd's own `.claude/`) follow only when the project is included, the
6
+ * trust gate every caller applies. Hooks, output styles, memory, the CLAUDE.md excludes,
7
+ * and the skill-shell policy all resolve their files through this one chain.
8
+ */
9
+
10
+ import * as path from 'node:path'
11
+ import { claudeConfigDir } from './config-dir.js'
12
+ import { findNearestFile } from './project-root.js'
13
+
14
+ /** The user settings.json, then (only when `includeProject`) the nearest project
15
+ * settings.json and settings.local.json at or above cwd, with cwd's own `.claude/` as
16
+ * the fallback for each. Later files win. */
17
+ export function claudeSettingsChain(cwd: string, home: string, includeProject: boolean): string[] {
18
+ const files = [path.join(claudeConfigDir(home), 'settings.json')]
19
+ if (!includeProject) return files
20
+ for (const name of ['settings.json', 'settings.local.json']) {
21
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
22
+ }
23
+ return files
24
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A cheap freshness token for a file: its mtime and size joined, so a same-mtime rewrite
3
+ * of a different length still invalidates. The per-turn caches that stat a file instead of
4
+ * re-reading it (the CLAUDE.md import memo, the memory index cache) compare this token.
5
+ * Throws when the file cannot be stat'd; callers that treat "missing" as a value catch it.
6
+ */
7
+
8
+ import * as fs from 'node:fs'
9
+
10
+ /** `${mtimeMs}:${size}` for `file`. Throws if the file cannot be stat'd. */
11
+ export function statToken(file: string): string {
12
+ const stat = fs.statSync(file)
13
+ return `${stat.mtimeMs}:${stat.size}`
14
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A per-turn value override: capture the prior value once, restore it when the turn
3
+ * settles. This is the shape shared by commands.ts's `model:`/`effort:` overrides and
4
+ * thinking.ts's keyword escalation. Each captures what to restore the first time a turn
5
+ * overrides (so back-to-back overrides in one turn still restore the original session
6
+ * value, not an intermediate one), drops that capture without restoring at a session
7
+ * boundary, and restores on settle.
8
+ *
9
+ * A conditional override restores only when the current value still equals the target it
10
+ * moved to, so a later owner (another extension's restore, a manual change) is not
11
+ * clobbered; a current that cannot be read restores unconditionally, the best-effort the
12
+ * consumers relied on. The escalating set itself stays at the call site, since who reads
13
+ * "current" and how the value is applied (sync, async-with-catch) differ per consumer.
14
+ */
15
+
16
+ export interface TurnOverride<T> {
17
+ /** The value to restore, captured only on the first arm of a turn; undefined when
18
+ * nothing is armed. */
19
+ readonly prior: T | undefined
20
+ /** The value last armed as the move target, for a conditional restore. */
21
+ readonly target: T | undefined
22
+ /** Record the value to restore (kept only the first time per turn) and, optionally, the
23
+ * value moved to for a conditional restore. Does not apply the override. */
24
+ arm(prior: T, target?: T): void
25
+ /** Drop the pending capture without restoring: a session boundary. */
26
+ reset(): void
27
+ /** Restore the captured prior via `set` and clear the capture. A conditional override
28
+ * stands down when the current value has moved off the target. No-op when nothing is
29
+ * armed. */
30
+ settle(): void
31
+ }
32
+
33
+ export function createTurnOverride<T>(opts: {
34
+ /** Applies the restore value. Consumers wrap their setter here (async-with-catch for
35
+ * commands' model, plain for the thinking level). */
36
+ set: (value: T) => void
37
+ /** Reads the current value, for a conditional restore. Only consulted when
38
+ * `conditional` is set. */
39
+ get?: () => T | undefined
40
+ /** When set, restore only if the current value still equals the armed target. */
41
+ conditional?: boolean
42
+ }): TurnOverride<T> {
43
+ let prior: T | undefined
44
+ let target: T | undefined
45
+ return {
46
+ get prior() {
47
+ return prior
48
+ },
49
+ get target() {
50
+ return target
51
+ },
52
+ arm(priorValue, targetValue) {
53
+ prior = prior ?? priorValue
54
+ target = targetValue
55
+ },
56
+ reset() {
57
+ prior = undefined
58
+ target = undefined
59
+ },
60
+ settle() {
61
+ if (prior === undefined) return
62
+ const restore = prior
63
+ const movedTo = target
64
+ prior = undefined
65
+ target = undefined
66
+ if (opts.conditional) {
67
+ const current = opts.get?.()
68
+ if (current !== undefined && current !== movedTo) return
69
+ }
70
+ opts.set(restore)
71
+ },
72
+ }
73
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * MCP server configuration: the config types, ${VAR} interpolation, the user and
3
+ * project config paths and their loading, and the servers declared by plugins.
4
+ */
5
+
6
+ import * as fs from 'node:fs'
7
+ import * as os from 'node:os'
8
+ import * as path from 'node:path'
9
+ import { claudeConfigDir } from '../internal/config-dir.js'
10
+ import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
11
+ import { findNearestFile } from '../internal/project-root.js'
12
+
13
+ export interface StdioServerConfig {
14
+ type?: 'stdio'
15
+ command: string
16
+ args?: string[]
17
+ env?: Record<string, string>
18
+ cwd?: string
19
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
20
+ timeout?: number
21
+ /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
22
+ aliasPrefix?: string
23
+ }
24
+
25
+ export interface HttpServerConfig {
26
+ type?: 'http' | 'streamable-http' | 'sse' | 'ws' | 'websocket'
27
+ url: string
28
+ headers?: Record<string, string>
29
+ bearerToken?: string
30
+ bearerTokenEnv?: string
31
+ /** A command whose JSON stdout is merged into the connect headers, for auth
32
+ * schemes other than OAuth/static tokens (Claude's headersHelper). */
33
+ headersHelper?: string
34
+ /** Per-call wall-clock budget in ms, overriding MCP_TOOL_TIMEOUT for this server. */
35
+ timeout?: number
36
+ /** Plugin servers alias their tools mcp__plugin_<plugin>_<server>__<tool>. */
37
+ aliasPrefix?: string
38
+ }
39
+
40
+ export type ServerConfig = StdioServerConfig | HttpServerConfig
41
+
42
+ /** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
43
+ * shell's `:-`, which substitutes when the variable is unset OR empty. */
44
+ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env, onMissing?: (name: string) => void): string {
45
+ return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (fullMatch, name, hasDefault, fallback) => {
46
+ const current = env[name]
47
+ if (hasDefault !== undefined) return current || fallback
48
+ if (current === undefined) {
49
+ // A referenced variable with no value and no default: keep the literal ${VAR} and
50
+ // report it, matching Claude, rather than silently substituting an empty string that
51
+ // turns `Bearer ${TOKEN}` into a confusing `Bearer ` and a mystery 401.
52
+ onMissing?.(name)
53
+ return fullMatch
54
+ }
55
+ return current
56
+ })
57
+ }
58
+
59
+ /** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
60
+ * When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
61
+ * default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
62
+ * blank value is treated as unset, matching claudeConfigDir. */
63
+ function claudeJsonPath(home: string): string {
64
+ const override = process.env.CLAUDE_CONFIG_DIR
65
+ return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
66
+ }
67
+
68
+ /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
69
+ * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
70
+ export function userConfigPaths(home: string): string[] {
71
+ return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
72
+ }
73
+
74
+ /** Project-scoped MCP config, each file the nearest of its name at or above cwd
75
+ * (bounded at the repository root, matching the approval walk). Loaded only for
76
+ * trusted projects: a server's `command` runs on connect. */
77
+ export function projectConfigPaths(cwd: string): string[] {
78
+ return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
79
+ }
80
+
81
+ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
82
+ const servers: Record<string, ServerConfig> = {}
83
+ for (const file of files) {
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
86
+ Object.assign(servers, parsed.mcpServers ?? {})
87
+ } catch {
88
+ // missing or invalid file: skip silently, /mcp reports what loaded
89
+ }
90
+ }
91
+ return servers
92
+ }
93
+
94
+ /**
95
+ * All user-owned servers for this session: the global user servers plus Claude's "local"
96
+ * scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
97
+ * Both are the user's own config, so neither needs project trust; local wins on a name
98
+ * clash (Claude's precedence is local over user).
99
+ */
100
+ export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
101
+ const servers = loadConfigFrom(userConfigPaths(home))
102
+ try {
103
+ const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
104
+ Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
105
+ } catch {
106
+ // missing or invalid ~/.claude.json: the top-level user servers already loaded
107
+ }
108
+ return servers
109
+ }
110
+
111
+ /** The mcpServers one plugin declares: an inline map on the manifest, or the file it
112
+ * points to (default .mcp.json at the plugin root), with ${CLAUDE_PLUGIN_*} substituted
113
+ * before parsing. Malformed or missing JSON yields no entries. */
114
+ function pluginServerEntries(plugin: InstalledPlugin): Record<string, ServerConfig> {
115
+ const declared = plugin.manifest.mcpServers
116
+ // An inline map of name -> config; an array is not a valid mcpServers map (it
117
+ // would register a server named '0'), so it falls through to the path branch.
118
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
119
+ try {
120
+ return JSON.parse(substitutePluginVars(JSON.stringify(declared), plugin))
121
+ } catch {
122
+ return {}
123
+ }
124
+ }
125
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : '.mcp.json')
126
+ try {
127
+ const parsed = JSON.parse(substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin))
128
+ return parsed.mcpServers ?? {}
129
+ } catch {
130
+ return {}
131
+ }
132
+ }
133
+
134
+ /** Servers shipped by enabled plugins (.mcp.json or the manifest's `mcpServers`,
135
+ * inline or by path), with ${CLAUDE_PLUGIN_*} substituted before parsing. Their
136
+ * tools alias as mcp__plugin_<plugin>_<server>__<tool> for hook matchers, as
137
+ * Claude scopes them. */
138
+ export function loadPluginServers(plugins: InstalledPlugin[]): Record<string, ServerConfig> {
139
+ const fold = (name: string): string => name.replaceAll('-', '_')
140
+ const servers: Record<string, ServerConfig> = {}
141
+ for (const plugin of plugins) {
142
+ for (const [name, config] of Object.entries(pluginServerEntries(plugin))) {
143
+ servers[name] = { ...config, aliasPrefix: `mcp__plugin_${fold(plugin.name)}_${fold(name)}__` }
144
+ }
145
+ }
146
+ return servers
147
+ }
148
+
149
+ /** A server cwd expands ${VAR} then a leading ~, or stays unset. */
150
+ export function expandCwd(cwd: string | undefined): string | undefined {
151
+ if (!cwd) return undefined
152
+ return interpolateEnv(cwd).replace(/^~(?=\/|$)/, os.homedir())
153
+ }
154
+
155
+ export function warnOnTypelessUrl(name: string, config: ServerConfig): void {
156
+ if ('url' in config && config.type === undefined) {
157
+ console.warn(`pi-code-mcp: server ${name} declares a url with no "type"; add "type": "http" or "sse"`)
158
+ }
159
+ }