@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.
Files changed (39) hide show
  1. package/README.md +10 -2
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +2 -0
  4. package/dist/package-version.d.ts +1 -1
  5. package/dist/package-version.js +1 -1
  6. package/dist/prompts.js +19 -1
  7. package/dist/resources.d.ts +2 -0
  8. package/dist/resources.js +202 -148
  9. package/dist/server-validation.d.ts +1 -1
  10. package/dist/server-validation.js +17 -0
  11. package/dist/server.d.ts +23 -0
  12. package/dist/server.js +161 -5
  13. package/dist/tools/doctor.js +6 -0
  14. package/dist/tools/tool-support.d.ts +1 -1
  15. package/dist/tools/tool-support.js +1 -1
  16. package/dist/vendor/adapters/claude-cli.d.ts +2 -2
  17. package/dist/vendor/adapters/claude-cli.js +22 -7
  18. package/dist/vendor/adapters/cli-bridge.d.ts +7 -0
  19. package/dist/vendor/adapters/cli-bridge.js +94 -8
  20. package/dist/vendor/adapters/codex-launcher.js +18 -7
  21. package/dist/vendor/adapters/openai-compatible.js +90 -57
  22. package/dist/vendor/contracts/index.d.ts +1 -1
  23. package/dist/vendor/contracts/index.js +1 -0
  24. package/dist/vendor/core/index.d.ts +10 -2
  25. package/dist/vendor/core/index.js +31 -3
  26. package/dist/vendor/core/persistence/memory-store.d.ts +63 -0
  27. package/dist/vendor/core/persistence/memory-store.js +115 -0
  28. package/dist/vendor/core/persistence/trace-store.d.ts +85 -0
  29. package/dist/vendor/core/persistence/trace-store.js +125 -0
  30. package/dist/vendor/core/repo-analyzer.d.ts +53 -0
  31. package/dist/vendor/core/repo-analyzer.js +263 -0
  32. package/dist/vendor/core/routing.d.ts +42 -0
  33. package/dist/vendor/core/routing.js +156 -5
  34. package/dist/vendor/core/test-integrity.d.ts +64 -0
  35. package/dist/vendor/core/test-integrity.js +197 -0
  36. package/dist/workflow-state.d.ts +1 -1
  37. package/dist/workflow-state.js +12 -4
  38. package/package.json +76 -76
  39. 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
- return createToolSuccessResult(output, `Estimate: ${route.selectedMode} route, ~$${route.expectedCostUsd.toFixed(2)} expected cost, ${route.expectedPreworkBurnPct}% pre-work burn. Recommended budget: $${recommendedBudgetUsd.toFixed(2)}.`);
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
- await connectMartinMcpStdioServer();
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
  }
@@ -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 effectiveCapUsd = capUsd * 0.8;
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)} (80% threshold: $${effectiveCapUsd.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 += (turnTokensIn / 1000) * pricing.inputPer1K + (turnTokensOut / 1000) * pricing.outputPer1K;
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
- case ".ps1":
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
  }