@martinloop/mcp 0.3.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +82 -141
  2. package/dist/discovery-metadata.d.ts +1 -1
  3. package/dist/discovery-metadata.js +1 -1
  4. package/dist/resources.d.ts +1 -2
  5. package/dist/resources.js +47 -95
  6. package/dist/server-validation.d.ts +1 -1
  7. package/dist/server-validation.js +16 -11
  8. package/dist/server.d.ts +23 -0
  9. package/dist/server.js +152 -7
  10. package/dist/tools/preflight.d.ts +2 -0
  11. package/dist/tools/preflight.js +42 -2
  12. package/dist/tools/run-dossier.d.ts +3 -1
  13. package/dist/tools/run-dossier.js +40 -2
  14. package/dist/tools/run-loop.d.ts +1 -4
  15. package/dist/tools/run-loop.js +6 -15
  16. package/dist/tools/tool-support.d.ts +2 -1
  17. package/dist/tools/tool-support.js +1 -12
  18. package/dist/vendor/adapters/claude-cli.js +38 -35
  19. package/dist/vendor/adapters/cli-bridge.d.ts +9 -1
  20. package/dist/vendor/adapters/cli-bridge.js +54 -13
  21. package/dist/vendor/adapters/codex-launcher.d.ts +61 -1
  22. package/dist/vendor/adapters/codex-launcher.js +118 -19
  23. package/dist/vendor/adapters/index.d.ts +1 -3
  24. package/dist/vendor/adapters/index.js +1 -3
  25. package/dist/vendor/adapters/openai-compatible.js +51 -12
  26. package/dist/vendor/contracts/adapter-capabilities.d.ts +1 -1
  27. package/dist/vendor/contracts/context-handoff.d.ts +100 -0
  28. package/dist/vendor/contracts/context-handoff.js +11 -0
  29. package/dist/vendor/contracts/context-manifest.d.ts +195 -0
  30. package/dist/vendor/contracts/context-manifest.js +17 -0
  31. package/dist/vendor/contracts/context-shadow.d.ts +75 -0
  32. package/dist/vendor/contracts/context-shadow.js +8 -0
  33. package/dist/vendor/contracts/delivery.d.ts +47 -0
  34. package/dist/vendor/contracts/delivery.js +18 -0
  35. package/dist/vendor/contracts/exits.d.ts +126 -0
  36. package/dist/vendor/contracts/exits.js +32 -0
  37. package/dist/vendor/contracts/index.d.ts +41 -2
  38. package/dist/vendor/contracts/index.js +24 -0
  39. package/dist/vendor/contracts/mission.d.ts +102 -0
  40. package/dist/vendor/contracts/mission.js +64 -0
  41. package/dist/vendor/contracts/operator.d.ts +3 -1
  42. package/dist/vendor/contracts/operator.js +2 -1
  43. package/dist/vendor/contracts/verified-handoff.d.ts +100 -0
  44. package/dist/vendor/contracts/verified-handoff.js +35 -0
  45. package/dist/vendor/core/context-chain-gate.d.ts +89 -0
  46. package/dist/vendor/core/context-chain-gate.js +173 -0
  47. package/dist/vendor/core/context-compiler.d.ts +53 -0
  48. package/dist/vendor/core/context-compiler.js +261 -0
  49. package/dist/vendor/core/context-handoff.d.ts +42 -0
  50. package/dist/vendor/core/context-handoff.js +167 -0
  51. package/dist/vendor/core/context-shadow.d.ts +39 -0
  52. package/dist/vendor/core/context-shadow.js +200 -0
  53. package/dist/vendor/core/delivery/index.d.ts +6 -0
  54. package/dist/vendor/core/delivery/index.js +4 -0
  55. package/dist/vendor/core/delivery/message-client.d.ts +18 -0
  56. package/dist/vendor/core/delivery/message-client.js +38 -0
  57. package/dist/vendor/core/delivery/message-ledger.d.ts +35 -0
  58. package/dist/vendor/core/delivery/message-ledger.js +97 -0
  59. package/dist/vendor/core/delivery/message-schema.d.ts +16 -0
  60. package/dist/vendor/core/delivery/message-schema.js +111 -0
  61. package/dist/vendor/core/delivery/update-check.d.ts +19 -0
  62. package/dist/vendor/core/delivery/update-check.js +107 -0
  63. package/dist/vendor/core/exit-signal.d.ts +88 -0
  64. package/dist/vendor/core/exit-signal.js +339 -0
  65. package/dist/vendor/core/exits.d.ts +41 -0
  66. package/dist/vendor/core/exits.js +258 -0
  67. package/dist/vendor/core/index.d.ts +44 -1
  68. package/dist/vendor/core/index.js +1088 -662
  69. package/dist/vendor/core/mission/aggregate.d.ts +27 -0
  70. package/dist/vendor/core/mission/aggregate.js +42 -0
  71. package/dist/vendor/core/mission/index.d.ts +2 -0
  72. package/dist/vendor/core/mission/index.js +1 -0
  73. package/dist/vendor/core/persistence/compiler.d.ts +8 -0
  74. package/dist/vendor/core/persistence/compiler.js +156 -0
  75. package/dist/vendor/core/persistence/context-handoff-store.d.ts +68 -0
  76. package/dist/vendor/core/persistence/context-handoff-store.js +112 -0
  77. package/dist/vendor/core/persistence/index.d.ts +4 -0
  78. package/dist/vendor/core/persistence/index.js +2 -0
  79. package/dist/vendor/core/persistence/ledger.d.ts +1 -1
  80. package/dist/vendor/core/persistence/mission-store.d.ts +56 -0
  81. package/dist/vendor/core/persistence/mission-store.js +281 -0
  82. package/dist/vendor/core/policy.d.ts +2 -0
  83. package/dist/vendor/core/policy.js +5 -2
  84. package/dist/vendor/core/rollback.js +15 -1
  85. package/dist/vendor/core/savings.d.ts +13 -0
  86. package/dist/vendor/core/savings.js +22 -0
  87. package/dist/vendor/core/termination-store.d.ts +15 -0
  88. package/dist/vendor/core/termination-store.js +44 -0
  89. package/dist/vendor/core/verified-handoff.d.ts +67 -0
  90. package/dist/vendor/core/verified-handoff.js +210 -0
  91. package/package.json +3 -13
  92. package/server.json +3 -3
  93. package/dist/vendor/adapters/stub-agent-cli.d.ts +0 -8
  94. package/dist/vendor/adapters/stub-agent-cli.js +0 -40
  95. package/dist/vendor/adapters/verifier-only.d.ts +0 -9
  96. package/dist/vendor/adapters/verifier-only.js +0 -63
@@ -0,0 +1,111 @@
1
+ import { ALLOWED_ACTION_TYPES, DELIVERY_MESSAGE_SCHEMA_VERSION, MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION, } from "../../contracts/index.js";
2
+ const MAX_RESPONSE_BYTES = 8_192;
3
+ const MAX_TITLE_LENGTH = 120;
4
+ const MAX_BODY_LENGTH = 500;
5
+ const MAX_ID_LENGTH = 128;
6
+ const ALLOWED_ACTION_SET = new Set(ALLOWED_ACTION_TYPES);
7
+ /**
8
+ * Parse and validate a raw response body from the message selection endpoint.
9
+ * Returns ok:false on any violation — caller must render nothing and log the error.
10
+ */
11
+ export function parseMessageSelectionResponse(raw) {
12
+ if (Buffer.byteLength(raw, "utf8") > MAX_RESPONSE_BYTES) {
13
+ return { ok: false, error: "schema_response_too_large" };
14
+ }
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return { ok: false, error: "schema_malformed" };
21
+ }
22
+ if (!isObject(parsed)) {
23
+ return { ok: false, error: "schema_malformed" };
24
+ }
25
+ if (parsed["schemaVersion"] !== MESSAGE_SELECTION_RESPONSE_SCHEMA_VERSION) {
26
+ return { ok: false, error: "schema_version_unknown", detail: String(parsed["schemaVersion"]) };
27
+ }
28
+ if (!("message" in parsed) || parsed["message"] === undefined || parsed["message"] === null) {
29
+ return { ok: true, message: undefined };
30
+ }
31
+ const result = validateMessage(parsed["message"]);
32
+ if (!result.ok)
33
+ return result;
34
+ return { ok: true, message: result.message };
35
+ }
36
+ function validateMessage(raw) {
37
+ if (!isObject(raw)) {
38
+ return { ok: false, error: "schema_malformed" };
39
+ }
40
+ if (raw["schemaVersion"] !== DELIVERY_MESSAGE_SCHEMA_VERSION) {
41
+ return { ok: false, error: "schema_version_unknown", detail: String(raw["schemaVersion"]) };
42
+ }
43
+ const id = raw["id"];
44
+ if (typeof id !== "string" || id.length === 0 || id.length > MAX_ID_LENGTH || !/^[\w\-.:]+$/.test(id)) {
45
+ return { ok: false, error: "schema_invalid_id" };
46
+ }
47
+ const kind = raw["kind"];
48
+ if (kind !== "update" && kind !== "feedback_request" && kind !== "milestone") {
49
+ return { ok: false, error: "schema_unknown_kind", detail: String(kind) };
50
+ }
51
+ const revision = raw["revision"];
52
+ if (typeof revision !== "number" || !Number.isInteger(revision) || revision < 0) {
53
+ return { ok: false, error: "schema_malformed", detail: "revision" };
54
+ }
55
+ const title = raw["title"];
56
+ if (typeof title !== "string" || title.length === 0 || title.length > MAX_TITLE_LENGTH) {
57
+ return { ok: false, error: "schema_text_too_long", detail: "title" };
58
+ }
59
+ const body = raw["body"];
60
+ if (typeof body !== "string" || body.length === 0 || body.length > MAX_BODY_LENGTH) {
61
+ return { ok: false, error: "schema_text_too_long", detail: "body" };
62
+ }
63
+ const action = raw["action"];
64
+ if (!isObject(action)) {
65
+ return { ok: false, error: "schema_malformed", detail: "action" };
66
+ }
67
+ const actionType = action["type"];
68
+ if (typeof actionType !== "string" || !ALLOWED_ACTION_SET.has(actionType)) {
69
+ return { ok: false, error: "schema_unknown_action_type", detail: String(actionType) };
70
+ }
71
+ if ("url" in action && action["url"] !== undefined) {
72
+ if (typeof action["url"] !== "string" || !action["url"].startsWith("https://")) {
73
+ return { ok: false, error: "schema_url_not_https" };
74
+ }
75
+ }
76
+ if ("targetVersion" in action && action["targetVersion"] !== undefined) {
77
+ if (typeof action["targetVersion"] !== "string") {
78
+ return { ok: false, error: "schema_malformed", detail: "targetVersion" };
79
+ }
80
+ }
81
+ const expiresAt = raw["expiresAt"];
82
+ if (typeof expiresAt !== "string" || isNaN(Date.parse(expiresAt))) {
83
+ return { ok: false, error: "schema_invalid_date", detail: "expiresAt" };
84
+ }
85
+ const cooldownHours = raw["cooldownHours"];
86
+ if (typeof cooldownHours !== "number" ||
87
+ !Number.isFinite(cooldownHours) ||
88
+ cooldownHours < 0 ||
89
+ cooldownHours > 8760) {
90
+ return { ok: false, error: "schema_invalid_cooldown" };
91
+ }
92
+ const message = {
93
+ schemaVersion: DELIVERY_MESSAGE_SCHEMA_VERSION,
94
+ id,
95
+ revision,
96
+ kind,
97
+ title,
98
+ body,
99
+ action: {
100
+ type: actionType,
101
+ ...(action["url"] !== undefined ? { url: action["url"] } : {}),
102
+ ...(action["targetVersion"] !== undefined ? { targetVersion: action["targetVersion"] } : {}),
103
+ },
104
+ expiresAt,
105
+ cooldownHours,
106
+ };
107
+ return { ok: true, message };
108
+ }
109
+ function isObject(v) {
110
+ return typeof v === "object" && v !== null && !Array.isArray(v);
111
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns the installed version of the CLI package (`martin-loop`).
3
+ * Call site: CLI only. Never call from MCP.
4
+ */
5
+ export declare function getCliInstalledVersion(): string | null;
6
+ /**
7
+ * Returns the installed version of the MCP package (`@martinloop/mcp`).
8
+ * Call site: MCP only. Never call from CLI.
9
+ */
10
+ export declare function getMcpInstalledVersion(): string | null;
11
+ /**
12
+ * Compare two semver strings. Returns true when `available` is strictly
13
+ * newer than `current`.
14
+ *
15
+ * Prereleases are never surfaced as updates unless the user's current
16
+ * version is itself a prerelease (semver prerelease protection).
17
+ * Build metadata (+build) does not affect precedence.
18
+ */
19
+ export declare function isNewerVersion(current: string, available: string): boolean;
@@ -0,0 +1,107 @@
1
+ import { createRequire } from "node:module";
2
+ // CLI and MCP version checks are intentionally independent — they use different
3
+ // package names and must never share a constant or resolve function.
4
+ /**
5
+ * Returns the installed version of the CLI package (`martin-loop`).
6
+ * Call site: CLI only. Never call from MCP.
7
+ */
8
+ export function getCliInstalledVersion() {
9
+ return resolvePackageVersion("martin-loop");
10
+ }
11
+ /**
12
+ * Returns the installed version of the MCP package (`@martinloop/mcp`).
13
+ * Call site: MCP only. Never call from CLI.
14
+ */
15
+ export function getMcpInstalledVersion() {
16
+ return resolvePackageVersion("@martinloop/mcp");
17
+ }
18
+ /**
19
+ * Compare two semver strings. Returns true when `available` is strictly
20
+ * newer than `current`.
21
+ *
22
+ * Prereleases are never surfaced as updates unless the user's current
23
+ * version is itself a prerelease (semver prerelease protection).
24
+ * Build metadata (+build) does not affect precedence.
25
+ */
26
+ export function isNewerVersion(current, available) {
27
+ const currentVersion = parseSemver(current);
28
+ const availableVersion = parseSemver(available);
29
+ if (!currentVersion || !availableVersion)
30
+ return false;
31
+ // Stable users must never be offered a prerelease.
32
+ if (currentVersion.prerelease.length === 0 &&
33
+ availableVersion.prerelease.length > 0) {
34
+ return false;
35
+ }
36
+ return compareSemver(availableVersion, currentVersion) > 0;
37
+ }
38
+ function resolvePackageVersion(name) {
39
+ try {
40
+ const require = createRequire(import.meta.url);
41
+ const pkg = require(`${name}/package.json`);
42
+ return typeof pkg.version === "string" ? pkg.version : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ function parseSemver(version) {
49
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
50
+ if (!match)
51
+ return null;
52
+ const prerelease = [];
53
+ for (const identifier of match[4]?.split(".") ?? []) {
54
+ if (/^\d+$/.test(identifier)) {
55
+ // Leading zeros on numeric identifiers are invalid per SemVer spec.
56
+ if (identifier.length > 1 && identifier.startsWith("0"))
57
+ return null;
58
+ prerelease.push(Number(identifier));
59
+ }
60
+ else {
61
+ prerelease.push(identifier);
62
+ }
63
+ }
64
+ return {
65
+ major: Number(match[1]),
66
+ minor: Number(match[2]),
67
+ patch: Number(match[3]),
68
+ prerelease,
69
+ };
70
+ }
71
+ /**
72
+ * Returns a positive number if left > right, negative if left < right, 0 if equal.
73
+ * Implements SemVer 2.0.0 precedence rules exactly.
74
+ */
75
+ function compareSemver(left, right) {
76
+ for (const key of ["major", "minor", "patch"]) {
77
+ if (left[key] !== right[key])
78
+ return left[key] > right[key] ? 1 : -1;
79
+ }
80
+ // When base versions are equal: stable (no prerelease) > prerelease.
81
+ if (left.prerelease.length === 0 && right.prerelease.length === 0)
82
+ return 0;
83
+ if (left.prerelease.length === 0)
84
+ return 1;
85
+ if (right.prerelease.length === 0)
86
+ return -1;
87
+ // Both have prereleases — compare identifier by identifier.
88
+ const length = Math.max(left.prerelease.length, right.prerelease.length);
89
+ for (let index = 0; index < length; index++) {
90
+ const a = left.prerelease[index];
91
+ const b = right.prerelease[index];
92
+ if (a === undefined)
93
+ return -1; // fewer identifiers = lower precedence
94
+ if (b === undefined)
95
+ return 1;
96
+ if (a === b)
97
+ continue;
98
+ if (typeof a === "number" && typeof b === "number")
99
+ return a > b ? 1 : -1;
100
+ if (typeof a === "number")
101
+ return -1; // numeric < alphanumeric
102
+ if (typeof b === "number")
103
+ return 1;
104
+ return a > b ? 1 : -1; // both alphanumeric: lexicographic
105
+ }
106
+ return 0;
107
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Durable exit signals — per-kind, exclusive-create, precedence-safe.
3
+ *
4
+ * Each signal kind occupies its own immutable slot:
5
+ * <runDir>/signals/human_interrupt.json
6
+ * <runDir>/signals/external_event.json
7
+ *
8
+ * Atomic publication (crash-safe):
9
+ * 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir
10
+ * 2. Sync and close the tmp file
11
+ * 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
12
+ * 4. Unlink tmp in finally; best-effort dir sync on POSIX
13
+ *
14
+ * The final slot is only visible after a fully written tmp — a crash cannot
15
+ * leave a permanently malformed final slot.
16
+ *
17
+ * Per-kind files let different kinds coexist — an external event filed first
18
+ * does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured.
19
+ *
20
+ * Real-path containment guards against symlink/junction traversal out of the
21
+ * run store. Diagnostics from malformed signals are propagated — never silently
22
+ * discarded as "no signal".
23
+ */
24
+ import type { ExitSignalV1 } from "../contracts/index.js";
25
+ export interface ExitSignalSource {
26
+ poll(runId: string): Promise<SignalReadResult>;
27
+ }
28
+ export interface SignalReadResult {
29
+ signals: readonly ExitSignalV1[];
30
+ diagnostics: readonly SignalDiagnostic[];
31
+ }
32
+ export interface SignalDiagnostic {
33
+ kind: "human_interrupt" | "external_event";
34
+ error: string;
35
+ }
36
+ /**
37
+ * Typed error surfaced by the monitor when diagnostics are detected and no
38
+ * onDiagnostic handler is registered. Contains safe diagnostic codes and
39
+ * signal kinds — no absolute paths or payload secrets.
40
+ */
41
+ export declare class SignalDiagnosticError extends Error {
42
+ readonly diagnostics: readonly SignalDiagnostic[];
43
+ constructor(diagnostics: readonly SignalDiagnostic[]);
44
+ }
45
+ /** Path to a per-kind signal file — safe against lexical traversal. */
46
+ export declare function exitSignalPath(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): string;
47
+ /**
48
+ * Write one signal of its kind atomically. Returns "created" on success or
49
+ * "already_exists" when that kind was already filed (first writer wins).
50
+ * Throws on IO errors other than EEXIST on the final slot.
51
+ *
52
+ * Publication sequence (crash-safe):
53
+ * 1. Validate signal and size-check the payload
54
+ * 2. mkdir the signals directory
55
+ * 3. realpath-check: the real signals dir must stay inside the real root
56
+ * 4. Write complete JSON to a unique tmp file (wx, 0o600)
57
+ * 5. Sync and close the tmp file
58
+ * 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
59
+ * 7. Unlink tmp in finally; best-effort dir sync on POSIX
60
+ */
61
+ export declare function writeExitSignal(runsRoot: string, signal: ExitSignalV1): Promise<"created" | "already_exists">;
62
+ /**
63
+ * Read all present signals for a run (both kinds). Returns a structured
64
+ * result with any parse diagnostics rather than throwing on malformed files.
65
+ */
66
+ export declare function readAllExitSignals(runsRoot: string, runId: string): Promise<SignalReadResult>;
67
+ /** Read a single signal kind; returns undefined when absent. */
68
+ export declare function readExitSignal(runsRoot: string, runId: string, kind: "human_interrupt" | "external_event"): Promise<ExitSignalV1 | undefined>;
69
+ export declare function createFileExitSignalSource(runsRoot: string): ExitSignalSource;
70
+ /**
71
+ * Polls for any exit signal and calls onSignal with the full set when any
72
+ * new signal appears. Returns a dispose function — MUST be called on every
73
+ * return/throw path in the run harness (invariant: one interval per run).
74
+ *
75
+ * Diagnostics (malformed signals, containment failures) are routed to
76
+ * onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError
77
+ * through onError so callers always learn about corruption rather than
78
+ * silently treating it as "no signal".
79
+ */
80
+ export declare function startExitSignalMonitor(input: {
81
+ source?: ExitSignalSource;
82
+ runId: string;
83
+ controller: AbortController;
84
+ pollIntervalMs?: number;
85
+ onSignal: (signals: readonly ExitSignalV1[]) => void;
86
+ onDiagnostic?: (diagnostics: readonly SignalDiagnostic[]) => void;
87
+ onError: (error: Error) => void;
88
+ }): () => void;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Durable exit signals — per-kind, exclusive-create, precedence-safe.
3
+ *
4
+ * Each signal kind occupies its own immutable slot:
5
+ * <runDir>/signals/human_interrupt.json
6
+ * <runDir>/signals/external_event.json
7
+ *
8
+ * Atomic publication (crash-safe):
9
+ * 1. Write complete JSON to a unique tmp file (wx, 0o600) in the signals dir
10
+ * 2. Sync and close the tmp file
11
+ * 3. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
12
+ * 4. Unlink tmp in finally; best-effort dir sync on POSIX
13
+ *
14
+ * The final slot is only visible after a fully written tmp — a crash cannot
15
+ * leave a permanently malformed final slot.
16
+ *
17
+ * Per-kind files let different kinds coexist — an external event filed first
18
+ * does NOT prevent a later human interrupt. EXIT_PRECEDENCE is always honoured.
19
+ *
20
+ * Real-path containment guards against symlink/junction traversal out of the
21
+ * run store. Diagnostics from malformed signals are propagated — never silently
22
+ * discarded as "no signal".
23
+ */
24
+ import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
25
+ import { basename, dirname, join, resolve } from "node:path";
26
+ import { randomUUID } from "node:crypto";
27
+ const MAX_SIGNAL_BYTES = 64 * 1024;
28
+ const RUN_ID_RE = /^[A-Za-z0-9._-]{1,128}$/u;
29
+ /**
30
+ * Typed error surfaced by the monitor when diagnostics are detected and no
31
+ * onDiagnostic handler is registered. Contains safe diagnostic codes and
32
+ * signal kinds — no absolute paths or payload secrets.
33
+ */
34
+ export class SignalDiagnosticError extends Error {
35
+ diagnostics;
36
+ constructor(diagnostics) {
37
+ const summary = diagnostics
38
+ .map((d) => `${d.kind}: ${d.error}`)
39
+ .join("; ");
40
+ super(`Signal diagnostics: ${summary}`);
41
+ this.name = "SignalDiagnosticError";
42
+ this.diagnostics = diagnostics;
43
+ }
44
+ }
45
+ /** Path to a per-kind signal file — safe against lexical traversal. */
46
+ export function exitSignalPath(runsRoot, runId, kind) {
47
+ assertRunId(runId);
48
+ const root = resolve(runsRoot);
49
+ const runDir = resolve(root, runId);
50
+ const sep = process.platform === "win32" ? "\\" : "/";
51
+ if (runDir !== root && !runDir.startsWith(`${root}${sep}`)) {
52
+ throw new Error("Resolved run path escapes runs root");
53
+ }
54
+ const filename = kind === "human_interrupt"
55
+ ? "human_interrupt.json"
56
+ : "external_event.json";
57
+ return join(runDir, "signals", filename);
58
+ }
59
+ /**
60
+ * Write one signal of its kind atomically. Returns "created" on success or
61
+ * "already_exists" when that kind was already filed (first writer wins).
62
+ * Throws on IO errors other than EEXIST on the final slot.
63
+ *
64
+ * Publication sequence (crash-safe):
65
+ * 1. Validate signal and size-check the payload
66
+ * 2. mkdir the signals directory
67
+ * 3. realpath-check: the real signals dir must stay inside the real root
68
+ * 4. Write complete JSON to a unique tmp file (wx, 0o600)
69
+ * 5. Sync and close the tmp file
70
+ * 6. Hard-link tmp to the final per-kind slot (EEXIST → already_exists)
71
+ * 7. Unlink tmp in finally; best-effort dir sync on POSIX
72
+ */
73
+ export async function writeExitSignal(runsRoot, signal) {
74
+ validateExitSignal(signal);
75
+ const payload = `${JSON.stringify(signal)}\n`;
76
+ if (Buffer.byteLength(payload, "utf8") > MAX_SIGNAL_BYTES) {
77
+ throw new Error("Exit signal payload exceeds 64 KiB");
78
+ }
79
+ const finalPath = exitSignalPath(runsRoot, signal.runId, signal.kind);
80
+ const sigDir = dirname(finalPath);
81
+ await mkdir(sigDir, { recursive: true });
82
+ // Real-path containment: the real signals directory must stay inside the
83
+ // real run store root. This catches symlink/junction escapes that lexical
84
+ // resolve() cannot detect.
85
+ const realRoot = await realpath(resolve(runsRoot));
86
+ const realSigDir = await realpath(sigDir);
87
+ if (!isContained(realRoot, realSigDir)) {
88
+ throw new Error("Signal directory escapes the run store");
89
+ }
90
+ // Atomic publication via unique tmp file + hard-link.
91
+ // Both tmp and final are in the same directory, so they are on the same
92
+ // filesystem — hard-link is always available.
93
+ const tmpPath = join(sigDir, `.tmp-${randomUUID()}`);
94
+ let handle;
95
+ try {
96
+ handle = await open(tmpPath, "wx", 0o600);
97
+ await handle.writeFile(payload, "utf8");
98
+ await handle.sync();
99
+ await handle.close();
100
+ handle = undefined;
101
+ }
102
+ catch (err) {
103
+ if (handle !== undefined) {
104
+ await handle.close().catch(() => undefined);
105
+ handle = undefined;
106
+ }
107
+ await unlink(tmpPath).catch(() => undefined);
108
+ throw err;
109
+ }
110
+ // Claim the final slot atomically — link() fails with EEXIST if slot is
111
+ // already occupied, preserving first-signal-wins without replacing the file.
112
+ let outcome;
113
+ try {
114
+ await link(tmpPath, finalPath);
115
+ outcome = "created";
116
+ }
117
+ catch (err) {
118
+ if (err.code === "EEXIST") {
119
+ outcome = "already_exists";
120
+ }
121
+ else {
122
+ throw err;
123
+ }
124
+ }
125
+ finally {
126
+ await unlink(tmpPath).catch(() => undefined);
127
+ // Best-effort directory sync so the new entry is durable on POSIX.
128
+ // Windows directories cannot be opened with O_RDONLY — skip.
129
+ if (process.platform !== "win32") {
130
+ const dh = await open(sigDir, "r").catch(() => undefined);
131
+ if (dh !== undefined) {
132
+ await dh.sync().catch(() => undefined);
133
+ await dh.close().catch(() => undefined);
134
+ }
135
+ }
136
+ }
137
+ return outcome;
138
+ }
139
+ /**
140
+ * Read all present signals for a run (both kinds). Returns a structured
141
+ * result with any parse diagnostics rather than throwing on malformed files.
142
+ */
143
+ export async function readAllExitSignals(runsRoot, runId) {
144
+ const kinds = [
145
+ "human_interrupt",
146
+ "external_event"
147
+ ];
148
+ const signals = [];
149
+ const diagnostics = [];
150
+ for (const kind of kinds) {
151
+ const result = await readOneSignal(runsRoot, runId, kind);
152
+ if (result.signal !== undefined)
153
+ signals.push(result.signal);
154
+ if (result.diagnostic !== undefined)
155
+ diagnostics.push(result.diagnostic);
156
+ }
157
+ return { signals, diagnostics };
158
+ }
159
+ /** Read a single signal kind; returns undefined when absent. */
160
+ export async function readExitSignal(runsRoot, runId, kind) {
161
+ const { signal } = await readOneSignal(runsRoot, runId, kind);
162
+ return signal;
163
+ }
164
+ export function createFileExitSignalSource(runsRoot) {
165
+ return {
166
+ poll: async (runId) => readAllExitSignals(runsRoot, runId)
167
+ };
168
+ }
169
+ /**
170
+ * Polls for any exit signal and calls onSignal with the full set when any
171
+ * new signal appears. Returns a dispose function — MUST be called on every
172
+ * return/throw path in the run harness (invariant: one interval per run).
173
+ *
174
+ * Diagnostics (malformed signals, containment failures) are routed to
175
+ * onDiagnostic when provided; otherwise surfaced as a SignalDiagnosticError
176
+ * through onError so callers always learn about corruption rather than
177
+ * silently treating it as "no signal".
178
+ */
179
+ export function startExitSignalMonitor(input) {
180
+ if (input.source === undefined)
181
+ return () => undefined;
182
+ const intervalMs = input.pollIntervalMs ?? 250;
183
+ let polling = false;
184
+ let lastCount = 0;
185
+ const timer = setInterval(() => {
186
+ if (polling || input.controller.signal.aborted)
187
+ return;
188
+ polling = true;
189
+ void input.source
190
+ .poll(input.runId)
191
+ .then(({ signals, diagnostics }) => {
192
+ if (input.controller.signal.aborted)
193
+ return;
194
+ // Surface diagnostics without aborting the run
195
+ if (diagnostics.length > 0) {
196
+ if (input.onDiagnostic !== undefined) {
197
+ input.onDiagnostic(diagnostics);
198
+ }
199
+ else {
200
+ input.onError(new SignalDiagnosticError(diagnostics));
201
+ }
202
+ }
203
+ // Only fire onSignal when new signals appear
204
+ if (signals.length > lastCount) {
205
+ lastCount = signals.length;
206
+ input.onSignal(signals);
207
+ input.controller.abort(signals);
208
+ }
209
+ })
210
+ .catch((err) => {
211
+ const e = err instanceof Error ? err : new Error(String(err));
212
+ input.onError(e);
213
+ input.controller.abort(e);
214
+ })
215
+ .finally(() => { polling = false; });
216
+ }, intervalMs);
217
+ timer.unref?.();
218
+ return () => clearInterval(timer);
219
+ }
220
+ // ─── Internal helpers ────────────────────────────────────────────────────────
221
+ async function readOneSignal(runsRoot, runId, kind) {
222
+ assertRunId(runId);
223
+ const finalPath = exitSignalPath(runsRoot, runId, kind);
224
+ const sigDir = dirname(finalPath);
225
+ // Real-path containment for reads: signals dir must stay inside the run store.
226
+ // ENOENT on the signals dir means no signals have been written — return absent.
227
+ let realSigDir;
228
+ try {
229
+ realSigDir = await realpath(sigDir);
230
+ }
231
+ catch (err) {
232
+ if (err.code === "ENOENT")
233
+ return {};
234
+ return {
235
+ diagnostic: {
236
+ kind,
237
+ error: `containment check failed [${err.code ?? "ERR"}]`
238
+ }
239
+ };
240
+ }
241
+ const realRoot = await realpath(resolve(runsRoot));
242
+ if (!isContained(realRoot, realSigDir)) {
243
+ return { diagnostic: { kind, error: "signal directory escapes the run store" } };
244
+ }
245
+ // lstat: reject symlinks and surface size violations before reading.
246
+ // With atomic publication, the final file is always a complete hardlinked
247
+ // inode — a size=0 or a symlink indicates tampering.
248
+ let size;
249
+ try {
250
+ const lstats = await lstat(finalPath);
251
+ if (lstats.isSymbolicLink()) {
252
+ return { diagnostic: { kind, error: "signal file is a symbolic link" } };
253
+ }
254
+ size = lstats.size;
255
+ }
256
+ catch (err) {
257
+ if (err.code === "ENOENT")
258
+ return {};
259
+ return {
260
+ diagnostic: {
261
+ kind,
262
+ error: `stat error [${err.code ?? "ERR"}]`
263
+ }
264
+ };
265
+ }
266
+ if (size > MAX_SIGNAL_BYTES) {
267
+ return { diagnostic: { kind, error: "signal file exceeds 64 KiB" } };
268
+ }
269
+ let text;
270
+ try {
271
+ text = await readFile(finalPath, "utf8");
272
+ }
273
+ catch (err) {
274
+ if (err.code === "ENOENT")
275
+ return {};
276
+ return {
277
+ diagnostic: {
278
+ kind,
279
+ error: `read error [${err.code ?? "ERR"}]`
280
+ }
281
+ };
282
+ }
283
+ // With atomic publication the final file should always contain valid JSON.
284
+ // A parse failure indicates file corruption — surface as diagnostic.
285
+ let parsed;
286
+ try {
287
+ parsed = JSON.parse(text);
288
+ }
289
+ catch {
290
+ return { diagnostic: { kind, error: "signal file contains invalid JSON" } };
291
+ }
292
+ try {
293
+ validateExitSignal(parsed);
294
+ return { signal: parsed };
295
+ }
296
+ catch (err) {
297
+ return { diagnostic: { kind, error: String(err) } };
298
+ }
299
+ }
300
+ function validateExitSignal(signal) {
301
+ if (signal.schemaVersion !== "exit-signal/1") {
302
+ throw new Error(`Unsupported exit signal schema: ${String(signal.schemaVersion)}`);
303
+ }
304
+ assertRunId(signal.runId);
305
+ if (signal.kind !== "human_interrupt" && signal.kind !== "external_event") {
306
+ throw new Error(`Invalid exit signal kind: ${String(signal.kind)}`);
307
+ }
308
+ if (!signal.requestedBy?.trim() || !Number.isFinite(Date.parse(signal.requestedAt))) {
309
+ throw new Error("Exit signal requester and timestamp are required");
310
+ }
311
+ if (signal.kind === "external_event" && signal.externalEvent === undefined) {
312
+ throw new Error("external_event signal requires externalEvent evidence");
313
+ }
314
+ if (signal.externalEvent !== undefined) {
315
+ const ev = signal.externalEvent;
316
+ if (!ev.source?.trim() || !ev.event?.trim()) {
317
+ throw new Error("External event source and event are required");
318
+ }
319
+ if (!["satisfied", "superseded", "cancelled"].includes(ev.disposition)) {
320
+ throw new Error(`Invalid external event disposition: ${String(ev.disposition)}`);
321
+ }
322
+ if (!Number.isFinite(Date.parse(ev.observedAt))) {
323
+ throw new Error("External event observedAt must be an ISO timestamp");
324
+ }
325
+ }
326
+ }
327
+ function assertRunId(runId) {
328
+ if (runId === "." ||
329
+ runId === ".." ||
330
+ !RUN_ID_RE.test(runId) ||
331
+ basename(runId) !== runId) {
332
+ throw new Error(`Invalid run id: ${JSON.stringify(runId)}`);
333
+ }
334
+ }
335
+ /** True when candidate equals root or is a strict descendant under root. */
336
+ function isContained(root, candidate) {
337
+ const sep = process.platform === "win32" ? "\\" : "/";
338
+ return candidate === root || candidate.startsWith(`${root}${sep}`);
339
+ }