pi-code 1.0.9 → 1.0.11
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/claude-rules.ts +2 -1
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +196 -38
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/hooks.ts +92 -11
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/plugins.ts +5 -3
- package/extensions/mcp.ts +146 -24
- package/extensions/memory.ts +86 -2
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +10 -2
- package/extensions/session-title.ts +126 -0
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +94 -1
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +2 -1
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
package/extensions/hooks.ts
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
5
|
* a project's existing hooks work under pi:
|
|
6
|
-
* - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
|
|
7
|
+
* pi `user_bash` for a `!`/`!!` command the user runs directly (the
|
|
8
|
+
* model never issues these, so a deny-list guard would otherwise miss
|
|
9
|
+
* them). No pi tool call exists there, so the payload reports the
|
|
10
|
+
* Claude name "Bash"; a deny hands pi a synthetic failed result so
|
|
11
|
+
* the command never runs. UserBashEvent carries no execution result
|
|
12
|
+
* and fires only before the command runs, so it has no PostToolUse
|
|
13
|
+
* counterpart (pi never delivers the output to observe).
|
|
7
14
|
* - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
|
|
8
15
|
* appended next to the tool result, as Claude documents)
|
|
9
16
|
* - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
|
|
@@ -62,6 +69,7 @@ import * as path from 'node:path'
|
|
|
62
69
|
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
63
70
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
64
71
|
import { runAgent } from './internal/agent-run.js'
|
|
72
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
65
73
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
66
74
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
67
75
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
@@ -83,6 +91,9 @@ const DEFAULT_TIMEOUT_S = 60
|
|
|
83
91
|
interface HookCommand {
|
|
84
92
|
type?: string
|
|
85
93
|
command: string
|
|
94
|
+
/** exec-form: spawn `command` directly with these args and no shell (shell-form when
|
|
95
|
+
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
96
|
+
args?: string[]
|
|
86
97
|
timeout?: number
|
|
87
98
|
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
88
99
|
url?: string
|
|
@@ -122,14 +133,15 @@ export interface HookRunResult {
|
|
|
122
133
|
}
|
|
123
134
|
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
124
135
|
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
125
|
-
/** The shell path specifically; the statusline reuses it for its own command.
|
|
126
|
-
|
|
136
|
+
/** The shell path specifically; the statusline reuses it for its own command. With an
|
|
137
|
+
* `args` array it becomes the exec path: `command` is spawned directly with those args. */
|
|
138
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
|
|
127
139
|
|
|
128
140
|
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
129
141
|
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
130
142
|
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
131
143
|
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
132
|
-
const files = [path.join(home, '
|
|
144
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
133
145
|
if (!trusted) return files
|
|
134
146
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
135
147
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -472,14 +484,23 @@ function killTree(child: ChildProcess): void {
|
|
|
472
484
|
child.kill('SIGKILL')
|
|
473
485
|
}
|
|
474
486
|
|
|
475
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
|
|
487
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
|
|
476
488
|
new Promise((resolve) => {
|
|
477
489
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
478
490
|
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
479
491
|
// the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
480
|
-
// reference project files regardless of the shell's cwd.
|
|
481
|
-
|
|
482
|
-
const
|
|
492
|
+
// reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
|
|
493
|
+
// subprocess Claude spawns, so it is set on the child unconditionally.
|
|
494
|
+
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
|
|
495
|
+
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
496
|
+
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
497
|
+
// and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
|
|
498
|
+
// each arg is replaced with the event JSON by a replacer function (so $$/$& in the
|
|
499
|
+
// payload survive verbatim). Without args it stays the shell path. Both share the
|
|
500
|
+
// same detached process group, so killTree reaches the descendants either way.
|
|
501
|
+
const file = Array.isArray(args) ? command : '/bin/sh'
|
|
502
|
+
const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
|
|
503
|
+
const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
483
504
|
let stdout = ''
|
|
484
505
|
let stderr = ''
|
|
485
506
|
let settled = false
|
|
@@ -673,6 +694,18 @@ export function lastAssistantText(messages: ReadonlyArray<{ role: string; conten
|
|
|
673
694
|
return ''
|
|
674
695
|
}
|
|
675
696
|
|
|
697
|
+
/** Claude overrides a Stop hook after it blocks this many times in a row with no user
|
|
698
|
+
* progress, ending the turn with a warning rather than looping forever. */
|
|
699
|
+
const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
|
|
700
|
+
|
|
701
|
+
/** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
|
|
702
|
+
* is a positive integer, else the default. A non-positive or malformed value falls back
|
|
703
|
+
* to the default rather than capping at zero (which would suppress the very first block). */
|
|
704
|
+
export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
|
|
705
|
+
const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
|
|
706
|
+
return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
707
|
+
}
|
|
708
|
+
|
|
676
709
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
677
710
|
const MAX_TIMEOUT_S = 2_147_483
|
|
678
711
|
|
|
@@ -833,6 +866,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
833
866
|
let allowedHttpHookUrls: string[] | undefined
|
|
834
867
|
let pendingSessionContext: string[] = []
|
|
835
868
|
let stopHookActive = false
|
|
869
|
+
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
870
|
+
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
871
|
+
let stopHookBlockCount = 0
|
|
836
872
|
let sessionCtx: ExtensionContext | undefined
|
|
837
873
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
838
874
|
let hooksDisabled = false
|
|
@@ -856,7 +892,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
856
892
|
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
857
893
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
858
894
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
859
|
-
return runHookCommand(hook.command, merged, ms, projectDir)
|
|
895
|
+
return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
|
|
860
896
|
}
|
|
861
897
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
862
898
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
@@ -992,10 +1028,38 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
992
1028
|
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
993
1029
|
})
|
|
994
1030
|
|
|
1031
|
+
// Claude's PreToolUse for Bash, extended to a command the user runs directly with the
|
|
1032
|
+
// `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
|
|
1033
|
+
// so without this a guard that blocks `git push -f` from the model would not stop the
|
|
1034
|
+
// same command typed by hand. There is no pi tool call, so the matcher sees both pi's
|
|
1035
|
+
// "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
|
|
1036
|
+
// reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
|
|
1037
|
+
// payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
|
|
1038
|
+
// exposes no block flag: a deny is enforced through `result` ("extension handled
|
|
1039
|
+
// execution, use this result"), a synthetic failed BashResult that stands in for the
|
|
1040
|
+
// command so it never runs and its deny reason shows as the output. The event delivers
|
|
1041
|
+
// no execution result and fires only before the command runs, so there is deliberately
|
|
1042
|
+
// no PostToolUse for it.
|
|
1043
|
+
pi.on('user_bash', async (event, ctx) => {
|
|
1044
|
+
const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
|
|
1045
|
+
if (!decision.block) return undefined
|
|
1046
|
+
// Claude's "ask": prompt before running and let the command through on approval; with
|
|
1047
|
+
// no UI (headless) the block stands, the same safe default as the tool_call path.
|
|
1048
|
+
if (decision.ask && ctx.hasUI) {
|
|
1049
|
+
const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
|
|
1050
|
+
if (approved) return undefined
|
|
1051
|
+
}
|
|
1052
|
+
const reason = decision.reason ?? 'Command blocked by hook'
|
|
1053
|
+
return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
|
|
1054
|
+
})
|
|
1055
|
+
|
|
995
1056
|
pi.on('input', async (event, ctx) => {
|
|
996
1057
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
997
1058
|
// prompts the user submitted.
|
|
998
1059
|
if (event.source === 'extension') return { action: 'continue' }
|
|
1060
|
+
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
1061
|
+
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
1062
|
+
stopHookBlockCount = 0
|
|
999
1063
|
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
1000
1064
|
if (decision.block) {
|
|
1001
1065
|
// pi's input result has no reason channel, so surface why before consuming it.
|
|
@@ -1048,8 +1112,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
1048
1112
|
return { block: false, reason: '' }
|
|
1049
1113
|
})
|
|
1050
1114
|
.find((verdict) => verdict.block)
|
|
1051
|
-
|
|
1052
|
-
|
|
1115
|
+
if (!block) {
|
|
1116
|
+
// A non-blocking Stop breaks the streak: the next block starts a fresh count.
|
|
1117
|
+
stopHookActive = false
|
|
1118
|
+
stopHookBlockCount = 0
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
stopHookBlockCount += 1
|
|
1122
|
+
const cap = stopHookBlockCap()
|
|
1123
|
+
if (stopHookBlockCount >= cap) {
|
|
1124
|
+
// Claude overrides a Stop hook that has blocked cap times in a row with no user
|
|
1125
|
+
// progress: suppress the continuation, warn, and let the turn end so the loop cannot
|
|
1126
|
+
// run forever. Reset the count so a later run (or user turn) starts clean.
|
|
1127
|
+
stopHookActive = false
|
|
1128
|
+
stopHookBlockCount = 0
|
|
1129
|
+
ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
stopHookActive = true
|
|
1133
|
+
pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
|
|
1053
1134
|
})
|
|
1054
1135
|
|
|
1055
1136
|
pi.on('session_before_compact', async (event, ctx) => {
|
|
@@ -35,7 +35,16 @@ export interface ParsedCommand {
|
|
|
35
35
|
* command's injected spans run (see spanExec). */
|
|
36
36
|
shell?: string
|
|
37
37
|
model?: string
|
|
38
|
+
/** `effort:` per-command thinking-level override, one of pi's ThinkingLevel values
|
|
39
|
+
* (off/minimal/low/medium/high/xhigh/max); undefined when absent or unrecognized. */
|
|
40
|
+
effort?: string
|
|
41
|
+
/** `when_to_use:` extra trigger text appended to the slash_command tool listing only,
|
|
42
|
+
* never to the user-facing command description. */
|
|
43
|
+
whenToUse?: string
|
|
38
44
|
disableModelInvocation: boolean
|
|
45
|
+
/** `user-invocable:` false hides the command from the slash-command surface while
|
|
46
|
+
* keeping it callable by the model through the slash_command tool. Default true. */
|
|
47
|
+
userInvocable: boolean
|
|
39
48
|
body: string
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -236,6 +245,15 @@ const text = (value: unknown): string => {
|
|
|
236
245
|
const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
|
|
237
246
|
const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
|
|
238
247
|
|
|
248
|
+
/** YAML's negative boolean spellings, the mirror of YAML_TRUE. A flag that defaults to
|
|
249
|
+
* true (user-invocable) is turned off only by one of these; any other value, absent
|
|
250
|
+
* included, leaves it on, so an unrelated string never silently hides a command. */
|
|
251
|
+
const YAML_FALSE = new Set(['false', 'no', 'off', 'n', '0'])
|
|
252
|
+
const isFlagDisabled = (value: unknown): boolean => value === false || YAML_FALSE.has(text(value).toLowerCase())
|
|
253
|
+
|
|
254
|
+
/** pi's ThinkingLevel union, the values a command's `effort:` override may name. */
|
|
255
|
+
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
256
|
+
|
|
239
257
|
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
240
258
|
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
241
259
|
|
|
@@ -265,6 +283,7 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
265
283
|
const disable = frontmatter['disable-model-invocation']
|
|
266
284
|
const grants = parseToolGrants(frontmatter['allowed-tools'])
|
|
267
285
|
const shell = text(frontmatter.shell).toLowerCase()
|
|
286
|
+
const effort = text(frontmatter.effort).toLowerCase()
|
|
268
287
|
return {
|
|
269
288
|
description: text(frontmatter.description) || firstLine.slice(0, 60),
|
|
270
289
|
argumentHint: hint(frontmatter['argument-hint']) || undefined,
|
|
@@ -276,7 +295,11 @@ export function parseCommandFile(content: string): ParsedCommand {
|
|
|
276
295
|
disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
|
|
277
296
|
shell: SHELLS.has(shell) ? shell : undefined,
|
|
278
297
|
model: text(frontmatter.model) || undefined,
|
|
298
|
+
// An unrecognized effort is dropped rather than passed to setThinkingLevel.
|
|
299
|
+
effort: THINKING_LEVELS.has(effort) ? effort : undefined,
|
|
300
|
+
whenToUse: text(frontmatter.when_to_use) || undefined,
|
|
279
301
|
disableModelInvocation: isFlagEnabled(disable),
|
|
302
|
+
userInvocable: !isFlagDisabled(frontmatter['user-invocable']),
|
|
280
303
|
body,
|
|
281
304
|
}
|
|
282
305
|
}
|
|
@@ -453,7 +476,9 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
|
|
|
453
476
|
if (shell === 'powershell') {
|
|
454
477
|
const binary = resolveBinary()
|
|
455
478
|
if (binary !== undefined) {
|
|
456
|
-
|
|
479
|
+
// CLAUDECODE=1 marks every subprocess Claude spawns; pi.exec takes no env, so
|
|
480
|
+
// it is exported in the script alongside CLAUDE_PROJECT_DIR.
|
|
481
|
+
const preamble = `$ErrorActionPreference='Continue'\n$env:CLAUDE_PROJECT_DIR='${powershellQuote(projectDir)}'\n$env:CLAUDECODE='1'`
|
|
457
482
|
// No in-script 2>&1: under pwsh 7 it does not merge a native command's
|
|
458
483
|
// stderr on a script block, so mergeStreams has the caller append it. The
|
|
459
484
|
// trailing exit forwards a failed native command's code, which pwsh
|
|
@@ -470,7 +495,7 @@ export function spanExec(shell: string | undefined, projectDir: string, script:
|
|
|
470
495
|
// comment-only span is a hard sh syntax error (exit 2) that aborted the whole
|
|
471
496
|
// invocation, and `:` keeps such a span the harmless no-op it was on HEAD
|
|
472
497
|
// while the group still merges stderr for real spans.
|
|
473
|
-
return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\n{ :\n${script}\n} 2>&1`] }
|
|
498
|
+
return { command: '/bin/sh', args: ['-c', `export CLAUDE_PROJECT_DIR='${quoted}'\nexport CLAUDECODE=1\n{ :\n${script}\n} 2>&1`] }
|
|
474
499
|
}
|
|
475
500
|
|
|
476
501
|
interface FenceBlock {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE_CONFIG_DIR: Claude Code's override for the home configuration directory.
|
|
3
|
+
*
|
|
4
|
+
* Claude relocates the entire ~/.claude configuration tree (settings.json, commands,
|
|
5
|
+
* agents, skills, plugins, output-styles, CLAUDE.md) when CLAUDE_CONFIG_DIR is set,
|
|
6
|
+
* so a user can keep that config outside their home directory. This resolves the
|
|
7
|
+
* home-scope config root for every consumer; a project's own `.claude/` directory is
|
|
8
|
+
* a separate scope and is never affected. A leading `~` expands against `home`, and
|
|
9
|
+
* the result is resolved to an absolute path so a relative value cannot depend on the
|
|
10
|
+
* reader's working directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** The home-scope Claude config directory: CLAUDE_CONFIG_DIR (expanded, absolute)
|
|
16
|
+
* when set to a non-empty value, otherwise `<home>/.claude`. */
|
|
17
|
+
export function claudeConfigDir(home: string): string {
|
|
18
|
+
const override = process.env.CLAUDE_CONFIG_DIR
|
|
19
|
+
if (override && override.trim().length > 0) {
|
|
20
|
+
const expanded = override.startsWith('~') ? path.join(home, override.slice(1)) : override
|
|
21
|
+
return path.resolve(expanded)
|
|
22
|
+
}
|
|
23
|
+
return path.join(home, '.claude')
|
|
24
|
+
}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import * as fs from 'node:fs'
|
|
16
16
|
import * as path from 'node:path'
|
|
17
17
|
|
|
18
|
+
import { claudeConfigDir } from './config-dir.js'
|
|
19
|
+
|
|
18
20
|
export interface InstalledPlugin {
|
|
19
21
|
name: string
|
|
20
22
|
/** The version directory: ${CLAUDE_PLUGIN_ROOT}. */
|
|
@@ -148,8 +150,8 @@ function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
|
|
|
148
150
|
* because any settings edit or cache-tree change invalidates it.
|
|
149
151
|
*/
|
|
150
152
|
export function installedPlugins(home: string, extraSettingsFiles: string[] = []): InstalledPlugin[] {
|
|
151
|
-
const cacheDir = path.join(home, '
|
|
152
|
-
const settingsFiles = [path.join(home, '
|
|
153
|
+
const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
|
|
154
|
+
const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
|
|
153
155
|
const key = [home, ...extraSettingsFiles].join('\n')
|
|
154
156
|
const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
|
|
155
157
|
const cached = pluginCache.get(key)
|
|
@@ -180,7 +182,7 @@ function resolvePlugin(home: string, cacheDir: string, marketplace: string, plug
|
|
|
180
182
|
const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
|
|
181
183
|
const id = qualified.replace(/[^A-Za-z0-9]+/g, '-')
|
|
182
184
|
const userConfig = configs[qualified] ?? configs[pluginDir] ?? configs[name]
|
|
183
|
-
return { name, root, dataDir: path.join(home, '
|
|
185
|
+
return { name, root, dataDir: path.join(claudeConfigDir(home), 'plugins', 'data', id), manifest, ...(userConfig ? { userConfig } : {}) }
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
/** The two plugin path variables, textually substituted into plugin-shipped
|
package/extensions/mcp.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
|
|
|
48
48
|
import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
49
49
|
import { Type } from 'typebox'
|
|
50
50
|
import { splitArgs } from './internal/command-file.js'
|
|
51
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
51
52
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
52
53
|
import { setMcpToolCaller } from './internal/mcp-call.js'
|
|
53
54
|
import { FileOAuthProvider, openBrowser, startCallbackServer, waitForAuthCode } from './internal/mcp-oauth.js'
|
|
@@ -57,7 +58,14 @@ import { isProjectApproved, isProjectApprovedSilently } from './internal/project
|
|
|
57
58
|
import { findNearestFile } from './internal/project-root.js'
|
|
58
59
|
|
|
59
60
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
60
|
-
|
|
61
|
+
// Claude's MCP_TOOL_TIMEOUT default is effectively hours: the per-call wall-clock budget
|
|
62
|
+
// is only a ceiling, and the idle timeout below is the real guard. 4h matches that model,
|
|
63
|
+
// so a legitimately slow-but-progressing tool is not killed at the old 2 minutes.
|
|
64
|
+
const DEFAULT_CALL_TIMEOUT_MS = 14_400_000
|
|
65
|
+
// The idle timeout: the longest a call may go with no response or progress before it is
|
|
66
|
+
// abandoned. Claude uses a separate idle guard (minutes) rather than the hours-long
|
|
67
|
+
// wall-clock budget; the SDK resets this window on every progress notification.
|
|
68
|
+
const DEFAULT_CALL_IDLE_TIMEOUT_MS = 300_000
|
|
61
69
|
|
|
62
70
|
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
63
71
|
function envTimeout(name: string, fallback: number): number {
|
|
@@ -70,6 +78,33 @@ function envTimeout(name: string, fallback: number): number {
|
|
|
70
78
|
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
71
79
|
const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
72
80
|
const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
81
|
+
|
|
82
|
+
/** The idle timeout in ms: the longest a call may go with no response or progress before
|
|
83
|
+
* it is abandoned, overridable by CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, with 0 disabling it
|
|
84
|
+
* (leaving only the wall-clock budget). Unlike envTimeout, an explicit 0 is honored as
|
|
85
|
+
* "disabled" rather than falling back to the default. */
|
|
86
|
+
function idleTimeoutMs(): number {
|
|
87
|
+
const raw = process.env.CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT
|
|
88
|
+
if (raw === undefined) return DEFAULT_CALL_IDLE_TIMEOUT_MS
|
|
89
|
+
const value = Number.parseInt(raw, 10)
|
|
90
|
+
if (value === 0) return 0
|
|
91
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_CALL_IDLE_TIMEOUT_MS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The SDK RequestOptions for a call under pi's two-tier timeout: a wall-clock ceiling and,
|
|
95
|
+
* under it, an idle timeout the SDK resets on every progress notification. When the idle
|
|
96
|
+
* window is enabled and tighter than the wall budget, `timeout` is that per-quiet-period
|
|
97
|
+
* deadline (resetTimeoutOnProgress), maxTotalTimeout caps the wall clock, and an onprogress
|
|
98
|
+
* handler is required: it makes the server address progress to this request and lets the
|
|
99
|
+
* SDK reset the timer on it. When the idle timeout is disabled, or already looser than the
|
|
100
|
+
* wall budget, only the wall budget applies. The outer withTimeout race is a wall-clock
|
|
101
|
+
* backstop and must be raced against `wall`, never the idle window, so a legitimately
|
|
102
|
+
* progressing call is not cut off. */
|
|
103
|
+
function callRequestOptions(wall: number): { timeout: number; resetTimeoutOnProgress?: boolean; maxTotalTimeout?: number; onprogress?: () => void } {
|
|
104
|
+
const idle = idleTimeoutMs()
|
|
105
|
+
if (idle === 0 || idle >= wall) return { timeout: wall }
|
|
106
|
+
return { timeout: idle, resetTimeoutOnProgress: true, maxTotalTimeout: wall, onprogress: () => {} }
|
|
107
|
+
}
|
|
73
108
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
74
109
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
75
110
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -125,9 +160,19 @@ export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.e
|
|
|
125
160
|
})
|
|
126
161
|
}
|
|
127
162
|
|
|
128
|
-
/**
|
|
163
|
+
/** The user's ~/.claude.json (top-level mcpServers plus the per-project `projects` map).
|
|
164
|
+
* When CLAUDE_CONFIG_DIR is set, Claude relocates .claude.json inside that directory; by
|
|
165
|
+
* default it stays at the home root, since .claude.json does NOT live inside ~/.claude. A
|
|
166
|
+
* blank value is treated as unset, matching claudeConfigDir. */
|
|
167
|
+
function claudeJsonPath(home: string): string {
|
|
168
|
+
const override = process.env.CLAUDE_CONFIG_DIR
|
|
169
|
+
return override && override.trim().length > 0 ? path.join(claudeConfigDir(home), '.claude.json') : path.join(home, '.claude.json')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
|
|
173
|
+
* tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
|
|
129
174
|
export function userConfigPaths(home: string): string[] {
|
|
130
|
-
return [
|
|
175
|
+
return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
|
|
131
176
|
}
|
|
132
177
|
|
|
133
178
|
/** Project-scoped MCP config, each file the nearest of its name at or above cwd
|
|
@@ -163,7 +208,7 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
|
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
210
|
const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
|
|
166
|
-
const userSettings = read(path.join(home, '
|
|
211
|
+
const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
|
|
167
212
|
const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
|
|
168
213
|
const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
|
|
169
214
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
@@ -208,7 +253,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
208
253
|
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
209
254
|
const servers = loadConfigFrom(userConfigPaths(home))
|
|
210
255
|
try {
|
|
211
|
-
const claudeJson = JSON.parse(fs.readFileSync(
|
|
256
|
+
const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
|
|
212
257
|
Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
|
|
213
258
|
} catch {
|
|
214
259
|
// missing or invalid ~/.claude.json: the top-level user servers already loaded
|
|
@@ -321,6 +366,47 @@ export function mcpAllowDeny(managedFile: string = managedSettingsFileOverride ?
|
|
|
321
366
|
}
|
|
322
367
|
}
|
|
323
368
|
|
|
369
|
+
/** The managed-mcp.json path: a sibling of managed-settings.json (same directory). Derived
|
|
370
|
+
* through the same test seam so a test can write both into one temp dir. */
|
|
371
|
+
export function managedMcpPath(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): string {
|
|
372
|
+
return path.join(path.dirname(managedFile), 'managed-mcp.json')
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Claude's managed-mcp.json: when it exists beside managed-settings.json it takes
|
|
376
|
+
* exclusive control of MCP. Only its `mcpServers` load; user, project, and plugin servers
|
|
377
|
+
* are all suppressed (and the project-approval flow with them), and an empty map disables
|
|
378
|
+
* MCP entirely. Returns the managed server map (possibly empty) when the file exists and
|
|
379
|
+
* parses, or null only when the file is absent, in which case MCP loads from the usual
|
|
380
|
+
* scopes exactly as before. A file that parses but carries no `mcpServers` object is an
|
|
381
|
+
* empty managed set, so a deployed-but-bodyless policy locks down rather than silently
|
|
382
|
+
* reopening the other scopes. A file that is PRESENT but not valid JSON fails closed to
|
|
383
|
+
* the same empty set (deny-all) rather than reopening those scopes: the lockdown intent
|
|
384
|
+
* means a corrupt or truncated policy file must not become an allow-all. The allow/deny
|
|
385
|
+
* lists still filter the returned set. */
|
|
386
|
+
export function loadManagedMcpServers(managedFile: string = managedSettingsFileOverride ?? managedSettingsPath()): Record<string, ServerConfig> | null {
|
|
387
|
+
const file = managedMcpPath(managedFile)
|
|
388
|
+
let raw: string
|
|
389
|
+
try {
|
|
390
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
391
|
+
} catch {
|
|
392
|
+
// Absent (or unreadable) managed-mcp.json: no managed MCP control, load normally.
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
let parsed: unknown
|
|
396
|
+
try {
|
|
397
|
+
parsed = JSON.parse(raw)
|
|
398
|
+
} catch (error) {
|
|
399
|
+
// Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
|
|
400
|
+
// rather than reopening the user/project/plugin scopes.
|
|
401
|
+
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${error instanceof Error ? error.message : String(error)}`)
|
|
402
|
+
return {}
|
|
403
|
+
}
|
|
404
|
+
if (parsed === null || typeof parsed !== 'object') return {}
|
|
405
|
+
const servers = (parsed as { mcpServers?: unknown }).mcpServers
|
|
406
|
+
if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) return {}
|
|
407
|
+
return servers as Record<string, ServerConfig>
|
|
408
|
+
}
|
|
409
|
+
|
|
324
410
|
/** Claude's managed allow/deny lists: `allowed` null means no allow list (keep all);
|
|
325
411
|
* a set (even empty) is exclusive, so only its members survive; a deny list removes
|
|
326
412
|
* servers on top, deny winning over allow. */
|
|
@@ -799,7 +885,7 @@ function resourceTemplateEntry(server: string, template: { uriTemplate: string;
|
|
|
799
885
|
async function collectResources(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
|
|
800
886
|
let cursor: string | undefined
|
|
801
887
|
do {
|
|
802
|
-
const page = await withTimeout(client.listResources({ cursor },
|
|
888
|
+
const page = await withTimeout(client.listResources({ cursor }, callRequestOptions(budget)), budget, `list resources ${name}`)
|
|
803
889
|
for (const resource of page.resources) entries.push(resourceEntry(name, resource))
|
|
804
890
|
cursor = page.nextCursor
|
|
805
891
|
} while (cursor)
|
|
@@ -809,7 +895,7 @@ async function collectResources(entries: Array<Record<string, unknown>>, name: s
|
|
|
809
895
|
async function collectResourceTemplates(entries: Array<Record<string, unknown>>, name: string, client: Client, budget: number): Promise<void> {
|
|
810
896
|
let cursor: string | undefined
|
|
811
897
|
do {
|
|
812
|
-
const page = await withTimeout(client.listResourceTemplates({ cursor },
|
|
898
|
+
const page = await withTimeout(client.listResourceTemplates({ cursor }, callRequestOptions(budget)), budget, `list resource templates ${name}`)
|
|
813
899
|
for (const template of page.resourceTemplates) entries.push(resourceTemplateEntry(name, template))
|
|
814
900
|
cursor = page.nextCursor
|
|
815
901
|
} while (cursor)
|
|
@@ -845,7 +931,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
845
931
|
setMcpToolCaller(async (server, tool, input) => {
|
|
846
932
|
const client = clients.get(server)
|
|
847
933
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
848
|
-
const result = await client.callTool({ name: tool, arguments: input }, undefined,
|
|
934
|
+
const result = await client.callTool({ name: tool, arguments: input }, undefined, callRequestOptions(callTimeoutMs()))
|
|
849
935
|
const text = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
850
936
|
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
851
937
|
.map((part) => part.text)
|
|
@@ -883,12 +969,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
883
969
|
// and this closure would otherwise keep calling the old, closed client.
|
|
884
970
|
const current = clients.get(name)
|
|
885
971
|
if (!current) throw new Error(`MCP server "${name}" is not connected`)
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
//
|
|
972
|
+
// The per-server timeout (Claude's, 1s floor) or MCP_TOOL_TIMEOUT is the
|
|
973
|
+
// wall-clock ceiling; callRequestOptions layers the idle timeout under it, which
|
|
974
|
+
// the SDK enforces (resetting on progress). Pass the options to the SDK too: its
|
|
975
|
+
// own default request timeout is 60s and would otherwise reject first. The outer
|
|
976
|
+
// race uses the wall budget, never the idle window, so a progressing call is not
|
|
977
|
+
// cut off at the idle timeout.
|
|
889
978
|
const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
|
|
890
|
-
const
|
|
891
|
-
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined,
|
|
979
|
+
const wall = declared ?? callTimeoutMs()
|
|
980
|
+
const result = await withTimeout(current.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, callRequestOptions(wall)), wall, toolName)
|
|
892
981
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
893
982
|
const details: { error?: string } = {}
|
|
894
983
|
if (result.isError) {
|
|
@@ -940,8 +1029,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
940
1029
|
const promptArgs = mapPromptArguments(prompt.arguments, args)
|
|
941
1030
|
const params: { name: string; arguments?: Record<string, string> } = { name: prompt.name }
|
|
942
1031
|
if (Object.keys(promptArgs).length > 0) params.arguments = promptArgs
|
|
943
|
-
const
|
|
944
|
-
const result = await withTimeout(current.getPrompt(params,
|
|
1032
|
+
const wall = callTimeoutMs()
|
|
1033
|
+
const result = await withTimeout(current.getPrompt(params, callRequestOptions(wall)), wall, commandName)
|
|
945
1034
|
// The prompt drives a turn exactly the way a custom slash command does
|
|
946
1035
|
// (see commands.ts), carrying its image blocks through. A prompt that
|
|
947
1036
|
// yields no content is reported rather than sent as an empty turn.
|
|
@@ -1027,8 +1116,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1027
1116
|
const { server, uri } = params as { server: string; uri: string }
|
|
1028
1117
|
const client = clients.get(server)
|
|
1029
1118
|
if (!client) throw new Error(`MCP server "${server}" is not connected`)
|
|
1030
|
-
const
|
|
1031
|
-
const result = await withTimeout(client.readResource({ uri },
|
|
1119
|
+
const wall = callTimeoutMs()
|
|
1120
|
+
const result = await withTimeout(client.readResource({ uri }, callRequestOptions(wall)), wall, `read ${uri}`)
|
|
1032
1121
|
const blocks = (result.contents as Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>).map((entry): McpContentBlock => {
|
|
1033
1122
|
if (typeof entry.text === 'string') return { type: 'resource', resource: { uri: entry.uri, text: entry.text } }
|
|
1034
1123
|
if (entry.blob && entry.mimeType?.startsWith('image/')) return { type: 'image', data: entry.blob, mimeType: entry.mimeType }
|
|
@@ -1143,15 +1232,32 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1143
1232
|
|
|
1144
1233
|
let projectConnected = false
|
|
1145
1234
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1235
|
+
/** managed-mcp.json exclusive mode: a policy deployed mid-process must not leave
|
|
1236
|
+
* already-connected user/project servers running alongside the managed set. Evict every
|
|
1237
|
+
* connected client not in the managed set (delete it from the map first so the onclose
|
|
1238
|
+
* handler's guard sees it gone and does not overwrite the status, then close it
|
|
1239
|
+
* best-effort and mark it disabled), then connect only the managed servers. */
|
|
1240
|
+
async function connectManagedExclusive(managed: Record<string, ServerConfig>, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
|
|
1241
|
+
const managedServers = applyServerPolicy(managed, allowed, denied)
|
|
1242
|
+
const managedNames = new Set(Object.keys(managedServers))
|
|
1243
|
+
for (const [name, client] of Array.from(clients.entries())) {
|
|
1244
|
+
if (managedNames.has(name)) continue
|
|
1245
|
+
clients.delete(name)
|
|
1246
|
+
await client.close().catch(() => {})
|
|
1247
|
+
status.set(name, { state: 'disabled by managed policy', tools: 0 })
|
|
1248
|
+
}
|
|
1249
|
+
await connectServers(managedServers, authUi)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** The normal user + plugin + project scopes, when no managed-mcp.json is present.
|
|
1253
|
+
* Connecting spawns processes and opens sockets, so it belongs here rather than in the
|
|
1254
|
+
* factory: pi runs the factory for invocations that never start a session. Names still
|
|
1255
|
+
* connected are filtered out, so a later session start only retries servers that failed
|
|
1256
|
+
* or whose transport dropped, without duplicate-name warnings. */
|
|
1257
|
+
async function connectNormalScopes(ctx: ExtensionContext, allowed: Set<string> | null, denied: Set<string>, authUi?: AuthUi): Promise<void> {
|
|
1151
1258
|
// Plugin servers merge under the user scope (plugins are user-installed);
|
|
1152
1259
|
// the user's own entry wins a name clash with a plugin's.
|
|
1153
1260
|
const pluginServers = loadPluginServers(installedPlugins(os.homedir()))
|
|
1154
|
-
const { allowed, denied } = mcpAllowDeny()
|
|
1155
1261
|
const scoped = applyServerPolicy({ ...pluginServers, ...loadUserScope(os.homedir(), ctx.cwd) }, allowed, denied)
|
|
1156
1262
|
// Claude's precedence is project over user for a duplicate name. A project .mcp.json
|
|
1157
1263
|
// server only outranks the user's own when it will actually connect (the user already
|
|
@@ -1165,7 +1271,6 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1165
1271
|
const { consented, gated } = splitByPolicy(applyServerPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), allowed, denied), projectPolicy)
|
|
1166
1272
|
const projectWinners = new Set(Object.keys(consented))
|
|
1167
1273
|
const userServers = Object.fromEntries(Object.entries(scoped).filter(([name]) => !clients.has(name) && !projectWinners.has(name)))
|
|
1168
|
-
const authUi = authUiFor(ctx)
|
|
1169
1274
|
// The consented project servers carry no ordering dependency on the user scope:
|
|
1170
1275
|
// projectWinners already excludes their names from userServers, so the two batches
|
|
1171
1276
|
// are disjoint and connect concurrently, and startup pays the slower scope rather
|
|
@@ -1181,6 +1286,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
1181
1286
|
// whole-project confirm, and the rest stay behind it, sequentially after both
|
|
1182
1287
|
// scopes so the confirm dialog never races a connect.
|
|
1183
1288
|
if (!projectConnected) projectConnected = await connectGatedProjectServers(ctx, gated, authUi)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
1292
|
+
const authUi = authUiFor(ctx)
|
|
1293
|
+
// The managed allow/deny lists filter every scope, including a managed-mcp.json set.
|
|
1294
|
+
const { allowed, denied } = mcpAllowDeny()
|
|
1295
|
+
// managed-mcp.json (beside managed-settings.json) takes exclusive control when present:
|
|
1296
|
+
// only its servers load, and the user, project, and plugin scopes plus the whole
|
|
1297
|
+
// project-approval flow below are skipped. An empty map disables MCP entirely. An absent
|
|
1298
|
+
// file leaves the normal scopes untouched; a present but corrupt file fails closed to an
|
|
1299
|
+
// empty set (see loadManagedMcpServers).
|
|
1300
|
+
const managed = loadManagedMcpServers()
|
|
1301
|
+
if (managed !== null) {
|
|
1302
|
+
await connectManagedExclusive(managed, allowed, denied, authUi)
|
|
1303
|
+
} else {
|
|
1304
|
+
await connectNormalScopes(ctx, allowed, denied, authUi)
|
|
1305
|
+
}
|
|
1184
1306
|
|
|
1185
1307
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
1186
1308
|
|