@martinloop/mcp 0.3.2 → 0.3.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/README.md +112 -107
- package/dist/discovery-support.js +2 -2
- package/dist/prompts.d.ts +2 -1
- package/dist/resources.d.ts +2 -1
- package/dist/resources.js +147 -147
- package/dist/server-validation.js +5 -2
- package/dist/server.js +5 -4
- package/dist/tools/eval.js +29 -5
- package/dist/tools/execution-policy.d.ts +11 -0
- package/dist/tools/execution-policy.js +47 -0
- package/dist/tools/run-dossier.js +2 -2
- package/dist/tools/run-loop.d.ts +2 -1
- package/dist/tools/run-loop.js +15 -2
- package/dist/tools/run-store.js +166 -6
- package/dist/tools/tool-support.d.ts +3 -2
- package/dist/tools/tool-support.js +112 -18
- package/dist/tools/triage-runs.js +31 -6
- package/dist/vendor/adapters/claude-cli.d.ts +1 -1
- package/dist/vendor/adapters/claude-cli.js +71 -20
- package/dist/vendor/adapters/cli-bridge.d.ts +6 -0
- package/dist/vendor/adapters/cli-bridge.js +91 -7
- package/dist/vendor/adapters/codex-launcher.js +65 -8
- package/dist/vendor/adapters/openai-compatible.d.ts +1 -1
- package/dist/vendor/adapters/openai-compatible.js +7 -7
- package/dist/vendor/adapters/verifier-only.js +1 -1
- package/dist/vendor/contracts/adapter-capabilities.d.ts +17 -0
- package/dist/vendor/contracts/adapter-capabilities.js +35 -0
- package/dist/vendor/contracts/context-graph.d.ts +48 -0
- package/dist/vendor/contracts/context-graph.js +26 -0
- package/dist/vendor/contracts/execution-policy.d.ts +62 -0
- package/dist/vendor/contracts/execution-policy.js +34 -0
- package/dist/vendor/contracts/governance.d.ts +6 -3
- package/dist/vendor/contracts/identity.d.ts +29 -0
- package/dist/vendor/contracts/identity.js +30 -0
- package/dist/vendor/contracts/index.d.ts +13 -2
- package/dist/vendor/contracts/index.js +4 -0
- package/dist/vendor/contracts/observation.d.ts +21 -0
- package/dist/vendor/contracts/observation.js +27 -0
- package/dist/vendor/contracts/trajectory.d.ts +26 -0
- package/dist/vendor/contracts/trajectory.js +27 -0
- package/dist/vendor/contracts/verification.d.ts +28 -0
- package/dist/vendor/contracts/verification.js +7 -0
- package/dist/vendor/core/context-graph.d.ts +3 -0
- package/dist/vendor/core/context-graph.js +371 -0
- package/dist/vendor/core/context-integrity.js +1 -1
- package/dist/vendor/core/grounding.js +1 -1
- package/dist/vendor/core/identity.d.ts +20 -0
- package/dist/vendor/core/identity.js +86 -0
- package/dist/vendor/core/index.d.ts +7 -0
- package/dist/vendor/core/index.js +63 -9
- package/dist/vendor/core/leash.js +1 -1
- package/dist/vendor/core/persistence/integrity.d.ts +1 -0
- package/dist/vendor/core/persistence/integrity.js +7 -3
- package/dist/vendor/core/persistence/runs-reader.d.ts +14 -0
- package/dist/vendor/core/persistence/runs-reader.js +61 -4
- package/dist/vendor/core/policy-compiler.d.ts +2 -0
- package/dist/vendor/core/policy-compiler.js +195 -0
- package/dist/vendor/core/policy.d.ts +2 -0
- package/dist/vendor/core/policy.js +71 -4
- package/dist/vendor/core/rollback.js +32 -12
- package/dist/vendor/core/trajectory.d.ts +18 -0
- package/dist/vendor/core/trajectory.js +295 -0
- package/package.json +76 -75
- package/server.json +21 -21
|
@@ -3,6 +3,7 @@ import { readdir, stat } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { evaluateCostGovernor, resolveRunsRoot } from "../vendor/core/index.js";
|
|
5
5
|
import { readAllLoopRecordsSafely } from "./run-store.js";
|
|
6
|
+
export const MARTIN_ENGINE_VALUES = ["claude", "codex", "gemini"];
|
|
6
7
|
const CLI_CACHE_TTL_MS = 60_000;
|
|
7
8
|
const RUN_STORE_CACHE_TTL_MS = 5_000;
|
|
8
9
|
const cliAvailabilityCache = new Map();
|
|
@@ -30,14 +31,16 @@ export function detectCliAvailability(command) {
|
|
|
30
31
|
command,
|
|
31
32
|
available: true,
|
|
32
33
|
locator,
|
|
33
|
-
detail:
|
|
34
|
+
detail: isOnPathDirectly(command, resolvedPath)
|
|
35
|
+
? `${command} is available on PATH.`
|
|
36
|
+
: `${command} found at ${resolvedPath} (not on PATH, auto-discovered).`,
|
|
34
37
|
...(resolvedPath ? { resolvedPath } : {})
|
|
35
38
|
}
|
|
36
39
|
: {
|
|
37
40
|
command,
|
|
38
41
|
available: false,
|
|
39
42
|
locator,
|
|
40
|
-
detail: `${command} is not
|
|
43
|
+
detail: `${command} is not installed. ${suggestInstallCommand(command)}`
|
|
41
44
|
};
|
|
42
45
|
cliAvailabilityCache.set(cacheKey, {
|
|
43
46
|
expiresAt: Date.now() + CLI_CACHE_TTL_MS,
|
|
@@ -48,13 +51,6 @@ export function detectCliAvailability(command) {
|
|
|
48
51
|
function findCommandOnPath(command) {
|
|
49
52
|
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
|
50
53
|
const rawPath = pathKey ? process.env[pathKey] : undefined;
|
|
51
|
-
if (!rawPath) {
|
|
52
|
-
return undefined;
|
|
53
|
-
}
|
|
54
|
-
const pathEntries = rawPath
|
|
55
|
-
.split(process.platform === "win32" ? ";" : ":")
|
|
56
|
-
.map((entry) => entry.trim())
|
|
57
|
-
.filter(Boolean);
|
|
58
54
|
const hasExtension = /\.[A-Za-z0-9]+$/u.test(command);
|
|
59
55
|
const candidateNames = process.platform === "win32" && !hasExtension
|
|
60
56
|
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
@@ -63,7 +59,25 @@ function findCommandOnPath(command) {
|
|
|
63
59
|
.filter(Boolean)
|
|
64
60
|
.map((extension) => `${command}${extension.toLowerCase()}`)
|
|
65
61
|
: [command];
|
|
66
|
-
|
|
62
|
+
// 1. Search PATH entries
|
|
63
|
+
if (rawPath) {
|
|
64
|
+
const pathEntries = rawPath
|
|
65
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
66
|
+
.map((entry) => entry.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
for (const directory of pathEntries) {
|
|
69
|
+
for (const candidateName of candidateNames) {
|
|
70
|
+
const candidatePath = join(directory, candidateName);
|
|
71
|
+
if (isExecutablePath(candidatePath)) {
|
|
72
|
+
return candidatePath;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 2. Search common install locations when PATH didn't find it.
|
|
78
|
+
// This catches CLIs installed via npm/brew/pip that aren't on the
|
|
79
|
+
// current shell's PATH (common in IDEs, CI, and agent environments).
|
|
80
|
+
for (const directory of discoverCommonInstallDirectories(command)) {
|
|
67
81
|
for (const candidateName of candidateNames) {
|
|
68
82
|
const candidatePath = join(directory, candidateName);
|
|
69
83
|
if (isExecutablePath(candidatePath)) {
|
|
@@ -73,6 +87,49 @@ function findCommandOnPath(command) {
|
|
|
73
87
|
}
|
|
74
88
|
return undefined;
|
|
75
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Returns directories where CLI tools are commonly installed but may not
|
|
92
|
+
* be on PATH. Checked only as a fallback after PATH search fails.
|
|
93
|
+
*/
|
|
94
|
+
function discoverCommonInstallDirectories(command) {
|
|
95
|
+
const dirs = [];
|
|
96
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
97
|
+
if (process.platform === "win32") {
|
|
98
|
+
// npm global bin (Roaming)
|
|
99
|
+
const appData = process.env.APPDATA;
|
|
100
|
+
if (appData) {
|
|
101
|
+
dirs.push(join(appData, "npm"));
|
|
102
|
+
}
|
|
103
|
+
// Codex desktop install
|
|
104
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
105
|
+
if (localAppData) {
|
|
106
|
+
dirs.push(join(localAppData, "OpenAI", "Codex", "bin"));
|
|
107
|
+
}
|
|
108
|
+
// Scoop
|
|
109
|
+
if (home) {
|
|
110
|
+
dirs.push(join(home, "scoop", "shims"));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
// Common Unix install paths
|
|
115
|
+
dirs.push("/usr/local/bin", "/opt/homebrew/bin");
|
|
116
|
+
if (home) {
|
|
117
|
+
dirs.push(join(home, ".local", "bin"), // pip, pipx
|
|
118
|
+
join(home, ".npm-global", "bin"), // npm prefix
|
|
119
|
+
join(home, ".bun", "bin"), // bun
|
|
120
|
+
join(home, ".cargo", "bin"), // cargo
|
|
121
|
+
join(home, "go", "bin") // go install
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
// nvm managed node
|
|
125
|
+
const nvmDir = process.env.NVM_DIR;
|
|
126
|
+
if (nvmDir) {
|
|
127
|
+
const nvmBin = join(nvmDir, "current", "bin");
|
|
128
|
+
dirs.push(nvmBin);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return dirs.filter(Boolean);
|
|
132
|
+
}
|
|
76
133
|
function isExecutablePath(candidatePath) {
|
|
77
134
|
try {
|
|
78
135
|
accessSync(candidatePath, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
@@ -82,6 +139,39 @@ function isExecutablePath(candidatePath) {
|
|
|
82
139
|
return false;
|
|
83
140
|
}
|
|
84
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Returns true if the resolved path is inside a directory that's already
|
|
144
|
+
* on PATH (i.e., it was found via normal PATH lookup, not auto-discovery).
|
|
145
|
+
*/
|
|
146
|
+
function isOnPathDirectly(command, resolvedPath) {
|
|
147
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path");
|
|
148
|
+
const rawPath = pathKey ? process.env[pathKey] : undefined;
|
|
149
|
+
if (!rawPath)
|
|
150
|
+
return false;
|
|
151
|
+
const pathEntries = rawPath
|
|
152
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
153
|
+
.map((entry) => entry.trim().replace(/[\\/]+$/u, ""))
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
const resolvedDir = resolvedPath
|
|
156
|
+
.replace(/[\\/][^\\/]+$/u, "")
|
|
157
|
+
.replace(/[\\/]+$/u, "");
|
|
158
|
+
return pathEntries.some((entry) => entry.toLowerCase() === resolvedDir.toLowerCase());
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Returns a platform-specific one-liner install command for a known CLI.
|
|
162
|
+
*/
|
|
163
|
+
function suggestInstallCommand(command) {
|
|
164
|
+
const npmInstalls = {
|
|
165
|
+
claude: "npm install -g @anthropic-ai/claude-code",
|
|
166
|
+
codex: "npm install -g @openai/codex",
|
|
167
|
+
gemini: "npm install -g @google/gemini-cli"
|
|
168
|
+
};
|
|
169
|
+
const install = npmInstalls[command];
|
|
170
|
+
if (install) {
|
|
171
|
+
return `Install with: ${install}`;
|
|
172
|
+
}
|
|
173
|
+
return `Install ${command} and ensure it is on PATH.`;
|
|
174
|
+
}
|
|
85
175
|
export function getEngineAvailability(engine) {
|
|
86
176
|
return detectCliAvailability(engine);
|
|
87
177
|
}
|
|
@@ -181,18 +271,22 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
|
|
|
181
271
|
const latestEvidence = selectedEvidence.evidence;
|
|
182
272
|
if (!latestEvidence) {
|
|
183
273
|
return {
|
|
184
|
-
status: "
|
|
274
|
+
status: "not_run",
|
|
185
275
|
eventCount: verificationEvents.length,
|
|
186
276
|
ledgerEventCount: verificationLedgerEvents.length,
|
|
187
277
|
warnings
|
|
188
278
|
};
|
|
189
279
|
}
|
|
280
|
+
const contradicted = latestEvidence.passed === true &&
|
|
281
|
+
warnings.some((warning) => warning.toLowerCase().includes("conflicts for the latest attempt"));
|
|
190
282
|
return {
|
|
191
|
-
status:
|
|
192
|
-
? "
|
|
193
|
-
: latestEvidence.passed ===
|
|
194
|
-
? "
|
|
195
|
-
:
|
|
283
|
+
status: contradicted
|
|
284
|
+
? "contradicted"
|
|
285
|
+
: latestEvidence.passed === true
|
|
286
|
+
? "passed"
|
|
287
|
+
: latestEvidence.passed === false
|
|
288
|
+
? "failed"
|
|
289
|
+
: "not_run",
|
|
196
290
|
eventCount: verificationEvents.length,
|
|
197
291
|
ledgerEventCount: verificationLedgerEvents.length,
|
|
198
292
|
...(latestEvidence.attemptIndex !== undefined ? { latestAttemptIndex: latestEvidence.attemptIndex } : {}),
|
|
@@ -205,7 +299,7 @@ export function buildVerificationSummary(loop, ledgerEvents = []) {
|
|
|
205
299
|
}
|
|
206
300
|
export function resolveReceiptIntegrity(loop) {
|
|
207
301
|
return (loop.receiptIntegrity ?? {
|
|
208
|
-
state: "
|
|
302
|
+
state: "selector_noncanonical",
|
|
209
303
|
reason: "Receipt integrity metadata was not available on the loop record."
|
|
210
304
|
});
|
|
211
305
|
}
|
|
@@ -397,7 +491,7 @@ function selectLatestVerificationEvidence(loop, verificationEvents, verification
|
|
|
397
491
|
.map((candidate) => candidate.passed)
|
|
398
492
|
.filter((candidate) => typeof candidate === "boolean"));
|
|
399
493
|
if (distinctStatuses.size > 1) {
|
|
400
|
-
warnings.push("Verification evidence conflicts for the latest attempt;
|
|
494
|
+
warnings.push("Verification evidence conflicts for the latest attempt; marking verification as contradicted.");
|
|
401
495
|
return { warnings };
|
|
402
496
|
}
|
|
403
497
|
return {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { decideCircuitBreak } from "../vendor/core/index.js";
|
|
2
3
|
import { buildLoopPreview, buildSuggestedPromptNames, buildSuggestedResourceUris, buildVerificationSummary } from "./tool-support.js";
|
|
3
4
|
import { listLoopRecords, readLedgerEvents } from "./run-store.js";
|
|
4
5
|
import { resolveSafeLoopRecordPath } from "../server-validation.js";
|
|
@@ -14,6 +15,16 @@ export async function martinTriageRunsTool(input) {
|
|
|
14
15
|
const findings = await Promise.all(listed.loops.map(async (loop) => {
|
|
15
16
|
try {
|
|
16
17
|
const preview = buildLoopPreview(loop);
|
|
18
|
+
const trajectory = decideCircuitBreak({
|
|
19
|
+
objective: loop.task?.objective ?? loop.loopId,
|
|
20
|
+
verificationPlan: loop.task?.verificationPlan,
|
|
21
|
+
attempts: loop.attempts.map((attempt) => ({
|
|
22
|
+
index: attempt.index,
|
|
23
|
+
summary: attempt.summary,
|
|
24
|
+
failureClass: attempt.failureClass
|
|
25
|
+
})),
|
|
26
|
+
remainingIterations: preview.remainingIterations
|
|
27
|
+
});
|
|
17
28
|
let ledgerEvents = [];
|
|
18
29
|
const canonicalLoopRecordPath = resolveSafeLoopRecordPath(loop.loopId, listed.runsRoot);
|
|
19
30
|
ledgerEvents = await readLedgerEvents({
|
|
@@ -26,7 +37,7 @@ export async function martinTriageRunsTool(input) {
|
|
|
26
37
|
canonicalLoopRecordPath
|
|
27
38
|
});
|
|
28
39
|
const verification = buildVerificationSummary(loop, ledgerEvents);
|
|
29
|
-
return buildTriageFinding(preview, verification);
|
|
40
|
+
return buildTriageFinding(preview, verification, trajectory);
|
|
30
41
|
}
|
|
31
42
|
catch {
|
|
32
43
|
warnings.push(`Skipped triage for '${loop.loopId}' because its run record or verification evidence is unreadable.`);
|
|
@@ -59,13 +70,17 @@ export async function martinTriageRunsTool(input) {
|
|
|
59
70
|
warnings
|
|
60
71
|
};
|
|
61
72
|
}
|
|
62
|
-
function buildTriageFinding(loop, verification) {
|
|
73
|
+
function buildTriageFinding(loop, verification, trajectory) {
|
|
63
74
|
const reasonCodes = [];
|
|
64
75
|
let severity = "low";
|
|
65
76
|
if (verification.status === "failed") {
|
|
66
77
|
reasonCodes.push("verification_failed");
|
|
67
78
|
severity = maxSeverity(severity, "critical");
|
|
68
79
|
}
|
|
80
|
+
if (verification.status === "contradicted") {
|
|
81
|
+
reasonCodes.push("verification_contradicted");
|
|
82
|
+
severity = maxSeverity(severity, "critical");
|
|
83
|
+
}
|
|
69
84
|
if (loop.pressure === "hard_limit" || loop.shouldStop) {
|
|
70
85
|
reasonCodes.push("budget_hard_limit");
|
|
71
86
|
severity = maxSeverity(severity, "critical");
|
|
@@ -78,8 +93,12 @@ function buildTriageFinding(loop, verification) {
|
|
|
78
93
|
reasonCodes.push("diminishing_returns");
|
|
79
94
|
severity = maxSeverity(severity, "high");
|
|
80
95
|
}
|
|
81
|
-
if (
|
|
82
|
-
reasonCodes.push("
|
|
96
|
+
if (trajectory.shouldStop || trajectory.assessment.status === "stalled") {
|
|
97
|
+
reasonCodes.push("trajectory_stalled");
|
|
98
|
+
severity = maxSeverity(severity, "critical");
|
|
99
|
+
}
|
|
100
|
+
if (verification.status === "not_run" && loop.attempts > 0) {
|
|
101
|
+
reasonCodes.push("verification_not_run");
|
|
83
102
|
severity = maxSeverity(severity, "medium");
|
|
84
103
|
}
|
|
85
104
|
if (loop.pressure === "soft_limit") {
|
|
@@ -104,6 +123,9 @@ function buildTriageFinding(loop, verification) {
|
|
|
104
123
|
};
|
|
105
124
|
}
|
|
106
125
|
function summarizeFinding(loop, verification, severity, reasonCodes) {
|
|
126
|
+
if (reasonCodes.includes("trajectory_stalled")) {
|
|
127
|
+
return `Severity ${severity}: ${loop.loopId} should not spend another attempt before operator review.`;
|
|
128
|
+
}
|
|
107
129
|
if (reasonCodes.includes("verification_failed")) {
|
|
108
130
|
return `Severity ${severity}: ${loop.loopId} failed verification after ${loop.attempts} attempt(s).`;
|
|
109
131
|
}
|
|
@@ -113,8 +135,11 @@ function summarizeFinding(loop, verification, severity, reasonCodes) {
|
|
|
113
135
|
if (reasonCodes.includes("status_failed")) {
|
|
114
136
|
return `Severity ${severity}: ${loop.loopId} is currently failed/${loop.lifecycleState}.`;
|
|
115
137
|
}
|
|
116
|
-
if (reasonCodes.includes("
|
|
117
|
-
return `Severity ${severity}: ${loop.loopId} has
|
|
138
|
+
if (reasonCodes.includes("verification_contradicted")) {
|
|
139
|
+
return `Severity ${severity}: ${loop.loopId} has contradictory verification evidence and needs operator review.`;
|
|
140
|
+
}
|
|
141
|
+
if (reasonCodes.includes("verification_not_run")) {
|
|
142
|
+
return `Severity ${severity}: ${loop.loopId} has ${loop.attempts} attempt(s) but verification was not recorded.`;
|
|
118
143
|
}
|
|
119
144
|
if (reasonCodes.includes("budget_soft_limit")) {
|
|
120
145
|
return `Severity ${severity}: ${loop.loopId} is at soft-limit pressure with ${loop.remainingBudgetUsd.toFixed(2)} USD remaining.`;
|
|
@@ -33,7 +33,7 @@ export interface AgentCliAdapterOptions {
|
|
|
33
33
|
workingDirectory?: string;
|
|
34
34
|
/** Timeout for the agent subprocess in ms. Defaults to 300_000 (5 min). */
|
|
35
35
|
timeoutMs?: number;
|
|
36
|
-
/** Timeout per verification command in ms. Defaults to
|
|
36
|
+
/** Timeout per verification command in ms. Defaults to 120_000 (2 min). */
|
|
37
37
|
verifyTimeoutMs?: number;
|
|
38
38
|
/** Human-readable label shown in loop records. */
|
|
39
39
|
label?: string;
|
|
@@ -242,12 +242,66 @@ function extractGeminiJsonResult(stdout, modelLabel) {
|
|
|
242
242
|
function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
243
243
|
const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ??
|
|
244
244
|
{ inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
|
|
245
|
+
// Safety margin: terminate at 80% of cap to bound one-turn overshoot.
|
|
246
|
+
// Without this, a single expensive turn can blow past the cap before the
|
|
247
|
+
// next check fires (proven live: $1.50 cap → $28.42 actual).
|
|
248
|
+
const effectiveCapUsd = capUsd * 0.8;
|
|
249
|
+
// Token-count ceiling fallback: if no usage events are ever parsed (e.g.
|
|
250
|
+
// Claude changes its stream-json event format), use raw byte volume as a
|
|
251
|
+
// last-resort circuit breaker. Derived from budget / blended cost per char.
|
|
252
|
+
const blendedCostPerChar = (pricing.inputPer1K / 1000 / 4) + (pricing.outputPer1K / 1000 / 4);
|
|
253
|
+
const bytesCeiling = blendedCostPerChar > 0
|
|
254
|
+
? Math.ceil((capUsd / blendedCostPerChar) * 2)
|
|
255
|
+
: 100_000_000;
|
|
245
256
|
let buffer = "";
|
|
246
257
|
let cumulativeUsd = 0;
|
|
247
258
|
let tokensIn = 0;
|
|
248
259
|
let tokensOut = 0;
|
|
249
260
|
let turns = 0;
|
|
261
|
+
let totalBytes = 0;
|
|
262
|
+
let usageEventSeen = false;
|
|
250
263
|
let finalResult;
|
|
264
|
+
const checkBudgetExceeded = (terminate) => {
|
|
265
|
+
if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
|
|
266
|
+
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
267
|
+
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (80% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
|
|
268
|
+
`Subprocess terminated to bound runaway overspend.`);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const extractUsageFromEvent = (event, terminate) => {
|
|
272
|
+
// Check for authoritative total_cost_usd on ANY event — if Claude reports
|
|
273
|
+
// cost exceeding cap, terminate immediately regardless of event type.
|
|
274
|
+
if (typeof event.total_cost_usd === "number" && event.total_cost_usd > 0) {
|
|
275
|
+
cumulativeUsd = event.total_cost_usd;
|
|
276
|
+
checkBudgetExceeded(terminate);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Extract usage from any event shape that carries it:
|
|
280
|
+
// - { type: "assistant", message: { usage: { ... } } } (original format)
|
|
281
|
+
// - { usage: { input_tokens, output_tokens, ... } } (top-level usage)
|
|
282
|
+
// - { message: { usage: { ... } } } (nested without type check)
|
|
283
|
+
const usage = (event.message && typeof event.message === "object" && "usage" in event.message
|
|
284
|
+
? event.message.usage
|
|
285
|
+
: undefined) ??
|
|
286
|
+
(event.usage && typeof event.usage === "object" ? event.usage : undefined);
|
|
287
|
+
if (!usage || typeof usage !== "object") {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const usageRecord = usage;
|
|
291
|
+
const turnTokensIn = (usageRecord.input_tokens ?? usageRecord.inputTokens ?? 0) +
|
|
292
|
+
(usageRecord.cache_read_input_tokens ?? usageRecord.cacheReadInputTokens ?? 0) +
|
|
293
|
+
(usageRecord.cache_creation_input_tokens ?? usageRecord.cacheCreationInputTokens ?? 0);
|
|
294
|
+
const turnTokensOut = usageRecord.output_tokens ?? usageRecord.outputTokens ?? 0;
|
|
295
|
+
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
tokensIn += turnTokensIn;
|
|
299
|
+
tokensOut += turnTokensOut;
|
|
300
|
+
turns += 1;
|
|
301
|
+
usageEventSeen = true;
|
|
302
|
+
cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
|
|
303
|
+
checkBudgetExceeded(terminate);
|
|
304
|
+
};
|
|
251
305
|
const ingestLine = (line, terminate) => {
|
|
252
306
|
const trimmed = line.trim();
|
|
253
307
|
if (!trimmed) {
|
|
@@ -260,30 +314,25 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
260
314
|
catch {
|
|
261
315
|
return;
|
|
262
316
|
}
|
|
263
|
-
|
|
264
|
-
const usage = event.message.usage;
|
|
265
|
-
const turnTokensIn = (usage.input_tokens ?? usage.inputTokens ?? 0) +
|
|
266
|
-
(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? 0) +
|
|
267
|
-
(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? 0);
|
|
268
|
-
const turnTokensOut = usage.output_tokens ?? usage.outputTokens ?? 0;
|
|
269
|
-
tokensIn += turnTokensIn;
|
|
270
|
-
tokensOut += turnTokensOut;
|
|
271
|
-
turns += 1;
|
|
272
|
-
cumulativeUsd += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
|
|
273
|
-
if (capUsd > 0 && cumulativeUsd > capUsd) {
|
|
274
|
-
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
275
|
-
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (derived from remaining loop budget). ` +
|
|
276
|
-
`Subprocess terminated to bound runaway overspend.`);
|
|
277
|
-
}
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
317
|
+
extractUsageFromEvent(event, terminate);
|
|
280
318
|
if (event.type === "result") {
|
|
281
319
|
finalResult = event;
|
|
282
320
|
}
|
|
283
321
|
};
|
|
284
322
|
return {
|
|
285
323
|
onChunk: (chunk, terminate) => {
|
|
286
|
-
|
|
324
|
+
const chunkStr = chunk.toString("utf8");
|
|
325
|
+
totalBytes += chunk.byteLength;
|
|
326
|
+
buffer += chunkStr;
|
|
327
|
+
// Token-count ceiling fallback: if we've ingested a lot of bytes but
|
|
328
|
+
// never seen a single usage event, the event format may have changed
|
|
329
|
+
// and the inspector is silently blind. Terminate as a last resort.
|
|
330
|
+
if (!usageEventSeen && capUsd > 0 && totalBytes > bytesCeiling) {
|
|
331
|
+
terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` +
|
|
332
|
+
`without any usage events parsed. The Claude stream-json event format may have changed. ` +
|
|
333
|
+
`Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
287
336
|
let newlineIndex = buffer.indexOf("\n");
|
|
288
337
|
while (newlineIndex !== -1) {
|
|
289
338
|
const line = buffer.slice(0, newlineIndex);
|
|
@@ -349,7 +398,7 @@ function inferStructuralClassHint(agentOutput, verificationSummary, exitCode, ob
|
|
|
349
398
|
export function createAgentCliAdapter(options) {
|
|
350
399
|
const workingDirectory = options.workingDirectory ?? process.cwd();
|
|
351
400
|
const timeoutMs = options.timeoutMs ?? 300_000;
|
|
352
|
-
const verifyTimeoutMs = options.verifyTimeoutMs ??
|
|
401
|
+
const verifyTimeoutMs = options.verifyTimeoutMs ?? 120_000;
|
|
353
402
|
const adapterId = `agent-cli:${options.adapterIdSuffix ?? options.command}`;
|
|
354
403
|
const supportsJsonOutput = options.supportsJsonOutput === true;
|
|
355
404
|
const supportsUsageSettlement = supportsJsonOutput || options.command === "codex" || options.command === "gemini";
|
|
@@ -883,7 +932,8 @@ function buildPrompt(request) {
|
|
|
883
932
|
for (const attempt of request.previousAttempts) {
|
|
884
933
|
const failurePart = attempt.failureClass ? ` [${attempt.failureClass}]` : "";
|
|
885
934
|
const interventionPart = attempt.intervention ? ` -> intervention: ${attempt.intervention}` : "";
|
|
886
|
-
|
|
935
|
+
const diagPart = attempt.diagnosticHint ? `\n DIAGNOSIS: ${sanitizeForPrompt(attempt.diagnosticHint)}` : "";
|
|
936
|
+
lines.push(` Attempt ${String(attempt.index)}${failurePart}: ${sanitizeForPrompt(attempt.summary ?? "")}${interventionPart}${diagPart}`);
|
|
887
937
|
}
|
|
888
938
|
lines.push("");
|
|
889
939
|
}
|
|
@@ -976,6 +1026,7 @@ function redactSecretsForPrompt(input) {
|
|
|
976
1026
|
.replace(/\bAIza[0-9A-Za-z_-]{30,}\b/gu, "[REDACTED_SECRET]")
|
|
977
1027
|
.replace(/-----BEGIN(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----[\s\S]*?-----END(?:\s+[A-Z0-9]+)*\s+PRIVATE KEY-----/gu, "[REDACTED_SECRET]")
|
|
978
1028
|
.replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, "[REDACTED_SECRET]")
|
|
1029
|
+
.replace(/\b(?:api[_-]?key|secret|password|token)\s*[:=]\s*["']?[A-Za-z0-9_\-/+=]{8,}["']?/giu, "[REDACTED_SECRET]")
|
|
979
1030
|
.replace(/\B\.env(?!\.example\b)(?:\.[A-Za-z0-9._-]+)?\b/giu, "[REDACTED_PATH]");
|
|
980
1031
|
}
|
|
981
1032
|
function extractStructuredErrors(stderr, stdout) {
|
|
@@ -77,4 +77,10 @@ export interface SpawnPlan {
|
|
|
77
77
|
args: string[];
|
|
78
78
|
}
|
|
79
79
|
export declare function createSpawnPlan(command: string, args: string[], cwd: string, preserveRawForInjectedSpawn: boolean): SpawnPlan;
|
|
80
|
+
/**
|
|
81
|
+
* Returns true if the command string contains shell operators that cannot be
|
|
82
|
+
* passed as literal arguments to spawn(). These must be routed through a
|
|
83
|
+
* platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
|
|
84
|
+
*/
|
|
85
|
+
export declare function containsShellOperator(command: string): boolean;
|
|
80
86
|
export declare function splitCommand(command: string): string[];
|
|
@@ -9,6 +9,7 @@ export async function runSubprocess(command, args, options) {
|
|
|
9
9
|
let outputCapped = false;
|
|
10
10
|
let terminationReason;
|
|
11
11
|
let settled = false;
|
|
12
|
+
let exited = false;
|
|
12
13
|
let outputBytes = 0;
|
|
13
14
|
const stdoutChunks = [];
|
|
14
15
|
const stderrChunks = [];
|
|
@@ -35,12 +36,12 @@ export async function runSubprocess(command, args, options) {
|
|
|
35
36
|
return;
|
|
36
37
|
}
|
|
37
38
|
const trackOutput = (chunks, chunk) => {
|
|
39
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
38
42
|
chunks.push(chunk);
|
|
39
43
|
outputBytes += chunk.byteLength;
|
|
40
|
-
if (options.maxOutputBytes !== undefined &&
|
|
41
|
-
!outputCapped &&
|
|
42
|
-
!timedOut &&
|
|
43
|
-
outputBytes > options.maxOutputBytes) {
|
|
44
|
+
if (options.maxOutputBytes !== undefined && outputBytes > options.maxOutputBytes) {
|
|
44
45
|
outputCapped = true;
|
|
45
46
|
proc.kill("SIGTERM");
|
|
46
47
|
}
|
|
@@ -53,10 +54,23 @@ export async function runSubprocess(command, args, options) {
|
|
|
53
54
|
proc.kill("SIGTERM");
|
|
54
55
|
};
|
|
55
56
|
proc.stdout?.on("data", (chunk) => {
|
|
57
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
56
60
|
trackOutput(stdoutChunks, chunk);
|
|
57
|
-
options.onStdoutChunk
|
|
61
|
+
if (options.onStdoutChunk) {
|
|
62
|
+
try {
|
|
63
|
+
options.onStdoutChunk(chunk, terminateEarly);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
terminateEarly(`stdout inspector error: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
58
69
|
});
|
|
59
70
|
proc.stderr?.on("data", (chunk) => {
|
|
71
|
+
if (outputCapped || timedOut || terminationReason) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
60
74
|
trackOutput(stderrChunks, chunk);
|
|
61
75
|
});
|
|
62
76
|
proc.stdin?.on("error", (error) => {
|
|
@@ -68,9 +82,15 @@ export async function runSubprocess(command, args, options) {
|
|
|
68
82
|
stderrChunks.push(Buffer.from(`${error.message}\n`, "utf8"));
|
|
69
83
|
});
|
|
70
84
|
const timer = setTimeout(() => {
|
|
85
|
+
if (settled || exited || proc.exitCode !== null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
71
88
|
timedOut = true;
|
|
72
89
|
proc.kill("SIGTERM");
|
|
73
90
|
}, options.timeoutMs);
|
|
91
|
+
proc.on("exit", () => {
|
|
92
|
+
exited = true;
|
|
93
|
+
});
|
|
74
94
|
proc.on("error", (error) => {
|
|
75
95
|
clearTimeout(timer);
|
|
76
96
|
resolveOnce({ exitCode: 1, stdout: "", stderr: error.message, launched: false });
|
|
@@ -117,8 +137,30 @@ export async function runVerification(commands, cwd, timeoutMs, verificationStac
|
|
|
117
137
|
const stepOutcomes = [];
|
|
118
138
|
const warnings = [];
|
|
119
139
|
for (const step of steps) {
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
let bin;
|
|
141
|
+
let args;
|
|
142
|
+
if (containsShellOperator(step.command)) {
|
|
143
|
+
// Shell operators (&&, ||, ;, |) cannot be passed as literal arguments
|
|
144
|
+
// to spawn(). Route through the platform shell so the operator is
|
|
145
|
+
// interpreted correctly.
|
|
146
|
+
if (process.platform === "win32") {
|
|
147
|
+
bin = process.env.ComSpec || "cmd.exe";
|
|
148
|
+
args = ["/d", "/c", step.command];
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
bin = "sh";
|
|
152
|
+
args = ["-c", step.command];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const parts = splitCommand(step.command);
|
|
157
|
+
const first = parts[0];
|
|
158
|
+
if (!first) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
bin = first;
|
|
162
|
+
args = parts.slice(1);
|
|
163
|
+
}
|
|
122
164
|
if (!bin) {
|
|
123
165
|
continue;
|
|
124
166
|
}
|
|
@@ -200,6 +242,10 @@ export function resolveGitRepositoryRoot(workingDirectory) {
|
|
|
200
242
|
if (cached !== undefined) {
|
|
201
243
|
return cached ?? undefined;
|
|
202
244
|
}
|
|
245
|
+
if (!existsSync(resolvedWorkingDirectory)) {
|
|
246
|
+
gitRepositoryRootCache.set(resolvedWorkingDirectory, null);
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
203
249
|
const visited = [];
|
|
204
250
|
let current = resolvedWorkingDirectory;
|
|
205
251
|
while (true) {
|
|
@@ -318,6 +364,44 @@ function windowsPathDirectories() {
|
|
|
318
364
|
.map((entry) => entry.trim().replace(/^"|"$/g, ""))
|
|
319
365
|
.filter(Boolean);
|
|
320
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Returns true if the command string contains shell operators that cannot be
|
|
369
|
+
* passed as literal arguments to spawn(). These must be routed through a
|
|
370
|
+
* platform shell (cmd.exe /c or sh -c) so the operator is interpreted.
|
|
371
|
+
*/
|
|
372
|
+
export function containsShellOperator(command) {
|
|
373
|
+
// Match &&, ||, ;, or | that are NOT inside quotes.
|
|
374
|
+
// Simple heuristic: scan outside of single/double quoted regions.
|
|
375
|
+
let inSingle = false;
|
|
376
|
+
let inDouble = false;
|
|
377
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
378
|
+
const ch = command[i];
|
|
379
|
+
if (ch === "'" && !inDouble) {
|
|
380
|
+
inSingle = !inSingle;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (ch === '"' && !inSingle) {
|
|
384
|
+
inDouble = !inDouble;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (inSingle || inDouble) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
if (ch === "|" && command[i + 1] === "|") {
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
if (ch === ";") {
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
if (ch === "|" && command[i + 1] !== "|") {
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
321
405
|
export function splitCommand(command) {
|
|
322
406
|
const tokens = [];
|
|
323
407
|
let current = "";
|