@pi-agents/orchid 0.1.0-beta.0
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 +41 -0
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/agents/AGENTS-MANIFEST.md +42 -0
- package/agents/brain.md +42 -0
- package/agents/context-builder.md +46 -0
- package/agents/delegate.md +12 -0
- package/agents/dev-1.md +42 -0
- package/agents/oracle.md +73 -0
- package/agents/planner.md +55 -0
- package/agents/researcher.md +52 -0
- package/agents/reviewer.md +79 -0
- package/agents/scout.md +50 -0
- package/agents/tester.md +45 -0
- package/agents/worker.md +55 -0
- package/extensions/ralph.ts +1 -0
- package/extensions/reviewer-extension.ts +125 -0
- package/extensions/task-orchestrator.ts +28 -0
- package/package.json +63 -0
- package/prompts/gather-context-and-clarify.md +13 -0
- package/prompts/parallel-cleanup.md +59 -0
- package/prompts/parallel-context-build.md +53 -0
- package/prompts/parallel-handoff-plan.md +59 -0
- package/prompts/parallel-research.md +50 -0
- package/prompts/parallel-review.md +54 -0
- package/prompts/review-loop.md +41 -0
- package/skills/orchid/SKILL.md +214 -0
- package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
- package/skills/orchid/orchid-converge/SKILL.md +124 -0
- package/skills/orchid/orchid-decompose/SKILL.md +201 -0
- package/skills/orchid/orchid-doctor/SKILL.md +162 -0
- package/skills/orchid/orchid-investigate/SKILL.md +102 -0
- package/skills/orchid/orchid-launch/SKILL.md +147 -0
- package/skills/ralph/SKILL.md +73 -0
- package/skills/subagents/pi-subagents/SKILL.md +813 -0
- package/src/index.ts +7 -0
- package/src/orchestrator/abort.ts +534 -0
- package/src/orchestrator/agent-bridge-extension.ts +1020 -0
- package/src/orchestrator/agent-host.ts +954 -0
- package/src/orchestrator/cleanup.ts +776 -0
- package/src/orchestrator/config-loader.ts +1412 -0
- package/src/orchestrator/config-schema.ts +690 -0
- package/src/orchestrator/config.ts +81 -0
- package/src/orchestrator/context-window.ts +66 -0
- package/src/orchestrator/diagnostic-reports.ts +475 -0
- package/src/orchestrator/diagnostics.ts +394 -0
- package/src/orchestrator/discovery.ts +1833 -0
- package/src/orchestrator/engine-worker.ts +415 -0
- package/src/orchestrator/engine.ts +5940 -0
- package/src/orchestrator/execution.ts +3104 -0
- package/src/orchestrator/extension.ts +5934 -0
- package/src/orchestrator/formatting.ts +785 -0
- package/src/orchestrator/git.ts +88 -0
- package/src/orchestrator/index.ts +28 -0
- package/src/orchestrator/lane-runner.ts +1787 -0
- package/src/orchestrator/mailbox.ts +780 -0
- package/src/orchestrator/merge.ts +3414 -0
- package/src/orchestrator/messages.ts +1062 -0
- package/src/orchestrator/migrations.ts +278 -0
- package/src/orchestrator/naming.ts +117 -0
- package/src/orchestrator/path-resolver.ts +275 -0
- package/src/orchestrator/persistence.ts +2625 -0
- package/src/orchestrator/process-registry.ts +452 -0
- package/src/orchestrator/quality-gate.ts +1085 -0
- package/src/orchestrator/resume.ts +3488 -0
- package/src/orchestrator/sessions.ts +57 -0
- package/src/orchestrator/settings-loader.ts +136 -0
- package/src/orchestrator/settings-tui.ts +2208 -0
- package/src/orchestrator/sidecar-telemetry.ts +267 -0
- package/src/orchestrator/supervisor.ts +4548 -0
- package/src/orchestrator/task-executor-core.ts +675 -0
- package/src/orchestrator/tmux-compat.ts +37 -0
- package/src/orchestrator/tool-allowlist-constants.ts +37 -0
- package/src/orchestrator/types.ts +4465 -0
- package/src/orchestrator/verification.ts +547 -0
- package/src/orchestrator/waves.ts +1564 -0
- package/src/orchestrator/workspace.ts +707 -0
- package/src/orchestrator/worktree.ts +2725 -0
- package/src/ralph/index.ts +825 -0
- package/src/subagents/agents/agent-management.ts +648 -0
- package/src/subagents/agents/agent-scope.ts +6 -0
- package/src/subagents/agents/agent-selection.ts +23 -0
- package/src/subagents/agents/agent-serializer.ts +86 -0
- package/src/subagents/agents/agents.ts +832 -0
- package/src/subagents/agents/chain-serializer.ts +137 -0
- package/src/subagents/agents/frontmatter.ts +29 -0
- package/src/subagents/agents/identity.ts +30 -0
- package/src/subagents/agents/skills.ts +632 -0
- package/src/subagents/extension/config.ts +16 -0
- package/src/subagents/extension/control-notices.ts +92 -0
- package/src/subagents/extension/doctor.ts +199 -0
- package/src/subagents/extension/fanout-child.ts +170 -0
- package/src/subagents/extension/index.ts +573 -0
- package/src/subagents/extension/schemas.ts +168 -0
- package/src/subagents/intercom/intercom-bridge.ts +379 -0
- package/src/subagents/intercom/result-intercom.ts +377 -0
- package/src/subagents/runs/background/async-execution.ts +712 -0
- package/src/subagents/runs/background/async-job-tracker.ts +310 -0
- package/src/subagents/runs/background/async-resume.ts +345 -0
- package/src/subagents/runs/background/async-status.ts +325 -0
- package/src/subagents/runs/background/completion-dedupe.ts +63 -0
- package/src/subagents/runs/background/notify.ts +108 -0
- package/src/subagents/runs/background/parallel-groups.ts +45 -0
- package/src/subagents/runs/background/result-watcher.ts +307 -0
- package/src/subagents/runs/background/run-id-resolver.ts +83 -0
- package/src/subagents/runs/background/run-status.ts +269 -0
- package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
- package/src/subagents/runs/background/subagent-runner.ts +1808 -0
- package/src/subagents/runs/background/top-level-async.ts +13 -0
- package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
- package/src/subagents/runs/foreground/chain-execution.ts +938 -0
- package/src/subagents/runs/foreground/execution.ts +918 -0
- package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
- package/src/subagents/runs/shared/completion-guard.ts +147 -0
- package/src/subagents/runs/shared/long-running-guard.ts +175 -0
- package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
- package/src/subagents/runs/shared/model-fallback.ts +103 -0
- package/src/subagents/runs/shared/nested-events.ts +819 -0
- package/src/subagents/runs/shared/nested-path.ts +52 -0
- package/src/subagents/runs/shared/nested-render.ts +115 -0
- package/src/subagents/runs/shared/parallel-utils.ts +109 -0
- package/src/subagents/runs/shared/pi-args.ts +220 -0
- package/src/subagents/runs/shared/pi-spawn.ts +115 -0
- package/src/subagents/runs/shared/run-history.ts +60 -0
- package/src/subagents/runs/shared/single-output.ts +164 -0
- package/src/subagents/runs/shared/subagent-control.ts +226 -0
- package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
- package/src/subagents/runs/shared/worktree.ts +577 -0
- package/src/subagents/shared/artifacts.ts +98 -0
- package/src/subagents/shared/atomic-json.ts +16 -0
- package/src/subagents/shared/file-coalescer.ts +40 -0
- package/src/subagents/shared/fork-context.ts +76 -0
- package/src/subagents/shared/formatters.ts +133 -0
- package/src/subagents/shared/jsonl-writer.ts +81 -0
- package/src/subagents/shared/model-info.ts +78 -0
- package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
- package/src/subagents/shared/session-identity.ts +10 -0
- package/src/subagents/shared/session-tokens.ts +44 -0
- package/src/subagents/shared/settings.ts +397 -0
- package/src/subagents/shared/status-format.ts +49 -0
- package/src/subagents/shared/types.ts +822 -0
- package/src/subagents/shared/utils.ts +450 -0
- package/src/subagents/slash/prompt-template-bridge.ts +397 -0
- package/src/subagents/slash/slash-bridge.ts +174 -0
- package/src/subagents/slash/slash-commands.ts +528 -0
- package/src/subagents/slash/slash-live-state.ts +292 -0
- package/src/subagents/tui/render-helpers.ts +80 -0
- package/src/subagents/tui/render.ts +1358 -0
- package/templates/agents/local/supervisor.md +33 -0
- package/templates/agents/local/task-merger.md +27 -0
- package/templates/agents/local/task-reviewer.md +30 -0
- package/templates/agents/local/task-worker.md +34 -0
- package/templates/agents/supervisor-routing.md +92 -0
- package/templates/agents/supervisor.md +229 -0
- package/templates/agents/task-merger.md +214 -0
- package/templates/agents/task-reviewer.md +260 -0
- package/templates/agents/task-worker-segment.md +44 -0
- package/templates/agents/task-worker.md +557 -0
- package/templates/tasks/CONTEXT.md +30 -0
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import { isMutatingBashCommand } from "./long-running-guard.ts";
|
|
3
|
+
|
|
4
|
+
const REVIEW_ONLY_PATTERNS = [
|
|
5
|
+
/\breview only\b/i,
|
|
6
|
+
/\bsuggest fixes only\b/i,
|
|
7
|
+
/\bonly return findings\b/i,
|
|
8
|
+
/\breturn findings only\b/i,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const REVIEWER_REQUIRED_EDIT_PATTERNS = [
|
|
12
|
+
/\bmust\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
13
|
+
/\brequired\s+to\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
14
|
+
/\bregardless\s+of\s+findings\b/i,
|
|
15
|
+
/\balways\s+(?:edit|modify|change|fix|patch|apply)\b/i,
|
|
16
|
+
/\bapply\s+(?:the\s+)?fix(?:es)?\s+directly\b/i,
|
|
17
|
+
/\bmake\s+(?:the\s+)?code\s+changes\b/i,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const EXPLICIT_NO_EDIT_PATTERNS = [
|
|
21
|
+
/\bdo not edit\b/i,
|
|
22
|
+
/\bdon't edit\b/i,
|
|
23
|
+
/\bdo not modify\b/i,
|
|
24
|
+
/\bdo not change files\b/i,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const SCOPED_NO_EDIT_CONSTRAINT_PATTERNS = [
|
|
28
|
+
/\bdo not edit files?\s+outside\b/i,
|
|
29
|
+
/\bdo not edit\s+outside\b/i,
|
|
30
|
+
/\bdo not edit\s+unrelated files?\b/i,
|
|
31
|
+
/\bdo not change\s+unrelated files?\b/i,
|
|
32
|
+
/\bdo not modify\s+unrelated files?\b/i,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const RESEARCH_AGENT_PATTERNS = [
|
|
36
|
+
/\binvestigate\b/i,
|
|
37
|
+
/\bscout\b/i,
|
|
38
|
+
/\bresearch(?:er)?\b/i,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const WORKER_IMPLEMENTATION_PATTERNS = [
|
|
42
|
+
/\b(?:implement|fix|edit|modify|patch|refactor|delete)\b/i,
|
|
43
|
+
/\b(?:update|add|remove|replace|create)\b(?!\s+(?:(?:a|an|the)\s+)?(?:report|summary|findings?)(?:\b|$))/i,
|
|
44
|
+
/\bapply\s+(?:the\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
|
|
45
|
+
/\bmake\s+(?:the\s+)?changes\b/i,
|
|
46
|
+
/\bdo those fixes\b/i,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const GENERAL_IMPLEMENTATION_PATTERNS = [
|
|
50
|
+
/\b(?:implement|fix|edit|modify|patch|refactor)\b/i,
|
|
51
|
+
/\bapply\s+(?:the\s+)?(?:changes?|fix(?:es)?|patch)\b/i,
|
|
52
|
+
/\bmake\s+(?:the\s+)?changes\b/i,
|
|
53
|
+
/\bdo those fixes\b/i,
|
|
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
|
+
];
|
|
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
|
+
]);
|
|
68
|
+
|
|
69
|
+
interface CompletionMutationGuardInput {
|
|
70
|
+
agent: string;
|
|
71
|
+
task: string;
|
|
72
|
+
messages: Message[];
|
|
73
|
+
tools?: string[];
|
|
74
|
+
mcpDirectTools?: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CompletionMutationGuardResult {
|
|
78
|
+
expectedMutation: boolean;
|
|
79
|
+
attemptedMutation: boolean;
|
|
80
|
+
triggered: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stripFrameworkInstructions(task: string): string {
|
|
84
|
+
return task
|
|
85
|
+
.split("\n")
|
|
86
|
+
.filter((line) => !/^\s*\[(?:Write to|Read from):/i.test(line))
|
|
87
|
+
.filter((line) => !/^\s*(?:Create and maintain progress at:|Update progress at:|Write your findings to:)/i.test(line))
|
|
88
|
+
.join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stripScopedNoEditConstraints(task: string): string {
|
|
92
|
+
let stripped = task;
|
|
93
|
+
for (const pattern of SCOPED_NO_EDIT_CONSTRAINT_PATTERNS) {
|
|
94
|
+
stripped = stripped.replace(pattern, " ");
|
|
95
|
+
}
|
|
96
|
+
return stripped;
|
|
97
|
+
}
|
|
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
|
+
|
|
106
|
+
export function expectsImplementationMutation(agent: string, task: string): boolean {
|
|
107
|
+
const taskText = stripFrameworkInstructions(task);
|
|
108
|
+
const taskTextWithoutScopedConstraints = stripScopedNoEditConstraints(taskText);
|
|
109
|
+
if (REVIEW_ONLY_PATTERNS.some((pattern) => pattern.test(taskTextWithoutScopedConstraints))) return false;
|
|
110
|
+
if (EXPLICIT_NO_EDIT_PATTERNS.some((pattern) => pattern.test(taskTextWithoutScopedConstraints))) return false;
|
|
111
|
+
|
|
112
|
+
if (RESEARCH_AGENT_PATTERNS.some((pattern) => pattern.test(agent))) return false;
|
|
113
|
+
if (/\breviewer\b/i.test(agent)) return REVIEWER_REQUIRED_EDIT_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
114
|
+
|
|
115
|
+
const workerIntent = agent === "worker" && WORKER_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
116
|
+
if (workerIntent) return true;
|
|
117
|
+
|
|
118
|
+
return GENERAL_IMPLEMENTATION_PATTERNS.some((pattern) => pattern.test(taskText));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function hasMutationToolCall(messages: Message[]): boolean {
|
|
122
|
+
for (const message of messages) {
|
|
123
|
+
if (message.role !== "assistant") continue;
|
|
124
|
+
for (const part of message.content) {
|
|
125
|
+
if (part.type !== "toolCall") continue;
|
|
126
|
+
if (part.name === "edit" || part.name === "write") return true;
|
|
127
|
+
if (part.name !== "bash") continue;
|
|
128
|
+
const args = typeof part.arguments === "object" && part.arguments !== null && !Array.isArray(part.arguments)
|
|
129
|
+
? part.arguments as Record<string, unknown>
|
|
130
|
+
: {};
|
|
131
|
+
if (typeof args.command === "string" && isMutatingBashCommand(args.command)) return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
|
|
138
|
+
const expectedMutation = declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)
|
|
139
|
+
? false
|
|
140
|
+
: expectsImplementationMutation(input.agent, input.task);
|
|
141
|
+
const attemptedMutation = hasMutationToolCall(input.messages);
|
|
142
|
+
return {
|
|
143
|
+
expectedMutation,
|
|
144
|
+
attemptedMutation,
|
|
145
|
+
triggered: expectedMutation && !attemptedMutation,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ResolvedControlConfig } from "../../shared/types.ts";
|
|
2
|
+
|
|
3
|
+
interface LongRunningNoticeMetrics {
|
|
4
|
+
startedAt: number;
|
|
5
|
+
now: number;
|
|
6
|
+
turns: number;
|
|
7
|
+
tokens: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type LongRunningTriggerReason = "time_threshold" | "turn_threshold" | "token_threshold";
|
|
11
|
+
|
|
12
|
+
interface FailedMutatingAttempt {
|
|
13
|
+
tool: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
error: string;
|
|
16
|
+
ts: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MutatingFailureState {
|
|
20
|
+
consecutiveFailures: number;
|
|
21
|
+
lastFailureAt?: number;
|
|
22
|
+
recentFailures: FailedMutatingAttempt[];
|
|
23
|
+
lastMutatingPath?: string;
|
|
24
|
+
repeatedPathFailures: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MUTATING_BASH_PATTERNS = [
|
|
28
|
+
/(^|[;&|()\s])rm\s+/,
|
|
29
|
+
/(^|[;&|()\s])mv\s+/,
|
|
30
|
+
/(^|[;&|()\s])cp\s+/,
|
|
31
|
+
/(^|[;&|()\s])mkdir\s+/,
|
|
32
|
+
/(^|[;&|()\s])touch\s+/,
|
|
33
|
+
/(^|[;&|()\s])git\s+apply\b/,
|
|
34
|
+
/(^|[;&|()\s])patch\s+/,
|
|
35
|
+
/(^|[;&|()\s])sed\s+[^\n;&|]*\s-i\b/,
|
|
36
|
+
/(^|[;&|()\s])perl\s+[^\n;&|]*\s-pi\b/,
|
|
37
|
+
/(^|[;&|()]|\n)\s*tee\s+[^|&;]+/,
|
|
38
|
+
/\b(writeFile|writeFileSync|appendFile|appendFileSync)\b/,
|
|
39
|
+
/\bwrite_text\s*\(/,
|
|
40
|
+
/\bopen\s*\([^)]*,\s*["'][wa]/,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const MUTATING_FAILURE_HINTS = [
|
|
44
|
+
"failed",
|
|
45
|
+
"error",
|
|
46
|
+
"no exact match",
|
|
47
|
+
"did not match",
|
|
48
|
+
"malformed",
|
|
49
|
+
"rejected",
|
|
50
|
+
"unable",
|
|
51
|
+
"cannot",
|
|
52
|
+
"could not",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function resolveCurrentPath(toolName: string | undefined, args: Record<string, unknown> | undefined): string | undefined {
|
|
56
|
+
if (!toolName || !args) return undefined;
|
|
57
|
+
const direct = ["path", "file", "filename", "target", "cwd"];
|
|
58
|
+
for (const key of direct) {
|
|
59
|
+
const value = args[key];
|
|
60
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
61
|
+
}
|
|
62
|
+
if (toolName === "bash") {
|
|
63
|
+
const command = typeof args.command === "string" ? args.command : undefined;
|
|
64
|
+
if (!command) return undefined;
|
|
65
|
+
const redirect = command.match(/(?:>|>>|tee\s+)(\S+)/);
|
|
66
|
+
if (redirect?.[1]) return redirect[1];
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasUnquotedFileRedirection(command: string): boolean {
|
|
72
|
+
let inSingle = false;
|
|
73
|
+
let inDouble = false;
|
|
74
|
+
for (let i = 0; i < command.length; i++) {
|
|
75
|
+
const char = command[i]!;
|
|
76
|
+
if (char === "'" && !inDouble) {
|
|
77
|
+
inSingle = !inSingle;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (char === '"' && !inSingle) {
|
|
81
|
+
inDouble = !inDouble;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (inSingle || inDouble) continue;
|
|
85
|
+
if (char !== ">") continue;
|
|
86
|
+
if (command[i - 1] === "-") continue;
|
|
87
|
+
const isDouble = command[i + 1] === ">";
|
|
88
|
+
let cursor = i + (isDouble ? 2 : 1);
|
|
89
|
+
while (cursor < command.length && /\s/.test(command[cursor]!)) cursor++;
|
|
90
|
+
if (cursor >= command.length) continue;
|
|
91
|
+
const targetStart = command[cursor]!;
|
|
92
|
+
if (targetStart === "&" || targetStart === "|" || targetStart === ";") continue;
|
|
93
|
+
if (targetStart === "(" || targetStart === ")") continue;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isMutatingBashCommand(command: string): boolean {
|
|
100
|
+
return hasUnquotedFileRedirection(command) || MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(command));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isMutatingTool(toolName: string | undefined, args: Record<string, unknown> | undefined): boolean {
|
|
104
|
+
if (!toolName) return false;
|
|
105
|
+
if (toolName === "edit" || toolName === "write") return true;
|
|
106
|
+
if (toolName !== "bash") return false;
|
|
107
|
+
const command = typeof args?.command === "string" ? args.command : "";
|
|
108
|
+
if (!command.trim()) return false;
|
|
109
|
+
return isMutatingBashCommand(command);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function didMutatingToolFail(text: string): boolean {
|
|
113
|
+
const lowered = text.toLowerCase();
|
|
114
|
+
return MUTATING_FAILURE_HINTS.some((hint) => lowered.includes(hint));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function nextLongRunningTrigger(
|
|
118
|
+
config: ResolvedControlConfig,
|
|
119
|
+
metrics: LongRunningNoticeMetrics,
|
|
120
|
+
): LongRunningTriggerReason | undefined {
|
|
121
|
+
if (metrics.now - metrics.startedAt >= config.activeNoticeAfterMs) return "time_threshold";
|
|
122
|
+
if (config.activeNoticeAfterTurns !== undefined && metrics.turns >= config.activeNoticeAfterTurns) return "turn_threshold";
|
|
123
|
+
if (config.activeNoticeAfterTokens !== undefined && metrics.tokens >= config.activeNoticeAfterTokens) return "token_threshold";
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function resetMutatingFailureState(state: MutatingFailureState): void {
|
|
128
|
+
state.consecutiveFailures = 0;
|
|
129
|
+
state.lastFailureAt = undefined;
|
|
130
|
+
state.recentFailures = [];
|
|
131
|
+
state.lastMutatingPath = undefined;
|
|
132
|
+
state.repeatedPathFailures = 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createMutatingFailureState(): MutatingFailureState {
|
|
136
|
+
return {
|
|
137
|
+
consecutiveFailures: 0,
|
|
138
|
+
recentFailures: [],
|
|
139
|
+
repeatedPathFailures: 0,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function recordMutatingFailure(
|
|
144
|
+
state: MutatingFailureState,
|
|
145
|
+
input: FailedMutatingAttempt,
|
|
146
|
+
windowMs: number,
|
|
147
|
+
): void {
|
|
148
|
+
if (state.lastFailureAt === undefined || input.ts - state.lastFailureAt > windowMs) {
|
|
149
|
+
state.consecutiveFailures = 0;
|
|
150
|
+
state.recentFailures = [];
|
|
151
|
+
state.repeatedPathFailures = 0;
|
|
152
|
+
state.lastMutatingPath = undefined;
|
|
153
|
+
}
|
|
154
|
+
state.lastFailureAt = input.ts;
|
|
155
|
+
state.consecutiveFailures += 1;
|
|
156
|
+
if (input.path && state.lastMutatingPath === input.path) {
|
|
157
|
+
state.repeatedPathFailures += 1;
|
|
158
|
+
} else if (input.path) {
|
|
159
|
+
state.lastMutatingPath = input.path;
|
|
160
|
+
state.repeatedPathFailures = 1;
|
|
161
|
+
}
|
|
162
|
+
state.recentFailures.push(input);
|
|
163
|
+
if (state.recentFailures.length > 3) state.recentFailures.shift();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function shouldEscalateMutatingFailures(state: MutatingFailureState, threshold: number): boolean {
|
|
167
|
+
return state.consecutiveFailures >= threshold || state.repeatedPathFailures >= threshold;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function summarizeRecentMutatingFailures(state: MutatingFailureState): string | undefined {
|
|
171
|
+
if (state.recentFailures.length === 0) return undefined;
|
|
172
|
+
return state.recentFailures
|
|
173
|
+
.map((entry) => `${entry.tool}${entry.path ? `(${entry.path})` : ""}: ${entry.error}`)
|
|
174
|
+
.join(" | ");
|
|
175
|
+
}
|
|
@@ -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
|
+
}
|