@martinloop/mcp 0.8.3 → 0.8.5
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/dist/package-version.d.ts +1 -1
- package/dist/package-version.js +1 -1
- package/dist/vendor/adapters/claude-cli.d.ts +2 -0
- package/dist/vendor/adapters/claude-cli.js +16 -7
- package/dist/vendor/adapters/codex-cli.js +1 -0
- package/dist/vendor/adapters/process-supervisor.d.ts +7 -0
- package/dist/vendor/adapters/process-supervisor.js +76 -2
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const MARTIN_MCP_PACKAGE_VERSION = "0.8.
|
|
1
|
+
export declare const MARTIN_MCP_PACKAGE_VERSION = "0.8.5";
|
package/dist/package-version.js
CHANGED
|
@@ -63,6 +63,8 @@ export interface AgentCliAdapterOptions {
|
|
|
63
63
|
streamingTokenCap?: boolean;
|
|
64
64
|
/** Provider detail-token fields are already included in input/output token totals. */
|
|
65
65
|
streamingUsageDetailsIncludedInTotals?: boolean;
|
|
66
|
+
/** Whether provider usage arrives incrementally or only on a terminal event. */
|
|
67
|
+
streamingUsageReportingMode?: "incremental" | "terminal";
|
|
66
68
|
/** Estimated floor used by the host to reject impossible token caps before spawning. */
|
|
67
69
|
budgetPreflight?: {
|
|
68
70
|
minimumViableTokens: number;
|
|
@@ -383,7 +383,7 @@ function extractGeminiJsonResult(stdout, modelLabel) {
|
|
|
383
383
|
})
|
|
384
384
|
};
|
|
385
385
|
}
|
|
386
|
-
function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate, capTokens, detailTokensIncludedInTotals = false, observedUsageGovernor) {
|
|
386
|
+
function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate, capTokens, detailTokensIncludedInTotals = false, observedUsageGovernor, usageReportingMode = "incremental") {
|
|
387
387
|
let pricingResolution = resolveModelPricing(modelLabel);
|
|
388
388
|
// Safety margin: terminate at 80% of cap to bound one-turn overshoot.
|
|
389
389
|
// Without this, a single expensive turn can blow past the cap before the
|
|
@@ -424,15 +424,22 @@ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate,
|
|
|
424
424
|
finalObservationPublished = true;
|
|
425
425
|
try {
|
|
426
426
|
const decision = observedUsageGovernor({ cumulativeUsd, cumulativeTokens, turns, final });
|
|
427
|
-
if (decision.action === "terminate") {
|
|
427
|
+
if (decision.action === "terminate" && !(usageReportingMode === "terminal" && final)) {
|
|
428
428
|
terminate(`Parent observed-usage governor terminated the subprocess: ${decision.reason}.`);
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
catch (error) {
|
|
432
|
-
|
|
432
|
+
if (!(usageReportingMode === "terminal" && final)) {
|
|
433
|
+
terminate(`Parent observed-usage governor failed closed: ${error instanceof Error ? error.message : String(error)}.`);
|
|
434
|
+
}
|
|
433
435
|
}
|
|
434
436
|
return;
|
|
435
437
|
}
|
|
438
|
+
// Codex reports authoritative usage on turn.completed, after the provider
|
|
439
|
+
// turn and its workspace edits have already finished. Record that terminal
|
|
440
|
+
// settlement, but do not kill and discard completed work at this point.
|
|
441
|
+
if (usageReportingMode === "terminal" && final)
|
|
442
|
+
return;
|
|
436
443
|
if (capTokens !== undefined && capTokens > 0 && cumulativeTokens > capTokens) {
|
|
437
444
|
terminate(`Streaming token lease exceeded after ${String(turns)} turn(s): observed ${String(cumulativeTokens)} tokens ` +
|
|
438
445
|
`surpassed the per-attempt token cap ${String(capTokens)}. Subprocess terminated to prevent additional token usage.`);
|
|
@@ -557,7 +564,7 @@ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate,
|
|
|
557
564
|
}
|
|
558
565
|
return;
|
|
559
566
|
}
|
|
560
|
-
extractUsageFromEvent(event, terminate);
|
|
567
|
+
extractUsageFromEvent(event, terminate, usageReportingMode === "terminal" && event.type === "turn.completed");
|
|
561
568
|
};
|
|
562
569
|
return {
|
|
563
570
|
onChunk: (chunk, terminate) => {
|
|
@@ -572,7 +579,8 @@ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate,
|
|
|
572
579
|
const bytesCeiling = blendedCostPerChar && blendedCostPerChar > 0
|
|
573
580
|
? Math.ceil((capUsd / blendedCostPerChar) * 2)
|
|
574
581
|
: undefined;
|
|
575
|
-
if (
|
|
582
|
+
if (usageReportingMode === "incremental" &&
|
|
583
|
+
!usageEventSeen && capUsd > 0 && bytesCeiling !== undefined && totalBytes > bytesCeiling) {
|
|
576
584
|
terminate(`Streaming byte ceiling exceeded (${String(totalBytes)} bytes > ${String(bytesCeiling)} ceiling) ` +
|
|
577
585
|
`without any usage events parsed. The Claude stream-json event format may have changed. ` +
|
|
578
586
|
`Subprocess terminated as a fallback budget guard for cap $${capUsd.toFixed(4)}.`);
|
|
@@ -582,7 +590,8 @@ function createStreamingUsageInspector(capUsd, modelLabel, promptTokenEstimate,
|
|
|
582
590
|
// without a single usage event, estimate spend from byte volume and
|
|
583
591
|
// enforce the cap. This catches cases where Claude's event format
|
|
584
592
|
// changed but bytes are still flowing.
|
|
585
|
-
if (
|
|
593
|
+
if (usageReportingMode === "incremental" &&
|
|
594
|
+
!usageEventSeen &&
|
|
586
595
|
capUsd > 0 &&
|
|
587
596
|
firstChunkAt !== undefined &&
|
|
588
597
|
Date.now() - firstChunkAt > USAGE_BLIND_TIMEOUT_MS &&
|
|
@@ -726,7 +735,7 @@ export function createAgentCliAdapter(options) {
|
|
|
726
735
|
const streamingUsage = options.streamingUsageCap &&
|
|
727
736
|
(request.context.remainingBudgetUsd > 0 ||
|
|
728
737
|
(options.streamingTokenCap && (request.context.remainingTokens ?? 0) > 0))
|
|
729
|
-
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn, options.streamingTokenCap ? request.context.remainingTokens : undefined, options.streamingUsageDetailsIncludedInTotals, request.observedUsageGovernor)
|
|
738
|
+
? createStreamingUsageInspector(request.context.remainingBudgetUsd, options.model ?? options.command, estimatedUsage.tokensIn, options.streamingTokenCap ? request.context.remainingTokens : undefined, options.streamingUsageDetailsIncludedInTotals, request.observedUsageGovernor, options.streamingUsageReportingMode)
|
|
730
739
|
: undefined;
|
|
731
740
|
const agentResult = await runSubprocess(options.executionCommand ?? options.command, args, {
|
|
732
741
|
cwd: workingDirectory,
|
|
@@ -51,6 +51,7 @@ export function createCodexCliAdapter(options = {}) {
|
|
|
51
51
|
streamingUsageCap: true,
|
|
52
52
|
streamingTokenCap: true,
|
|
53
53
|
streamingUsageDetailsIncludedInTotals: true,
|
|
54
|
+
streamingUsageReportingMode: "terminal",
|
|
54
55
|
budgetPreflight: {
|
|
55
56
|
minimumViableTokens: CODEX_MINIMUM_VIABLE_TOKEN_BUDGET,
|
|
56
57
|
basis: CODEX_TOKEN_BUDGET_PREFLIGHT_BASIS
|
|
@@ -57,4 +57,11 @@ export declare function spawnSupervisedProcess(command: string, args: readonly s
|
|
|
57
57
|
export declare function spawnSupervisedProcessForTest(command: string, args: readonly string[], options: SpawnSupervisedProcessOptions & {
|
|
58
58
|
spawnImpl: ProcessSpawnLike;
|
|
59
59
|
}, seams: ProcessSupervisorTestSeams): Promise<SupervisedProcessResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Classify an OS process-table snapshot with PID, PGID and STAT columns.
|
|
62
|
+
* POSIX Z means terminated but not yet reaped; other states remain live.
|
|
63
|
+
* Malformed or empty output must never assert cleanup success.
|
|
64
|
+
* @internal - deterministic pure classification for process-supervisor tests
|
|
65
|
+
*/
|
|
66
|
+
export declare function classifyPosixProcessGroupSnapshot(output: string, ownedGroupPid: number): "closed" | "alive" | "absent" | "invalid";
|
|
60
67
|
export {};
|
|
@@ -327,14 +327,21 @@ async function terminatePosixGroupAfterRootExit(pid, killGroup, graceMs, waitImp
|
|
|
327
327
|
const initial = probePosixProcessGroup(target, killGroup);
|
|
328
328
|
if (initial === "closed")
|
|
329
329
|
return { state: "closed" };
|
|
330
|
-
if (initial instanceof Error)
|
|
330
|
+
if (initial instanceof Error) {
|
|
331
|
+
if (isErrno(initial, "EPERM") && await confirmInactivePosixGroup(pid, killGroup)) {
|
|
332
|
+
return { state: "closed" };
|
|
333
|
+
}
|
|
331
334
|
return cleanupFailure("termination_failed", initial);
|
|
335
|
+
}
|
|
332
336
|
try {
|
|
333
337
|
killGroup(target, "SIGTERM");
|
|
334
338
|
}
|
|
335
339
|
catch (error) {
|
|
336
340
|
if (isErrno(error, "ESRCH"))
|
|
337
341
|
return { state: "closed" };
|
|
342
|
+
if (isErrno(error, "EPERM") && await confirmInactivePosixGroup(pid, killGroup)) {
|
|
343
|
+
return { state: "closed" };
|
|
344
|
+
}
|
|
338
345
|
return cleanupFailure("termination_failed", error);
|
|
339
346
|
}
|
|
340
347
|
if (await waitForPosixGroupExit(target, killGroup, graceMs, waitImpl))
|
|
@@ -345,14 +352,30 @@ async function terminatePosixGroupAfterRootExit(pid, killGroup, graceMs, waitImp
|
|
|
345
352
|
catch (error) {
|
|
346
353
|
if (isErrno(error, "ESRCH"))
|
|
347
354
|
return { state: "closed" };
|
|
355
|
+
if (isErrno(error, "EPERM") && await confirmInactivePosixGroup(pid, killGroup)) {
|
|
356
|
+
return { state: "closed" };
|
|
357
|
+
}
|
|
348
358
|
return cleanupFailure("termination_failed", error);
|
|
349
359
|
}
|
|
350
360
|
if (await waitForPosixGroupExit(target, killGroup, graceMs, waitImpl))
|
|
351
361
|
return { state: "closed" };
|
|
362
|
+
// A reparented, already-terminated descendant can remain as a zombie in the
|
|
363
|
+
// process table. On POSIX, kill(-pgid, 0) still sees that process group even
|
|
364
|
+
// though its remaining members cannot execute code. Prove the absence of
|
|
365
|
+
// *runnable* group members rather than treating an unreapable zombie as live.
|
|
366
|
+
// Do not turn an unreadable/ambiguous process snapshot into success.
|
|
367
|
+
const snapshot = await inspectPosixProcessGroupMembers(pid);
|
|
368
|
+
if (snapshot === "closed" || (snapshot === "absent" && probePosixProcessGroup(target, killGroup) === "closed")) {
|
|
369
|
+
return { state: "closed" };
|
|
370
|
+
}
|
|
371
|
+
if (snapshot instanceof Error)
|
|
372
|
+
return cleanupFailure("termination_failed", snapshot);
|
|
352
373
|
return {
|
|
353
374
|
state: "failed",
|
|
354
375
|
code: "tree_close_timeout",
|
|
355
|
-
message:
|
|
376
|
+
message: snapshot === "invalid"
|
|
377
|
+
? "Could not prove owned POSIX group closure from the process table."
|
|
378
|
+
: "Owned POSIX process group still has live members after SIGTERM and SIGKILL."
|
|
356
379
|
};
|
|
357
380
|
}
|
|
358
381
|
async function waitForPosixGroupExit(target, killGroup, durationMs, waitImpl) {
|
|
@@ -367,6 +390,14 @@ async function waitForPosixGroupExit(target, killGroup, durationMs, waitImpl) {
|
|
|
367
390
|
}
|
|
368
391
|
return probePosixProcessGroup(target, killGroup) === "closed";
|
|
369
392
|
}
|
|
393
|
+
async function confirmInactivePosixGroup(ownedGroupPid, killGroup) {
|
|
394
|
+
const snapshot = await inspectPosixProcessGroupMembers(ownedGroupPid);
|
|
395
|
+
if (snapshot === "closed")
|
|
396
|
+
return true; // Observed owned members are all defunct zombies.
|
|
397
|
+
// An absent group is not proof on its own after EPERM. Require an ESRCH re-probe.
|
|
398
|
+
return snapshot === "absent"
|
|
399
|
+
&& probePosixProcessGroup(-ownedGroupPid, killGroup) === "closed";
|
|
400
|
+
}
|
|
370
401
|
function probePosixProcessGroup(target, killGroup) {
|
|
371
402
|
try {
|
|
372
403
|
killGroup(target, 0);
|
|
@@ -378,6 +409,49 @@ function probePosixProcessGroup(target, killGroup) {
|
|
|
378
409
|
return error instanceof Error ? error : new Error(String(error));
|
|
379
410
|
}
|
|
380
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Classify an OS process-table snapshot with PID, PGID and STAT columns.
|
|
414
|
+
* POSIX Z means terminated but not yet reaped; other states remain live.
|
|
415
|
+
* Malformed or empty output must never assert cleanup success.
|
|
416
|
+
* @internal - deterministic pure classification for process-supervisor tests
|
|
417
|
+
*/
|
|
418
|
+
export function classifyPosixProcessGroupSnapshot(output, ownedGroupPid) {
|
|
419
|
+
if (!isOwnedPid(ownedGroupPid))
|
|
420
|
+
return "invalid";
|
|
421
|
+
let observed = 0;
|
|
422
|
+
let matchingGroupMembers = 0;
|
|
423
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
424
|
+
if (!line.trim())
|
|
425
|
+
continue;
|
|
426
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/u.exec(line);
|
|
427
|
+
if (!match)
|
|
428
|
+
return "invalid";
|
|
429
|
+
observed += 1;
|
|
430
|
+
if (Number(match[2]) === ownedGroupPid) {
|
|
431
|
+
matchingGroupMembers += 1;
|
|
432
|
+
if (!match[3].startsWith("Z"))
|
|
433
|
+
return "alive";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (observed === 0)
|
|
437
|
+
return "invalid";
|
|
438
|
+
return matchingGroupMembers > 0 ? "closed" : "absent";
|
|
439
|
+
}
|
|
440
|
+
async function inspectPosixProcessGroupMembers(ownedGroupPid) {
|
|
441
|
+
return new Promise((resolveResult) => {
|
|
442
|
+
execFile("ps", ["-axo", "pid=,pgid=,stat="], {
|
|
443
|
+
encoding: "utf8",
|
|
444
|
+
timeout: 2_000,
|
|
445
|
+
maxBuffer: 8 * 1024 * 1024
|
|
446
|
+
}, (error, stdout) => {
|
|
447
|
+
if (error) {
|
|
448
|
+
resolveResult(error);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
resolveResult(classifyPosixProcessGroupSnapshot(stdout, ownedGroupPid));
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
381
455
|
function isErrno(error, code) {
|
|
382
456
|
return typeof error === "object" && error !== null && "code" in error
|
|
383
457
|
&& String(error.code) === code;
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/Keesan12/martin-loop",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.8.
|
|
10
|
+
"version": "0.8.5",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "@martinloop/mcp",
|
|
15
|
-
"version": "0.8.
|
|
15
|
+
"version": "0.8.5",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|