@martinloop/mcp 0.3.5 → 0.3.6
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 +10 -2
- package/dist/discovery-metadata.d.ts +1 -1
- package/dist/discovery-metadata.js +2 -0
- package/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/prompts.js +19 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +202 -148
- package/dist/server-validation.d.ts +1 -1
- package/dist/server-validation.js +17 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +161 -5
- package/dist/tools/doctor.js +6 -0
- package/dist/tools/tool-support.d.ts +1 -1
- package/dist/tools/tool-support.js +1 -1
- package/dist/vendor/adapters/claude-cli.d.ts +2 -2
- package/dist/vendor/adapters/claude-cli.js +22 -7
- package/dist/vendor/adapters/cli-bridge.d.ts +7 -0
- package/dist/vendor/adapters/cli-bridge.js +94 -8
- package/dist/vendor/adapters/codex-launcher.js +18 -7
- package/dist/vendor/adapters/openai-compatible.js +90 -57
- package/dist/vendor/contracts/index.d.ts +1 -1
- package/dist/vendor/contracts/index.js +1 -0
- package/dist/vendor/core/index.d.ts +10 -2
- package/dist/vendor/core/index.js +31 -3
- package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
- package/dist/vendor/core/persistence/memory-store.js +115 -0
- package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
- package/dist/vendor/core/persistence/trace-store.js +125 -0
- package/dist/vendor/core/repo-analyzer.d.ts +53 -0
- package/dist/vendor/core/repo-analyzer.js +263 -0
- package/dist/vendor/core/routing.d.ts +42 -0
- package/dist/vendor/core/routing.js +156 -5
- package/dist/vendor/core/test-integrity.d.ts +64 -0
- package/dist/vendor/core/test-integrity.js +197 -0
- package/dist/workflow-state.d.ts +1 -1
- package/dist/workflow-state.js +12 -4
- package/package.json +76 -76
- package/server.json +21 -21
package/dist/server.js
CHANGED
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
* Manual start:
|
|
17
17
|
* node dist/server.js
|
|
18
18
|
*/
|
|
19
|
+
import { createServer as createHttpServer } from "node:http";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
import { realpathSync } from "node:fs";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import { resolveRunsRoot } from "./vendor/core/index.js";
|
|
23
24
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
24
25
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
26
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
25
27
|
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
26
28
|
import { MARTIN_MCP_PACKAGE_VERSION } from "./package-version.js";
|
|
27
29
|
import { getMartinPrompt, listMartinPrompts } from "./prompts.js";
|
|
@@ -721,7 +723,7 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
721
723
|
tools: [
|
|
722
724
|
{
|
|
723
725
|
name: "martin_run",
|
|
724
|
-
description: "Execute a governed Martin Loop run on a coding task and return the run summary, spend, artifact rollup, and verification state. This hard-blocks until martin_doctor, martin_plan, and martin_preflight receipts exist for the same task.",
|
|
726
|
+
description: "Execute a governed Martin Loop run on a coding task and return the run summary, spend, artifact rollup, and verification state. This hard-blocks until martin_doctor, martin_estimate, martin_plan, and martin_preflight receipts exist for the same task.",
|
|
725
727
|
annotations: {
|
|
726
728
|
destructiveHint: true,
|
|
727
729
|
idempotentHint: false,
|
|
@@ -1401,6 +1403,10 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1401
1403
|
type: "array",
|
|
1402
1404
|
items: { type: "string" },
|
|
1403
1405
|
description: "Optional file paths to scope the estimate."
|
|
1406
|
+
},
|
|
1407
|
+
workingDirectory: {
|
|
1408
|
+
type: "string",
|
|
1409
|
+
description: "Optional workspace path for recording the estimate receipt against the same task root you plan to run."
|
|
1404
1410
|
}
|
|
1405
1411
|
},
|
|
1406
1412
|
required: ["objective"]
|
|
@@ -1635,11 +1641,13 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1635
1641
|
return createToolSuccessResult(output, `PR review verdict for ${output.loopId}: ${output.verdict}.`);
|
|
1636
1642
|
}
|
|
1637
1643
|
if (name === "martin_estimate") {
|
|
1638
|
-
const input = args;
|
|
1644
|
+
const input = validateToolInput("martin_estimate", args);
|
|
1639
1645
|
const objective = input.objective;
|
|
1640
1646
|
const engine = input.engine ?? "claude";
|
|
1641
1647
|
const budgetUsd = input.budgetUsd ?? 5;
|
|
1642
1648
|
const fileScope = input.fileScope ?? [];
|
|
1649
|
+
const runsRoot = resolveRunsRoot(process.env);
|
|
1650
|
+
const workingDirectory = input.workingDirectory ?? resolveSafeRepoRoot();
|
|
1643
1651
|
const route = classifyRoute({
|
|
1644
1652
|
objective,
|
|
1645
1653
|
verificationPlan: [],
|
|
@@ -1662,9 +1670,24 @@ export function createMartinMcpServer(serverInfo) {
|
|
|
1662
1670
|
blockedSteps: route.blockedSteps,
|
|
1663
1671
|
compressed: route.compressed,
|
|
1664
1672
|
...(route.compressionSummary ? { compressionSummary: route.compressionSummary } : {}),
|
|
1665
|
-
recommendedBudgetUsd
|
|
1673
|
+
recommendedBudgetUsd,
|
|
1674
|
+
recommendedModelTier: route.recommendedModelTier,
|
|
1675
|
+
estimatedSavingVsSonnetUsd: route.estimatedSavingVsSonnetUsd
|
|
1666
1676
|
};
|
|
1667
|
-
|
|
1677
|
+
await recordMcpWorkflowStep({
|
|
1678
|
+
runsRoot,
|
|
1679
|
+
step: "estimate",
|
|
1680
|
+
workingDirectory,
|
|
1681
|
+
objective,
|
|
1682
|
+
engine,
|
|
1683
|
+
receiptScope: {
|
|
1684
|
+
invocationRoot: resolveSafeRepoRoot(),
|
|
1685
|
+
workingDirectory,
|
|
1686
|
+
repoRoot: workingDirectory,
|
|
1687
|
+
runsRoot
|
|
1688
|
+
}
|
|
1689
|
+
}).catch(() => { });
|
|
1690
|
+
return createToolSuccessResult(output, `Estimate: ${route.selectedMode} route (${route.recommendedModelTier}), ~$${route.expectedCostUsd.toFixed(2)} expected cost, ${route.expectedPreworkBurnPct}% pre-work burn. Recommended budget: $${recommendedBudgetUsd.toFixed(2)}.`);
|
|
1668
1691
|
}
|
|
1669
1692
|
return createToolErrorResult(toToolFailure(new Error(`Unknown tool: ${name}`)));
|
|
1670
1693
|
}
|
|
@@ -1687,6 +1710,114 @@ export async function connectMartinMcpStdioServer() {
|
|
|
1687
1710
|
await server.connect(transport);
|
|
1688
1711
|
return server;
|
|
1689
1712
|
}
|
|
1713
|
+
export function parseMartinMcpServerArgs(argv) {
|
|
1714
|
+
let transport = "stdio";
|
|
1715
|
+
let host = "127.0.0.1";
|
|
1716
|
+
let port = 3033;
|
|
1717
|
+
let path = "/mcp";
|
|
1718
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1719
|
+
const token = argv[index];
|
|
1720
|
+
if (token === "--http") {
|
|
1721
|
+
transport = "http";
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
if (token === "--port") {
|
|
1725
|
+
const next = argv[index + 1];
|
|
1726
|
+
const parsed = Number(next);
|
|
1727
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
1728
|
+
throw new Error(`Invalid --port value '${next ?? ""}'.`);
|
|
1729
|
+
}
|
|
1730
|
+
port = parsed;
|
|
1731
|
+
index += 1;
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
if (token === "--host") {
|
|
1735
|
+
const next = argv[index + 1];
|
|
1736
|
+
if (!next) {
|
|
1737
|
+
throw new Error("Missing value for --host.");
|
|
1738
|
+
}
|
|
1739
|
+
host = next;
|
|
1740
|
+
index += 1;
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (token === "--path") {
|
|
1744
|
+
const next = argv[index + 1];
|
|
1745
|
+
if (!next || !next.startsWith("/")) {
|
|
1746
|
+
throw new Error("MCP HTTP path must start with '/'.");
|
|
1747
|
+
}
|
|
1748
|
+
path = next;
|
|
1749
|
+
index += 1;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return transport === "http"
|
|
1753
|
+
? { transport, host, port, path }
|
|
1754
|
+
: { transport };
|
|
1755
|
+
}
|
|
1756
|
+
export async function connectMartinMcpHttpServer(options = {}) {
|
|
1757
|
+
const host = options.host ?? "127.0.0.1";
|
|
1758
|
+
const port = options.port ?? 3033;
|
|
1759
|
+
const path = options.path ?? "/mcp";
|
|
1760
|
+
const mcpServer = createMartinMcpServer();
|
|
1761
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1762
|
+
sessionIdGenerator: undefined
|
|
1763
|
+
});
|
|
1764
|
+
await mcpServer.connect(transport);
|
|
1765
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
1766
|
+
try {
|
|
1767
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? `${host}:${port}`}`);
|
|
1768
|
+
if (requestUrl.pathname !== path) {
|
|
1769
|
+
res.statusCode = 404;
|
|
1770
|
+
res.setHeader("content-type", "application/json");
|
|
1771
|
+
res.end(JSON.stringify({ error: "not_found", path: requestUrl.pathname }));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
if (req.method === "POST") {
|
|
1775
|
+
const parsedBody = await readJsonBody(req, res);
|
|
1776
|
+
if (res.writableEnded) {
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
1783
|
+
await transport.handleRequest(req, res);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
res.statusCode = 405;
|
|
1787
|
+
res.setHeader("allow", "GET, POST, DELETE");
|
|
1788
|
+
res.end();
|
|
1789
|
+
}
|
|
1790
|
+
catch (error) {
|
|
1791
|
+
if (!res.headersSent) {
|
|
1792
|
+
res.statusCode = 500;
|
|
1793
|
+
res.setHeader("content-type", "application/json");
|
|
1794
|
+
res.end(JSON.stringify({ error: "internal_error", message: error.message }));
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
await new Promise((resolve, reject) => {
|
|
1799
|
+
httpServer.once("error", reject);
|
|
1800
|
+
httpServer.listen(port, host, () => {
|
|
1801
|
+
httpServer.off("error", reject);
|
|
1802
|
+
resolve();
|
|
1803
|
+
});
|
|
1804
|
+
});
|
|
1805
|
+
const address = httpServer.address();
|
|
1806
|
+
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
|
1807
|
+
return {
|
|
1808
|
+
server: httpServer,
|
|
1809
|
+
host,
|
|
1810
|
+
port: resolvedPort,
|
|
1811
|
+
path,
|
|
1812
|
+
endpoint: `http://${host}:${resolvedPort}${path}`,
|
|
1813
|
+
close: async () => {
|
|
1814
|
+
await transport.close();
|
|
1815
|
+
await new Promise((resolve) => {
|
|
1816
|
+
httpServer.close(() => resolve());
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1690
1821
|
export function isDirectExecutionEntry(entryPath, moduleUrl = import.meta.url) {
|
|
1691
1822
|
if (typeof entryPath !== "string" || entryPath.length === 0) {
|
|
1692
1823
|
return false;
|
|
@@ -1707,5 +1838,30 @@ function realPathOrResolved(filePath) {
|
|
|
1707
1838
|
}
|
|
1708
1839
|
}
|
|
1709
1840
|
if (isDirectExecution()) {
|
|
1710
|
-
|
|
1841
|
+
const startup = parseMartinMcpServerArgs(process.argv.slice(2));
|
|
1842
|
+
if (startup.transport === "http") {
|
|
1843
|
+
await connectMartinMcpHttpServer(startup);
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
await connectMartinMcpStdioServer();
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
async function readJsonBody(req, res) {
|
|
1850
|
+
const chunks = [];
|
|
1851
|
+
for await (const chunk of req) {
|
|
1852
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1853
|
+
}
|
|
1854
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
1855
|
+
if (!raw) {
|
|
1856
|
+
return undefined;
|
|
1857
|
+
}
|
|
1858
|
+
try {
|
|
1859
|
+
return JSON.parse(raw);
|
|
1860
|
+
}
|
|
1861
|
+
catch {
|
|
1862
|
+
res.statusCode = 400;
|
|
1863
|
+
res.setHeader("content-type", "application/json");
|
|
1864
|
+
res.end(JSON.stringify({ error: "invalid_json" }));
|
|
1865
|
+
return undefined;
|
|
1866
|
+
}
|
|
1711
1867
|
}
|
package/dist/tools/doctor.js
CHANGED
|
@@ -113,6 +113,12 @@ export async function martinDoctorTool(input) {
|
|
|
113
113
|
available: gemini.available,
|
|
114
114
|
detail: gemini.detail,
|
|
115
115
|
...(gemini.resolvedPath ? { resolvedPath: gemini.resolvedPath } : {})
|
|
116
|
+
},
|
|
117
|
+
openai: {
|
|
118
|
+
available: Boolean(process.env.MARTIN_OPENAI_API_KEY),
|
|
119
|
+
detail: process.env.MARTIN_OPENAI_API_KEY
|
|
120
|
+
? `OpenAI-compatible endpoint configured (model: ${process.env.MARTIN_OPENAI_MODEL ?? "gpt-4.1-mini"})`
|
|
121
|
+
: "Set MARTIN_OPENAI_API_KEY and optionally MARTIN_OPENAI_BASE_URL + MARTIN_OPENAI_MODEL to enable."
|
|
116
122
|
}
|
|
117
123
|
},
|
|
118
124
|
...(input.engine ? { requestedEngine: input.engine } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LoopArtifact, LoopBudget, LoopCost, LoopEvent, LoopTask, ReceiptIntegritySummary, ReceiptScope } from "../vendor/contracts/index.js";
|
|
2
2
|
import { type LedgerEvent, type LoopAttemptRecord, type LoopRunRecord } from "../vendor/core/index.js";
|
|
3
|
-
export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini"];
|
|
3
|
+
export declare const MARTIN_ENGINE_VALUES: readonly ["claude", "codex", "gemini", "openai"];
|
|
4
4
|
export type MartinEngine = (typeof MARTIN_ENGINE_VALUES)[number];
|
|
5
5
|
export interface InspectableLoopAttempt extends LoopAttemptRecord {
|
|
6
6
|
attemptId?: string;
|
|
@@ -3,7 +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
|
+
export const MARTIN_ENGINE_VALUES = ["claude", "codex", "gemini", "openai"];
|
|
7
7
|
const CLI_CACHE_TTL_MS = 60_000;
|
|
8
8
|
const RUN_STORE_CACHE_TTL_MS = 5_000;
|
|
9
9
|
const cliAvailabilityCache = new Map();
|
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
*
|
|
12
12
|
* MCP tools and integration tests use the same factories.
|
|
13
13
|
*/
|
|
14
|
-
import type { MartinAdapter } from "../core/index.js";
|
|
14
|
+
import type { MartinAdapter, MartinAdapterRequest } from "../core/index.js";
|
|
15
15
|
import { type SpawnLike } from "./cli-bridge.js";
|
|
16
16
|
/**
|
|
17
17
|
* Given a prompt string, returns the full argv array to pass to spawn().
|
|
18
18
|
* Example for Claude: () => ["--output-format", "json", "--print"]
|
|
19
19
|
* Example for Codex: () => ["exec", "--sandbox", "workspace-write", "-"]
|
|
20
20
|
*/
|
|
21
|
-
export type CliArgsBuilder = (prompt: string) => string[];
|
|
21
|
+
export type CliArgsBuilder = (prompt: string, request: MartinAdapterRequest) => string[];
|
|
22
22
|
export type CliStdinBuilder = (prompt: string) => string | undefined;
|
|
23
23
|
export interface AgentCliAdapterOptions {
|
|
24
24
|
/** The executable to spawn (e.g. "claude", "codex"). */
|
|
@@ -239,13 +239,15 @@ function extractGeminiJsonResult(stdout, modelLabel) {
|
|
|
239
239
|
})
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
-
function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
242
|
+
function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate) {
|
|
243
243
|
const pricing = (modelLabel ? MODEL_PRICING[modelLabel] : undefined) ??
|
|
244
244
|
{ inputPer1K: BLENDED_INPUT_COST_PER_1K, outputPer1K: BLENDED_OUTPUT_COST_PER_1K };
|
|
245
245
|
// Safety margin: terminate at 80% of cap to bound one-turn overshoot.
|
|
246
246
|
// Without this, a single expensive turn can blow past the cap before the
|
|
247
247
|
// next check fires (proven live: $1.50 cap → $28.42 actual).
|
|
248
|
-
const
|
|
248
|
+
const largeContext = promptTokenEstimate > 10_000;
|
|
249
|
+
const effectiveCapRatio = largeContext ? 0.7 : 0.8;
|
|
250
|
+
const effectiveCapUsd = capUsd * effectiveCapRatio;
|
|
249
251
|
// Token-count ceiling fallback: if no usage events are ever parsed (e.g.
|
|
250
252
|
// Claude changes its stream-json event format), use raw byte volume as a
|
|
251
253
|
// last-resort circuit breaker. Derived from budget / blended cost per char.
|
|
@@ -269,7 +271,7 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
269
271
|
const checkBudgetExceeded = (terminate) => {
|
|
270
272
|
if (capUsd > 0 && cumulativeUsd > effectiveCapUsd) {
|
|
271
273
|
terminate(`Streaming usage cap exceeded after ${String(turns)} turn(s): cumulative cost ~$${cumulativeUsd.toFixed(4)} ` +
|
|
272
|
-
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (
|
|
274
|
+
`surpassed the per-attempt cap $${capUsd.toFixed(4)} (${String(Math.round(effectiveCapRatio * 100))}% threshold: $${effectiveCapUsd.toFixed(4)}). ` +
|
|
273
275
|
`Subprocess terminated to bound runaway overspend.`);
|
|
274
276
|
}
|
|
275
277
|
};
|
|
@@ -300,11 +302,23 @@ function createStreamingUsageInspector(capUsd, modelLabel) {
|
|
|
300
302
|
if (turnTokensIn === 0 && turnTokensOut === 0) {
|
|
301
303
|
return;
|
|
302
304
|
}
|
|
305
|
+
const turnUsd = (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
|
|
306
|
+
const remainingBudgetBeforeTurn = Math.max(capUsd - cumulativeUsd, 0);
|
|
307
|
+
if (capUsd > 0 && remainingBudgetBeforeTurn > 0 && turnUsd > remainingBudgetBeforeTurn * 0.5) {
|
|
308
|
+
cumulativeUsd += turnUsd;
|
|
309
|
+
tokensIn += turnTokensIn;
|
|
310
|
+
tokensOut += turnTokensOut;
|
|
311
|
+
turns += 1;
|
|
312
|
+
usageEventSeen = true;
|
|
313
|
+
terminate(`Single turn spend ~$${turnUsd.toFixed(4)} consumed more than 50% of the remaining per-attempt budget ` +
|
|
314
|
+
`($${remainingBudgetBeforeTurn.toFixed(4)} before the turn). Subprocess terminated to prevent a one-turn overshoot.`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
303
317
|
tokensIn += turnTokensIn;
|
|
304
318
|
tokensOut += turnTokensOut;
|
|
305
319
|
turns += 1;
|
|
306
320
|
usageEventSeen = true;
|
|
307
|
-
cumulativeUsd +=
|
|
321
|
+
cumulativeUsd += turnUsd;
|
|
308
322
|
checkBudgetExceeded(terminate);
|
|
309
323
|
};
|
|
310
324
|
const ingestLine = (line, terminate) => {
|
|
@@ -463,7 +477,7 @@ export function createAgentCliAdapter(options) {
|
|
|
463
477
|
};
|
|
464
478
|
}
|
|
465
479
|
}
|
|
466
|
-
const args = options.argsBuilder(prompt);
|
|
480
|
+
const args = options.argsBuilder(prompt, request);
|
|
467
481
|
const stdinData = options.stdinBuilder?.(prompt);
|
|
468
482
|
// Live cumulative-cost circuit breaker: a single attempt should never be
|
|
469
483
|
// allowed to spend more than the loop has left. `--output-format json`
|
|
@@ -472,7 +486,7 @@ export function createAgentCliAdapter(options) {
|
|
|
472
486
|
// instant projected spend crosses what remains — bounding the worst case
|
|
473
487
|
// to roughly one turn's overshoot rather than the entire runaway session.
|
|
474
488
|
const streamingUsage = options.streamingUsageCap && request.context.remainingBudgetUsd > 0
|
|
475
|
-
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command)
|
|
489
|
+
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn)
|
|
476
490
|
: undefined;
|
|
477
491
|
const agentResult = await runSubprocess(options.command, args, {
|
|
478
492
|
cwd: workingDirectory,
|
|
@@ -790,7 +804,7 @@ export function createClaudeCliAdapter(options = {}) {
|
|
|
790
804
|
supportsJsonOutput: true,
|
|
791
805
|
streamingUsageCap: true,
|
|
792
806
|
spawnImpl: options.spawnImpl,
|
|
793
|
-
argsBuilder: (_prompt) => [
|
|
807
|
+
argsBuilder: (_prompt, request) => [
|
|
794
808
|
"--output-format",
|
|
795
809
|
"stream-json",
|
|
796
810
|
"--verbose",
|
|
@@ -801,6 +815,7 @@ export function createClaudeCliAdapter(options = {}) {
|
|
|
801
815
|
// inside the governed subprocess, causing conflicts and "MCP server being
|
|
802
816
|
// overwritten" errors.
|
|
803
817
|
"--strict-mcp-config",
|
|
818
|
+
...(request.context.remainingTokens > 0 ? ["--max-tokens", String(request.context.remainingTokens)] : []),
|
|
804
819
|
...modelArgs,
|
|
805
820
|
...extraArgs
|
|
806
821
|
],
|
|
@@ -87,6 +87,13 @@ export interface SpawnPlan {
|
|
|
87
87
|
args: string[];
|
|
88
88
|
}
|
|
89
89
|
export declare function createSpawnPlan(command: string, args: string[], cwd: string, preserveRawForInjectedSpawn: boolean): SpawnPlan;
|
|
90
|
+
/**
|
|
91
|
+
* npm generates Windows shims (.cmd/.ps1, occasionally .bat) that ultimately just exec
|
|
92
|
+
* `node <real-cli-script>.js <args>` relative to the shim's own directory. Parse the shim text to
|
|
93
|
+
* find that real script path and return it if it resolves to a file that actually exists on disk;
|
|
94
|
+
* otherwise return undefined so callers fall back to the existing wrapper-shell behavior unchanged.
|
|
95
|
+
*/
|
|
96
|
+
export declare function resolveNpmShimScript(shimPath: string): string | undefined;
|
|
90
97
|
/**
|
|
91
98
|
* Returns true if the command string contains shell operators that cannot be
|
|
92
99
|
* passed as literal arguments to spawn(). These must be routed through a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { delimiter, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { diffStatsFromNumstat } from "./runtime-support.js";
|
|
5
5
|
const gitRepositoryRootCache = new Map();
|
|
6
6
|
export async function runSubprocess(command, args, options) {
|
|
@@ -319,20 +319,106 @@ export function createSpawnPlan(command, args, cwd, preserveRawForInjectedSpawn)
|
|
|
319
319
|
};
|
|
320
320
|
}
|
|
321
321
|
const extension = extname(resolvedOrUndefined).toLowerCase();
|
|
322
|
-
if (extension === ".cmd" || extension === ".bat") {
|
|
322
|
+
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
|
|
323
|
+
// npm-installed CLIs resolve to a generated shim on Windows. Wrapping that shim through an
|
|
324
|
+
// extra cmd.exe/powershell.exe hop adds a process layer that can lose the OS-level
|
|
325
|
+
// workspace-write sandbox permission when the whole tree is already nested inside another
|
|
326
|
+
// restricted parent process (e.g. VS Code's extension host), even though the same shim works
|
|
327
|
+
// fine from a top-level PowerShell window. When we can statically resolve the shim's real
|
|
328
|
+
// wrapped `node <script>` target, invoke that directly instead — this removes the extra hop
|
|
329
|
+
// for every Windows launch, nested or not, with no behavior change when resolution fails.
|
|
330
|
+
const directScript = resolveNpmShimScript(resolvedOrUndefined);
|
|
331
|
+
if (directScript !== undefined) {
|
|
332
|
+
// Use the system node rather than process.execPath. When MartinLoop runs
|
|
333
|
+
// inside Claude Code desktop or VS Code, process.execPath is Electron's
|
|
334
|
+
// bundled Node — which has different module resolution and may fail to
|
|
335
|
+
// load native CLI scripts. Prefer an explicit `node` from PATH instead.
|
|
336
|
+
const systemNode = resolveSystemNode();
|
|
337
|
+
return { command: systemNode, args: [directScript, ...args] };
|
|
338
|
+
}
|
|
339
|
+
if (extension === ".ps1") {
|
|
340
|
+
return {
|
|
341
|
+
command: "powershell.exe",
|
|
342
|
+
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedOrUndefined, ...args]
|
|
343
|
+
};
|
|
344
|
+
}
|
|
323
345
|
return {
|
|
324
346
|
command: process.env.ComSpec || "cmd.exe",
|
|
325
347
|
args: ["/d", "/c", resolvedOrUndefined, ...args]
|
|
326
348
|
};
|
|
327
349
|
}
|
|
328
|
-
if (extension === ".ps1") {
|
|
329
|
-
return {
|
|
330
|
-
command: "powershell.exe",
|
|
331
|
-
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolvedOrUndefined, ...args]
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
350
|
return { command: resolvedOrUndefined, args };
|
|
335
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* npm generates Windows shims (.cmd/.ps1, occasionally .bat) that ultimately just exec
|
|
354
|
+
* `node <real-cli-script>.js <args>` relative to the shim's own directory. Parse the shim text to
|
|
355
|
+
* find that real script path and return it if it resolves to a file that actually exists on disk;
|
|
356
|
+
* otherwise return undefined so callers fall back to the existing wrapper-shell behavior unchanged.
|
|
357
|
+
*/
|
|
358
|
+
export function resolveNpmShimScript(shimPath) {
|
|
359
|
+
let contents;
|
|
360
|
+
try {
|
|
361
|
+
contents = readFileSync(shimPath, "utf8");
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const shimDir = dirname(shimPath);
|
|
367
|
+
const scriptPathPattern = /["']?(?:%~?dp0%?|\$basedir)[\\/]([^"'\s]+\.[cm]?js)["']?/gi;
|
|
368
|
+
const matches = contents.matchAll(scriptPathPattern);
|
|
369
|
+
for (const match of matches) {
|
|
370
|
+
const relativeScript = match[1];
|
|
371
|
+
if (!relativeScript) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const segments = relativeScript.split(/[\\/]+/u).filter(Boolean);
|
|
375
|
+
const resolvedScript = resolve(shimDir, ...segments);
|
|
376
|
+
if (existsSync(resolvedScript)) {
|
|
377
|
+
return resolvedScript;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a reliable `node` executable for spawning CLI scripts on Windows.
|
|
384
|
+
*
|
|
385
|
+
* When MartinLoop runs inside Claude Code desktop, VS Code, or the Codex IDE,
|
|
386
|
+
* `process.execPath` points to the host application's bundled Electron Node —
|
|
387
|
+
* not the system Node. Electron's Node has different module resolution paths
|
|
388
|
+
* and may fail to load native npm CLI scripts that expect the system Node.
|
|
389
|
+
*
|
|
390
|
+
* Strategy:
|
|
391
|
+
* 1. Use MARTIN_NODE_PATH env var if explicitly set (escape hatch for CI)
|
|
392
|
+
* 2. Look for `node` / `node.exe` on PATH — the system install
|
|
393
|
+
* 3. Fall back to process.execPath (Electron Node) if nothing else found
|
|
394
|
+
*/
|
|
395
|
+
function resolveSystemNode() {
|
|
396
|
+
// Explicit override — useful in CI or restricted environments
|
|
397
|
+
const envOverride = process.env.MARTIN_NODE_PATH?.trim();
|
|
398
|
+
if (envOverride && envOverride.length > 0 && existsSync(envOverride)) {
|
|
399
|
+
return envOverride;
|
|
400
|
+
}
|
|
401
|
+
// Search PATH for a real `node` executable, skipping Electron binaries.
|
|
402
|
+
// Electron's node path typically contains "electron" or "Claude" in it.
|
|
403
|
+
for (const dir of windowsPathDirectories()) {
|
|
404
|
+
for (const candidate of ["node.exe", "node.cmd", "node"]) {
|
|
405
|
+
const fullPath = join(dir, candidate);
|
|
406
|
+
if (existsSync(fullPath) && !isElectronNode(fullPath)) {
|
|
407
|
+
return fullPath;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Last resort: use process.execPath even if it's Electron's node
|
|
412
|
+
return process.execPath;
|
|
413
|
+
}
|
|
414
|
+
function isElectronNode(nodePath) {
|
|
415
|
+
const lower = nodePath.toLowerCase();
|
|
416
|
+
return (lower.includes("electron") ||
|
|
417
|
+
lower.includes("claude") ||
|
|
418
|
+
lower.includes("vscode") ||
|
|
419
|
+
lower.includes("code.exe") ||
|
|
420
|
+
lower.includes("cursor"));
|
|
421
|
+
}
|
|
336
422
|
function resolveWindowsCommand(command, cwd) {
|
|
337
423
|
const hasPathSegment = command.includes("\\") || command.includes("/");
|
|
338
424
|
const baseCandidates = expandWindowsCommandCandidates(hasPathSegment ? resolve(cwd, command) : command);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, extname, join, resolve } from "node:path";
|
|
4
|
+
import { resolveNpmShimScript } from "./cli-bridge.js";
|
|
4
5
|
const codexLaunchProbeCache = new Map();
|
|
5
6
|
export const DEFAULT_CODEX_CHATGPT_MODEL = "gpt-5.4";
|
|
6
7
|
const CODEX_LAUNCH_PROBE_PROMPT = [
|
|
@@ -121,17 +122,27 @@ function buildProbeCommand(command, args, platform) {
|
|
|
121
122
|
switch (extension) {
|
|
122
123
|
case ".cmd":
|
|
123
124
|
case ".bat":
|
|
125
|
+
case ".ps1": {
|
|
126
|
+
// Bypass the npm shim's cmd.exe/powershell.exe wrapper hop when we can statically resolve
|
|
127
|
+
// the real `node <script>` target it wraps — keeps the live probe's process-nesting depth
|
|
128
|
+
// consistent with the real run's invocation (see createSpawnPlan in cli-bridge.ts).
|
|
129
|
+
const directScript = resolveNpmShimScript(command);
|
|
130
|
+
if (directScript !== undefined) {
|
|
131
|
+
return { command: process.execPath, args: [directScript, ...args], invocationMode: "direct" };
|
|
132
|
+
}
|
|
133
|
+
if (extension === ".ps1") {
|
|
134
|
+
return {
|
|
135
|
+
command: "powershell.exe",
|
|
136
|
+
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command, ...args],
|
|
137
|
+
invocationMode: "powershell"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
124
140
|
return {
|
|
125
141
|
command: process.env.ComSpec || "cmd.exe",
|
|
126
142
|
args: ["/d", "/c", command, ...args],
|
|
127
143
|
invocationMode: "cmd_shell"
|
|
128
144
|
};
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
command: "powershell.exe",
|
|
132
|
-
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", command, ...args],
|
|
133
|
-
invocationMode: "powershell"
|
|
134
|
-
};
|
|
145
|
+
}
|
|
135
146
|
default:
|
|
136
147
|
return { command, args, invocationMode: "direct" };
|
|
137
148
|
}
|
|
@@ -236,7 +247,7 @@ function classifyProbeFailure(stderr, stdout, diagnosis) {
|
|
|
236
247
|
...diagnosis,
|
|
237
248
|
sandboxCompatible: false,
|
|
238
249
|
warnings,
|
|
239
|
-
remediation: "Update or reinstall Codex on this Windows host until `codex exec --sandbox workspace-write` can launch a simple shell command before running governed Codex work."
|
|
250
|
+
remediation: "MartinLoop already invokes Codex's underlying binary directly when it can resolve the npm shim (bypassing the cmd.exe/PowerShell wrapper hop), so this failure persisted even at the shallowest invocation depth available. Update or reinstall Codex on this Windows host until `codex exec --sandbox workspace-write` can launch a simple shell command before running governed Codex work."
|
|
240
251
|
}
|
|
241
252
|
};
|
|
242
253
|
}
|