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.
- package/CHANGELOG.md +18 -5
- package/README.md +3 -1
- package/package.json +4 -8
- package/src/agents/agent-management.ts +5 -0
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +30 -6
- package/src/agents/skills.ts +25 -23
- package/src/extension/config.ts +16 -0
- package/src/extension/index.ts +7 -23
- package/src/intercom/intercom-bridge.ts +2 -1
- package/src/runs/background/async-execution.ts +6 -3
- package/src/runs/background/async-job-tracker.ts +16 -8
- package/src/runs/background/subagent-runner.ts +18 -7
- package/src/runs/foreground/execution.ts +17 -5
- package/src/runs/foreground/subagent-executor.ts +4 -4
- package/src/runs/shared/completion-guard.ts +23 -1
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/runs/shared/parallel-utils.ts +1 -0
- package/src/runs/shared/pi-args.ts +5 -0
- package/src/runs/shared/run-history.ts +12 -7
- package/src/runs/shared/single-output.ts +12 -2
- package/src/shared/artifacts.ts +2 -2
- package/src/shared/utils.ts +11 -1
- package/src/tui/render.ts +148 -144
|
@@ -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
|
|
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
|
-
|
|
185
|
-
|
|
187
|
+
if (job.status === "queued") {
|
|
188
|
+
job.status = "running";
|
|
189
|
+
job.updatedAt = Date.now();
|
|
190
|
+
}
|
|
186
191
|
} catch (error) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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, {
|
|
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)
|
|
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
|
|
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 :
|
|
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)
|
|
469
|
-
|
|
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({
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|