pi-subagents 0.24.3 → 0.24.4

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.
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { renderWidget } from "../../tui/render.ts";
4
+ import { renderWidget, widgetRenderKey } from "../../tui/render.ts";
5
5
  import { formatControlNoticeMessage } from "../shared/subagent-control.ts";
6
6
  import {
7
7
  type AsyncJobState,
@@ -118,7 +118,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
118
118
  return;
119
119
  }
120
120
 
121
+ let widgetChanged = false;
121
122
  for (const job of state.asyncJobs.values()) {
123
+ const widgetStateBefore = widgetRenderKey(job);
122
124
  try {
123
125
  emitNewControlEvents(job);
124
126
  const reconciliation = reconcileAsyncRun(job.asyncDir, {
@@ -153,7 +155,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
153
155
  job.currentStep = status.currentStep ?? job.currentStep;
154
156
  job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
155
157
  job.startedAt = status.startedAt ?? job.startedAt;
156
- job.updatedAt = status.lastUpdate ?? Date.now();
158
+ if (status.lastUpdate !== undefined) job.updatedAt = status.lastUpdate;
157
159
  if (status.steps?.length) {
158
160
  const groups = normalizeParallelGroups(status.parallelGroups, status.steps.length, status.chainStepCount ?? status.steps.length);
159
161
  job.parallelGroups = groups.length ? groups : job.parallelGroups;
@@ -179,21 +181,27 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
179
181
  if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
180
182
  scheduleCleanup(job.asyncId);
181
183
  }
184
+ if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
182
185
  continue;
183
186
  }
184
- job.status = job.status === "queued" ? "running" : job.status;
185
- job.updatedAt = Date.now();
187
+ if (job.status === "queued") {
188
+ job.status = "running";
189
+ job.updatedAt = Date.now();
190
+ }
186
191
  } catch (error) {
187
- console.error(`Failed to read async status for '${job.asyncDir}':`, error);
188
- job.status = "failed";
189
- job.updatedAt = Date.now();
192
+ if (job.status !== "failed") {
193
+ console.error(`Failed to read async status for '${job.asyncDir}':`, error);
194
+ job.status = "failed";
195
+ job.updatedAt = Date.now();
196
+ }
190
197
  if (!state.cleanupTimers.has(job.asyncId)) {
191
198
  scheduleCleanup(job.asyncId);
192
199
  }
193
200
  }
201
+ if (widgetRenderKey(job) !== widgetStateBefore) widgetChanged = true;
194
202
  }
195
203
 
196
- if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
204
+ if (widgetChanged && state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
197
205
  }, pollIntervalMs);
198
206
  state.poller.unref?.();
199
207
  };
@@ -222,7 +222,12 @@ function runPiStreaming(
222
222
  ...(piPackageRoot ? { piPackageRoot } : {}),
223
223
  ...(piArgv1 ? { argv1: piArgv1 } : {}),
224
224
  });
225
- const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
225
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
226
+ cwd,
227
+ stdio: ["ignore", "pipe", "pipe"],
228
+ env: spawnEnv,
229
+ windowsHide: true,
230
+ });
226
231
  let stderr = "";
227
232
  let stdoutBuf = "";
228
233
  let stderrBuf = "";
@@ -230,6 +235,7 @@ function runPiStreaming(
230
235
  const usage = emptyUsage();
231
236
  let model: string | undefined;
232
237
  let error: string | undefined;
238
+ let assistantError: string | undefined;
233
239
  let interrupted = false;
234
240
  let observedMutationAttempt = false;
235
241
  const rawStdoutLines: string[] = [];
@@ -290,7 +296,7 @@ function runPiStreaming(
290
296
 
291
297
  if (event.type !== "message_end" || event.message.role !== "assistant") return;
292
298
  if (event.message.model) model = event.message.model;
293
- if (event.message.errorMessage) error = event.message.errorMessage;
299
+ if (event.message.errorMessage) assistantError = event.message.errorMessage;
294
300
  const eventUsage = event.message.usage;
295
301
  if (eventUsage) {
296
302
  usage.turns++;
@@ -304,6 +310,7 @@ function runPiStreaming(
304
310
  const hasToolCall = Array.isArray(event.message.content)
305
311
  && event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
306
312
  if (stopReason === "stop" && !hasToolCall) {
313
+ if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
307
314
  cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
308
315
  startFinalDrain();
309
316
  }
@@ -371,7 +378,7 @@ function runPiStreaming(
371
378
  const termSent = trySignalChild(child, "SIGTERM");
372
379
  if (!termSent) return;
373
380
  forcedTerminationSignal = true;
374
- if (!cleanTerminalAssistantStopReceived && !error) {
381
+ if (!cleanTerminalAssistantStopReceived && !error && !assistantError) {
375
382
  error = `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
376
383
  }
377
384
  finalHardKillTimer = setTimeout(() => {
@@ -395,14 +402,15 @@ function runPiStreaming(
395
402
  if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
396
403
  outputStream.end();
397
404
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
398
- const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !error;
405
+ const finalError = error ?? assistantError;
406
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
399
407
  resolve({
400
408
  stderr,
401
409
  exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
402
410
  messages,
403
411
  usage,
404
412
  model,
405
- error: interrupted || forcedDrainAfterFinalSuccess ? undefined : error,
413
+ error: interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
406
414
  finalOutput,
407
415
  interrupted,
408
416
  observedMutationAttempt,
@@ -417,7 +425,7 @@ function runPiStreaming(
417
425
  outputStream.end();
418
426
  const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
419
427
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
420
- resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
428
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
421
429
  });
422
430
  });
423
431
  }
@@ -614,6 +622,7 @@ async function runSingleStep(
614
622
  systemPrompt: step.systemPrompt,
615
623
  systemPromptMode: step.systemPromptMode,
616
624
  mcpDirectTools: step.mcpDirectTools,
625
+ cwd: step.cwd ?? ctx.cwd,
617
626
  promptFileStem: step.agent,
618
627
  intercomSessionName: ctx.childIntercomTarget,
619
628
  orchestratorIntercomTarget: ctx.orchestratorIntercomTarget,
@@ -636,11 +645,13 @@ async function runSingleStep(
636
645
  cleanupTempDir(tempDir);
637
646
 
638
647
  const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
639
- const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError
648
+ const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && step.completionGuard !== false
640
649
  ? evaluateCompletionMutationGuard({
641
650
  agent: step.agent,
642
651
  task,
643
652
  messages: run.messages,
653
+ tools: step.tools,
654
+ mcpDirectTools: step.mcpDirectTools,
644
655
  })
645
656
  : undefined;
646
657
  const completionGuardTriggered = completionGuard?.triggered === true && !run.observedMutationAttempt;
@@ -151,6 +151,7 @@ async function runSingleAttempt(
151
151
  extensions: agent.extensions,
152
152
  systemPrompt: shared.systemPrompt,
153
153
  mcpDirectTools: agent.mcpDirectTools,
154
+ cwd: options.cwd ?? runtimeCwd,
154
155
  promptFileStem: agent.name,
155
156
  intercomSessionName: options.intercomSessionName,
156
157
  orchestratorIntercomTarget: options.orchestratorIntercomTarget,
@@ -207,6 +208,7 @@ async function runSingleAttempt(
207
208
  cwd: options.cwd ?? runtimeCwd,
208
209
  env: spawnEnv,
209
210
  stdio: ["ignore", "pipe", "pipe"],
211
+ windowsHide: true,
210
212
  });
211
213
  const jsonlWriter = createJsonlWriter(shared.jsonlPath, proc.stdout);
212
214
  let buf = "";
@@ -214,6 +216,7 @@ async function runSingleAttempt(
214
216
  let settled = false;
215
217
  let detached = false;
216
218
  let intercomStarted = false;
219
+ let assistantError: string | undefined;
217
220
  let removeAbortListener: (() => void) | undefined;
218
221
  let removeInterruptListener: (() => void) | undefined;
219
222
  let activityTimer: NodeJS.Timeout | undefined;
@@ -259,7 +262,7 @@ async function runSingleAttempt(
259
262
  const termSent = trySignalChild(proc, "SIGTERM");
260
263
  if (!termSent) return;
261
264
  forcedTerminationSignal = true;
262
- if (!cleanTerminalAssistantStopReceived) {
265
+ if (!cleanTerminalAssistantStopReceived && !assistantError) {
263
266
  result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
264
267
  }
265
268
  finalHardKillTimer = setTimeout(() => {
@@ -465,13 +468,15 @@ async function runSingleAttempt(
465
468
  progress.tokens = result.usage.input + result.usage.output;
466
469
  }
467
470
  if (!result.model && evt.message.model) result.model = evt.message.model;
468
- if (evt.message.errorMessage) result.error = evt.message.errorMessage;
469
- appendRecentOutput(progress, extractTextFromContent(evt.message.content).split("\n").slice(-10));
471
+ if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
472
+ const assistantText = extractTextFromContent(evt.message.content);
473
+ appendRecentOutput(progress, assistantText.split("\n").slice(-10));
470
474
  // Final assistant message: start the exit drain window.
471
475
  const stopReason = (evt.message as { stopReason?: string }).stopReason;
472
476
  const hasToolCall = Array.isArray(evt.message.content)
473
477
  && evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
474
478
  if (stopReason === "stop" && !hasToolCall) {
479
+ if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
475
480
  cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
476
481
  startFinalDrain();
477
482
  }
@@ -551,6 +556,7 @@ async function runSingleAttempt(
551
556
  }
552
557
  processClosed = true;
553
558
  if (buf.trim()) processLine(buf);
559
+ if (!result.error && assistantError) result.error = assistantError;
554
560
  const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !result.error;
555
561
  if (code !== 0 && stderrBuf.trim() && !result.error && !forcedDrainAfterFinalSuccess) {
556
562
  result.error = stderrBuf.trim();
@@ -662,8 +668,14 @@ async function runSingleAttempt(
662
668
  };
663
669
 
664
670
  let fullOutput = getFinalOutput(result.messages);
665
- const completionGuard = result.exitCode === 0 && !result.error
666
- ? evaluateCompletionMutationGuard({ agent: agent.name, task, messages: result.messages })
671
+ const completionGuard = result.exitCode === 0 && !result.error && agent.completionGuard !== false
672
+ ? evaluateCompletionMutationGuard({
673
+ agent: agent.name,
674
+ task,
675
+ messages: result.messages,
676
+ tools: agent.tools,
677
+ mcpDirectTools: agent.mcpDirectTools,
678
+ })
667
679
  : undefined;
668
680
  if (completionGuard?.triggered && !observedMutationAttempt) {
669
681
  result.exitCode = 1;
@@ -35,7 +35,7 @@ import { createForkContextResolver } from "../../shared/fork-context.ts";
35
35
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
36
36
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
37
37
  import { formatControlIntercomMessage, formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
38
- import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
38
+ import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
39
39
  import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd } from "../../shared/utils.ts";
40
40
  import {
41
41
  buildSubagentResultIntercomPayload,
@@ -979,7 +979,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
979
979
  };
980
980
  }
981
981
  const rawOutput = params.output !== undefined ? params.output : a.output;
982
- const effectiveOutput: string | false | undefined = rawOutput === true ? a.output : (rawOutput as string | false | undefined);
982
+ const effectiveOutput = normalizeSingleOutputOverride(rawOutput, a.output);
983
983
  const effectiveOutputMode = params.outputMode ?? "inline";
984
984
  const normalizedSkills = normalizeSkillInput(params.skill);
985
985
  const skills = normalizedSkills === false ? [] : normalizedSkills;
@@ -1716,7 +1716,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1716
1716
  );
1717
1717
  let skillOverride: string[] | false | undefined = normalizeSkillInput(params.skill);
1718
1718
  const rawOutput = params.output !== undefined ? params.output : agentConfig.output;
1719
- let effectiveOutput: string | false | undefined = rawOutput === true ? agentConfig.output : (rawOutput as string | false | undefined);
1719
+ let effectiveOutput = normalizeSingleOutputOverride(rawOutput, agentConfig.output);
1720
1720
  const effectiveOutputMode = params.outputMode ?? "inline";
1721
1721
  const currentMaxSubagentDepth = resolveCurrentMaxSubagentDepth(deps.config.maxSubagentDepth);
1722
1722
  const maxSubagentDepth = resolveChildMaxSubagentDepth(currentMaxSubagentDepth, agentConfig.maxSubagentDepth);
@@ -1750,7 +1750,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1750
1750
  task = result.templates[0]!;
1751
1751
  const override = result.behaviorOverrides[0];
1752
1752
  if (override?.model) modelOverride = override.model;
1753
- if (override?.output !== undefined) effectiveOutput = override.output;
1753
+ if (override?.output !== undefined) effectiveOutput = normalizeSingleOutputOverride(override.output, agentConfig.output);
1754
1754
  if (override?.skills !== undefined) skillOverride = override.skills;
1755
1755
 
1756
1756
  if (result.runInBackground) {
@@ -54,11 +54,24 @@ const GENERAL_IMPLEMENTATION_PATTERNS = [
54
54
  /\b(?:update|add|remove|replace|delete|create)\s+(?:the\s+)?(?:file|files|code|source|implementation|test|tests|component|function|module|class|method|logic|import|imports|readme|docs?|changelog|package\.json|config|manifest|extension|prompt|command)\b/i,
55
55
  ];
56
56
 
57
+ const READ_ONLY_BUILTIN_TOOLS = new Set([
58
+ "read",
59
+ "grep",
60
+ "find",
61
+ "ls",
62
+ "web_search",
63
+ "fetch_content",
64
+ "get_search_content",
65
+ "intercom",
66
+ "contact_supervisor",
67
+ ]);
57
68
 
58
69
  interface CompletionMutationGuardInput {
59
70
  agent: string;
60
71
  task: string;
61
72
  messages: Message[];
73
+ tools?: string[];
74
+ mcpDirectTools?: string[];
62
75
  }
63
76
 
64
77
  interface CompletionMutationGuardResult {
@@ -83,6 +96,13 @@ function stripScopedNoEditConstraints(task: string): string {
83
96
  return stripped;
84
97
  }
85
98
 
99
+ function declaresOnlyReadOnlyTools(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean {
100
+ return tools !== undefined
101
+ && tools.length > 0
102
+ && (mcpDirectTools?.length ?? 0) === 0
103
+ && tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
104
+ }
105
+
86
106
  export function expectsImplementationMutation(agent: string, task: string): boolean {
87
107
  const taskText = stripFrameworkInstructions(task);
88
108
  const taskTextWithoutScopedConstraints = stripScopedNoEditConstraints(taskText);
@@ -115,7 +135,9 @@ export function hasMutationToolCall(messages: Message[]): boolean {
115
135
  }
116
136
 
117
137
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
118
- const expectedMutation = expectsImplementationMutation(input.agent, input.task);
138
+ const expectedMutation = declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)
139
+ ? false
140
+ : expectsImplementationMutation(input.agent, input.task);
119
141
  const attemptedMutation = hasMutationToolCall(input.messages);
120
142
  return {
121
143
  expectedMutation,
@@ -0,0 +1,365 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { getAgentDir } from "../../shared/utils.ts";
6
+
7
+ const CACHE_VERSION = 1;
8
+ const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
+ const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]);
10
+ const GENERIC_GLOBAL_CONFIG_PATH = path.join(os.homedir(), ".config", "mcp", "mcp.json");
11
+ const IMPORT_PATHS = {
12
+ cursor: [path.join(os.homedir(), ".cursor", "mcp.json")],
13
+ "claude-code": [
14
+ path.join(os.homedir(), ".claude", "mcp.json"),
15
+ path.join(os.homedir(), ".claude.json"),
16
+ path.join(os.homedir(), ".claude", "claude_desktop_config.json"),
17
+ ],
18
+ "claude-desktop": [path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")],
19
+ codex: [path.join(os.homedir(), ".codex", "config.json")],
20
+ windsurf: [path.join(os.homedir(), ".windsurf", "mcp.json")],
21
+ vscode: [".vscode/mcp.json"],
22
+ } as const;
23
+
24
+ type ToolPrefix = "server" | "none" | "short";
25
+ type ImportKind = keyof typeof IMPORT_PATHS;
26
+
27
+ interface ServerEntry {
28
+ command?: string;
29
+ args?: string[];
30
+ env?: Record<string, string>;
31
+ cwd?: string;
32
+ url?: string;
33
+ headers?: Record<string, string>;
34
+ auth?: "oauth" | "bearer" | false;
35
+ bearerToken?: string;
36
+ bearerTokenEnv?: string;
37
+ exposeResources?: boolean;
38
+ excludeTools?: string[];
39
+ directTools?: boolean | string[];
40
+ }
41
+
42
+ interface McpConfig {
43
+ mcpServers: Record<string, ServerEntry>;
44
+ imports?: ImportKind[];
45
+ settings?: {
46
+ toolPrefix?: ToolPrefix;
47
+ directTools?: boolean;
48
+ };
49
+ }
50
+
51
+ interface CachedTool {
52
+ name?: string;
53
+ }
54
+
55
+ interface CachedResource {
56
+ uri?: string;
57
+ name?: string;
58
+ }
59
+
60
+ interface ServerCacheEntry {
61
+ configHash?: string;
62
+ tools?: CachedTool[];
63
+ resources?: CachedResource[];
64
+ cachedAt?: number;
65
+ }
66
+
67
+ interface MetadataCache {
68
+ version: number;
69
+ servers: Record<string, ServerCacheEntry>;
70
+ }
71
+
72
+ export function resolveMcpDirectToolNames(mcpDirectTools: string[] | undefined, cwd = process.cwd()): string[] {
73
+ if (!mcpDirectTools?.length) return [];
74
+
75
+ try {
76
+ const config = loadMcpConfig(cwd);
77
+ const cache = loadMetadataCache();
78
+ if (!cache) return [];
79
+ return resolveDirectToolNames(config, cache, getToolPrefix(config.settings?.toolPrefix), mcpDirectTools);
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function loadMetadataCache(): MetadataCache | null {
86
+ const cachePath = path.join(getAgentDir(), "mcp-cache.json");
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ if (!parsed || typeof parsed !== "object") return null;
95
+ const raw = parsed as Record<string, unknown>;
96
+ if (raw.version !== CACHE_VERSION || !raw.servers || typeof raw.servers !== "object" || Array.isArray(raw.servers)) {
97
+ return null;
98
+ }
99
+ return raw as unknown as MetadataCache;
100
+ }
101
+
102
+ function loadMcpConfig(cwd: string): McpConfig {
103
+ let config: McpConfig = { mcpServers: {} };
104
+ for (const sourcePath of getConfigPaths(cwd)) {
105
+ const loaded = readConfig(sourcePath);
106
+ if (!loaded) continue;
107
+ config = mergeConfigs(config, expandImports(loaded, cwd));
108
+ }
109
+ return config;
110
+ }
111
+
112
+ function getConfigPaths(cwd: string): string[] {
113
+ const piGlobalPath = path.join(getAgentDir(), "mcp.json");
114
+ const projectPath = path.resolve(cwd, ".mcp.json");
115
+ const projectPiPath = path.resolve(cwd, ".pi", "mcp.json");
116
+ const sources: string[] = [];
117
+ if (GENERIC_GLOBAL_CONFIG_PATH !== piGlobalPath) sources.push(GENERIC_GLOBAL_CONFIG_PATH);
118
+ sources.push(piGlobalPath);
119
+ if (projectPath !== piGlobalPath) sources.push(projectPath);
120
+ if (projectPiPath !== piGlobalPath && projectPiPath !== projectPath) sources.push(projectPiPath);
121
+ return sources;
122
+ }
123
+
124
+ function readConfig(configPath: string): McpConfig | null {
125
+ let parsed: unknown;
126
+ try {
127
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
128
+ } catch {
129
+ return null;
130
+ }
131
+ return validateConfig(parsed);
132
+ }
133
+
134
+ function validateConfig(raw: unknown): McpConfig {
135
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { mcpServers: {} };
136
+ const obj = raw as Record<string, unknown>;
137
+ const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {};
138
+ return {
139
+ mcpServers: servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {},
140
+ imports: Array.isArray(obj.imports) ? obj.imports.filter((value): value is ImportKind => isImportKind(value)) : undefined,
141
+ settings: obj.settings && typeof obj.settings === "object" && !Array.isArray(obj.settings)
142
+ ? obj.settings as McpConfig["settings"]
143
+ : undefined,
144
+ };
145
+ }
146
+
147
+ function mergeConfigs(base: McpConfig, next: McpConfig): McpConfig {
148
+ const imports = [...(base.imports ?? []), ...(next.imports ?? [])];
149
+ return {
150
+ mcpServers: { ...base.mcpServers, ...next.mcpServers },
151
+ imports: imports.length ? [...new Set(imports)] : undefined,
152
+ settings: next.settings ? { ...base.settings, ...next.settings } : base.settings,
153
+ };
154
+ }
155
+
156
+ function expandImports(config: McpConfig, cwd: string): McpConfig {
157
+ if (!config.imports?.length) return config;
158
+
159
+ const importedServers: Record<string, ServerEntry> = {};
160
+ for (const importKind of config.imports) {
161
+ const importPath = resolveImportPath(importKind, cwd);
162
+ if (!importPath) continue;
163
+ let imported: unknown;
164
+ try {
165
+ imported = JSON.parse(fs.readFileSync(importPath, "utf-8"));
166
+ } catch {
167
+ continue;
168
+ }
169
+ for (const [name, definition] of Object.entries(extractServers(imported, importKind))) {
170
+ if (!importedServers[name]) importedServers[name] = definition;
171
+ }
172
+ }
173
+
174
+ return {
175
+ imports: config.imports,
176
+ settings: config.settings,
177
+ mcpServers: { ...importedServers, ...config.mcpServers },
178
+ };
179
+ }
180
+
181
+ function resolveImportPath(importKind: ImportKind, cwd: string): string | null {
182
+ for (const candidate of IMPORT_PATHS[importKind]) {
183
+ const fullPath = candidate.startsWith(".") ? path.resolve(cwd, candidate) : candidate;
184
+ if (fs.existsSync(fullPath)) return fullPath;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function extractServers(config: unknown, kind: ImportKind): Record<string, ServerEntry> {
190
+ if (!config || typeof config !== "object" || Array.isArray(config)) return {};
191
+ const obj = config as Record<string, unknown>;
192
+ const servers = kind === "cursor" || kind === "windsurf" || kind === "vscode"
193
+ ? obj.mcpServers ?? obj["mcp-servers"]
194
+ : obj.mcpServers;
195
+ return servers && typeof servers === "object" && !Array.isArray(servers) ? servers as Record<string, ServerEntry> : {};
196
+ }
197
+
198
+ function resolveDirectToolNames(config: McpConfig, cache: MetadataCache, prefix: ToolPrefix, envOverride: string[]): string[] {
199
+ const names: string[] = [];
200
+ const seenNames = new Set<string>();
201
+ const { servers: selectedServers, tools: selectedTools } = parseSelections(envOverride);
202
+
203
+ for (const [serverName, definition] of Object.entries(config.mcpServers)) {
204
+ const serverCache = cache.servers[serverName];
205
+ if (!isServerCacheValid(serverCache, definition)) continue;
206
+
207
+ const toolFilter = selectedServers.has(serverName)
208
+ ? true
209
+ : selectedTools.get(serverName);
210
+ if (!toolFilter) continue;
211
+
212
+ for (const tool of Array.isArray(serverCache.tools) ? serverCache.tools : []) {
213
+ if (typeof tool?.name !== "string" || !tool.name) continue;
214
+ if (toolFilter !== true && !toolFilter.has(tool.name)) continue;
215
+ if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue;
216
+ const prefixedName = formatToolName(tool.name, serverName, prefix);
217
+ if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
218
+ seenNames.add(prefixedName);
219
+ names.push(prefixedName);
220
+ }
221
+
222
+ if (definition.exposeResources === false) continue;
223
+ for (const resource of Array.isArray(serverCache.resources) ? serverCache.resources : []) {
224
+ if (typeof resource?.name !== "string" || !resource.name || typeof resource.uri !== "string" || !resource.uri) continue;
225
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
226
+ if (toolFilter !== true && !toolFilter.has(baseName)) continue;
227
+ if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue;
228
+ const prefixedName = formatToolName(baseName, serverName, prefix);
229
+ if (BUILTIN_TOOL_NAMES.has(prefixedName) || seenNames.has(prefixedName)) continue;
230
+ seenNames.add(prefixedName);
231
+ names.push(prefixedName);
232
+ }
233
+ }
234
+
235
+ return names;
236
+ }
237
+
238
+ function parseSelections(selections: string[]): { servers: Set<string>; tools: Map<string, Set<string>> } {
239
+ const servers = new Set<string>();
240
+ const tools = new Map<string, Set<string>>();
241
+ for (let item of selections) {
242
+ item = item.replace(/\/+$/, "");
243
+ if (item.includes("/")) {
244
+ const [server, tool] = item.split("/", 2);
245
+ if (server && tool) {
246
+ if (!tools.has(server)) tools.set(server, new Set());
247
+ tools.get(server)!.add(tool);
248
+ } else if (server) {
249
+ servers.add(server);
250
+ }
251
+ } else if (item) {
252
+ servers.add(item);
253
+ }
254
+ }
255
+ return { servers, tools };
256
+ }
257
+
258
+ function isServerCacheValid(entry: ServerCacheEntry | undefined, definition: ServerEntry): entry is ServerCacheEntry {
259
+ if (!entry || entry.configHash !== computeMcpServerHash(definition)) return false;
260
+ if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false;
261
+ return Date.now() - entry.cachedAt <= CACHE_MAX_AGE_MS;
262
+ }
263
+
264
+ export function computeMcpServerHash(definition: ServerEntry): string {
265
+ const identity: Record<string, unknown> = {
266
+ command: definition.command,
267
+ args: definition.args,
268
+ env: interpolateEnvRecord(definition.env),
269
+ cwd: resolveConfigPath(definition.cwd),
270
+ url: definition.url,
271
+ headers: interpolateEnvRecord(definition.headers),
272
+ auth: definition.auth,
273
+ bearerToken: resolveBearerToken(definition),
274
+ bearerTokenEnv: definition.bearerTokenEnv,
275
+ exposeResources: definition.exposeResources,
276
+ excludeTools: definition.excludeTools,
277
+ };
278
+ return createHash("sha256").update(stableStringify(identity)).digest("hex");
279
+ }
280
+
281
+ function getToolPrefix(value: unknown): ToolPrefix {
282
+ return value === "none" || value === "short" || value === "server" ? value : "server";
283
+ }
284
+
285
+ function isImportKind(value: unknown): value is ImportKind {
286
+ return typeof value === "string" && Object.hasOwn(IMPORT_PATHS, value);
287
+ }
288
+
289
+ function getServerPrefix(serverName: string, mode: ToolPrefix): string {
290
+ if (mode === "none") return "";
291
+ if (mode === "short") {
292
+ const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
293
+ return short || "mcp";
294
+ }
295
+ return serverName.replace(/-/g, "_");
296
+ }
297
+
298
+ function formatToolName(toolName: string, serverName: string, prefix: ToolPrefix): string {
299
+ const serverPrefix = getServerPrefix(serverName, prefix);
300
+ return serverPrefix ? `${serverPrefix}_${toolName}` : toolName;
301
+ }
302
+
303
+ function isToolExcluded(toolName: string, serverName: string, prefix: ToolPrefix, excludeTools: unknown): boolean {
304
+ if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false;
305
+ const candidates = new Set([
306
+ normalizeToolName(toolName),
307
+ normalizeToolName(formatToolName(toolName, serverName, prefix)),
308
+ normalizeToolName(formatToolName(toolName, serverName, "server")),
309
+ normalizeToolName(formatToolName(toolName, serverName, "short")),
310
+ ]);
311
+ return excludeTools.some((excluded) => typeof excluded === "string" && candidates.has(normalizeToolName(excluded)));
312
+ }
313
+
314
+ function normalizeToolName(value: string): string {
315
+ return value.replace(/-/g, "_");
316
+ }
317
+
318
+ function resourceNameToToolName(name: string): string {
319
+ let result = name
320
+ .replace(/[^a-zA-Z0-9]/g, "_")
321
+ .replace(/_+/g, "_")
322
+ .replace(/^_+/, "")
323
+ .replace(/_+$/, "")
324
+ .toLowerCase();
325
+ if (!result || /^\d/.test(result)) result = `resource${result ? `_${result}` : ""}`;
326
+ return result;
327
+ }
328
+
329
+ function interpolateEnvRecord(values: Record<string, string> | undefined): Record<string, string> | undefined {
330
+ if (!values || typeof values !== "object" || Array.isArray(values)) return undefined;
331
+ const resolved: Record<string, string> = {};
332
+ for (const [key, value] of Object.entries(values)) {
333
+ if (typeof value === "string") resolved[key] = interpolateEnvVars(value);
334
+ }
335
+ return resolved;
336
+ }
337
+
338
+ function interpolateEnvVars(value: string): string {
339
+ return value
340
+ .replace(/\$\{(\w+)\}/g, (_, name: string) => process.env[name] ?? "")
341
+ .replace(/\$env:(\w+)/g, (_, name: string) => process.env[name] ?? "");
342
+ }
343
+
344
+ function resolveConfigPath(value: string | undefined): string | undefined {
345
+ if (typeof value !== "string") return undefined;
346
+ const resolved = interpolateEnvVars(value);
347
+ if (resolved === "~") return os.homedir();
348
+ if (resolved.startsWith("~/") || resolved.startsWith("~\\")) return path.join(os.homedir(), resolved.slice(2));
349
+ return resolved;
350
+ }
351
+
352
+ function resolveBearerToken(definition: Pick<ServerEntry, "bearerToken" | "bearerTokenEnv">): string | undefined {
353
+ if (typeof definition.bearerToken === "string") return interpolateEnvVars(definition.bearerToken);
354
+ return typeof definition.bearerTokenEnv === "string" ? process.env[definition.bearerTokenEnv] : undefined;
355
+ }
356
+
357
+ function stableStringify(value: unknown): string {
358
+ if (value === null || value === undefined || typeof value !== "object") {
359
+ const serialized = JSON.stringify(value);
360
+ return serialized === undefined ? "undefined" : serialized;
361
+ }
362
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
363
+ const obj = value as Record<string, unknown>;
364
+ return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
365
+ }