@evomap/evolver-core 2.0.0-beta.0 → 2.0.0-beta.10

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 (137) hide show
  1. package/assets/gep/genes.jsonl +5 -0
  2. package/dist/algo/candidateAssembly.js +23 -14
  3. package/dist/algo/capabilityCandidates.d.ts +2 -0
  4. package/dist/algo/capabilityCandidates.js +5 -0
  5. package/dist/algo/conversationSniffer.d.ts +18 -0
  6. package/dist/algo/conversationSniffer.js +149 -0
  7. package/dist/algo/cycleEngine.d.ts +11 -0
  8. package/dist/algo/cycleEngine.js +14 -6
  9. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  10. package/dist/algo/cycleFailureClassifier.js +13 -5
  11. package/dist/algo/geneIntake.d.ts +14 -4
  12. package/dist/algo/geneIntake.js +37 -9
  13. package/dist/algo/geneSelection.d.ts +18 -1
  14. package/dist/algo/geneSelection.js +42 -18
  15. package/dist/algo/index.d.ts +2 -0
  16. package/dist/algo/index.js +2 -0
  17. package/dist/algo/memoryGraph.d.ts +62 -0
  18. package/dist/algo/memoryGraph.js +86 -0
  19. package/dist/algo/orchestrator.d.ts +3 -0
  20. package/dist/algo/orchestrator.js +14 -2
  21. package/dist/assetstore/assetSidecarRecords.d.ts +28 -0
  22. package/dist/assetstore/assetSidecarRecords.js +376 -0
  23. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  24. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  25. package/dist/assetstore/assetStoreHealth.d.ts +75 -0
  26. package/dist/assetstore/assetStoreHealth.js +277 -0
  27. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  28. package/dist/assetstore/assetStoreLayout.js +6 -0
  29. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  30. package/dist/assetstore/assetStoreStorage.js +327 -0
  31. package/dist/assetstore/assetSyncLedger.d.ts +118 -0
  32. package/dist/assetstore/assetSyncLedger.js +745 -0
  33. package/dist/assetstore/index.d.ts +5 -1
  34. package/dist/assetstore/index.js +5 -1
  35. package/dist/assetstore/localJsonl.d.ts +12 -3
  36. package/dist/assetstore/localJsonl.js +131 -39
  37. package/dist/assetstore/pendingSignals.js +3 -1
  38. package/dist/assetstore/provenance.d.ts +27 -4
  39. package/dist/assetstore/provenance.js +107 -56
  40. package/dist/assetstore/provider.d.ts +26 -0
  41. package/dist/assetstore/provider.js +50 -0
  42. package/dist/assetstore/reviewFilter.js +5 -2
  43. package/dist/assetstore/reviewLedger.d.ts +14 -2
  44. package/dist/assetstore/reviewLedger.js +78 -43
  45. package/dist/assetstore/seedGenes.d.ts +3 -0
  46. package/dist/assetstore/seedGenes.js +134 -0
  47. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  48. package/dist/benchmark/antiGeneBenchmark.js +11 -1
  49. package/dist/benchmark/antiGeneImpact.d.ts +16 -0
  50. package/dist/benchmark/antiGeneImpact.js +34 -0
  51. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  52. package/dist/benchmark/antiGeneRollout.js +13 -1
  53. package/dist/benchmark/index.d.ts +2 -1
  54. package/dist/benchmark/index.js +2 -1
  55. package/dist/benchmark/triggerShift.d.ts +62 -0
  56. package/dist/benchmark/triggerShift.js +106 -0
  57. package/dist/events/eventArchive.d.ts +65 -0
  58. package/dist/events/eventArchive.js +343 -0
  59. package/dist/events/eventStore.js +2 -12
  60. package/dist/events/ingest.d.ts +1 -1
  61. package/dist/events/ingest.js +9 -0
  62. package/dist/events/paths.d.ts +10 -10
  63. package/dist/events/paths.js +20 -20
  64. package/dist/events/public.d.ts +3 -1
  65. package/dist/events/public.js +2 -1
  66. package/dist/events/retention.d.ts +24 -1
  67. package/dist/events/retention.js +133 -37
  68. package/dist/exec/autoExec.d.ts +6 -1
  69. package/dist/exec/autoExec.js +34 -0
  70. package/dist/exec/autonomousCycle.d.ts +2 -0
  71. package/dist/exec/autonomousCycle.js +5 -0
  72. package/dist/exec/claudeBridge.d.ts +21 -3
  73. package/dist/exec/claudeBridge.js +413 -26
  74. package/dist/exec/openPrRegistry.d.ts +8 -2
  75. package/dist/exec/openPrRegistry.js +32 -22
  76. package/dist/exec/prompt.js +9 -0
  77. package/dist/exec/runnerRegistry.d.ts +90 -18
  78. package/dist/exec/runnerRegistry.js +603 -42
  79. package/dist/exec/selfPrObfuscation.d.ts +2 -1
  80. package/dist/exec/selfPrObfuscation.js +19 -6
  81. package/dist/hub/agentDirectory.d.ts +90 -0
  82. package/dist/hub/agentDirectory.js +104 -0
  83. package/dist/hub/bindings.js +23 -3
  84. package/dist/hub/capability.d.ts +14 -2
  85. package/dist/hub/fake.d.ts +4 -2
  86. package/dist/hub/fake.js +3 -2
  87. package/dist/hub/index.d.ts +1 -0
  88. package/dist/hub/index.js +1 -0
  89. package/dist/hub/sanitize.js +3 -2
  90. package/dist/index.d.ts +2 -1
  91. package/dist/index.js +2 -1
  92. package/dist/issueReporter/index.d.ts +156 -0
  93. package/dist/issueReporter/index.js +1688 -0
  94. package/dist/mailbox/catalog.js +3 -0
  95. package/dist/mailbox/ipcServer.js +2 -2
  96. package/dist/mailbox/store.d.ts +14 -0
  97. package/dist/mailbox/store.js +58 -6
  98. package/dist/material/consumer.js +9 -6
  99. package/dist/material/index.d.ts +1 -0
  100. package/dist/material/index.js +1 -0
  101. package/dist/material/materialArchive.d.ts +81 -0
  102. package/dist/material/materialArchive.js +466 -0
  103. package/dist/material/materialStore.d.ts +1 -0
  104. package/dist/material/materialStore.js +6 -13
  105. package/dist/ops/savingsCore.js +1 -2
  106. package/dist/ops/selfUpdate.d.ts +10 -1
  107. package/dist/ops/selfUpdate.js +64 -15
  108. package/dist/personality/schema.d.ts +12 -12
  109. package/dist/schema/common.d.ts +1 -1
  110. package/dist/schema/common.js +1 -1
  111. package/dist/schema/material.d.ts +5 -5
  112. package/dist/strategy/constraintAblation.d.ts +64 -0
  113. package/dist/strategy/constraintAblation.js +3074 -0
  114. package/dist/strategy/index.d.ts +2 -1
  115. package/dist/strategy/index.js +2 -1
  116. package/dist/trace/trajectoryExport.js +2 -2
  117. package/dist/util/fetchPort.d.ts +1 -0
  118. package/dist/util/fetchPort.js +11 -0
  119. package/dist/util/fileLock.d.ts +24 -5
  120. package/dist/util/fileLock.js +288 -72
  121. package/dist/util/index.d.ts +1 -0
  122. package/dist/util/index.js +1 -0
  123. package/dist/wire/geneHints.d.ts +42 -1
  124. package/dist/wire/geneHints.js +52 -1
  125. package/dist/wire/index.d.ts +14 -2
  126. package/dist/wire/index.js +1 -1
  127. package/dist/workflow/dsl.d.ts +24 -3
  128. package/dist/workflow/dsl.js +4 -0
  129. package/dist/workflow/engine.d.ts +5 -1
  130. package/dist/workflow/engine.js +3 -0
  131. package/dist/workflow/index.d.ts +3 -1
  132. package/dist/workflow/index.js +3 -1
  133. package/dist/workflow/runtime.d.ts +110 -0
  134. package/dist/workflow/runtime.js +1298 -0
  135. package/dist/workflow/stateStore.d.ts +172 -0
  136. package/dist/workflow/stateStore.js +1044 -0
  137. package/package.json +6 -1
@@ -5,8 +5,11 @@
5
5
  // nothing here spawns a real agent in tests except through spawnCapture, which the bridge injects fakes around.
6
6
  import { spawn } from 'node:child_process';
7
7
  import { join as joinPath, delimiter as pathDelimiter } from 'node:path';
8
- import { readFileSync, existsSync } from 'node:fs';
8
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readdirSync, rmSync } from 'node:fs';
9
9
  export const DEFAULT_TIMEOUT_MS = 600_000;
10
+ /** Per-stream stdout/stderr capture ceiling. A child can emit indefinitely without growing the parent heap. */
11
+ export const DEFAULT_MAX_CAPTURE_BYTES = 1_048_576;
12
+ const MIN_MAX_CAPTURE_BYTES = 256;
10
13
  /** Thrown when permission bypass is requested without bounding the agent's tools (would be an unbounded autonomous agent). */
11
14
  export class UnboundedSkipPermissionsError extends Error {
12
15
  constructor() {
@@ -21,29 +24,41 @@ export class UnsupportedCursorSkipPermissionsError extends Error {
21
24
  this.name = 'UnsupportedCursorSkipPermissionsError';
22
25
  }
23
26
  }
24
- /** Thrown when Cursor's built-in runner is requested on Windows before its launcher path is run-verified. */
27
+ /** Thrown when Gemini permission options cannot be mapped to a verified bounded CLI contract. */
28
+ export class UnsupportedGeminiPermissionOptionsError extends Error {
29
+ constructor() {
30
+ super('gemini runner does not support skipPermissions or allowedTools: --yolo is unbounded and --allowed-tools is deprecated; the verified runner uses --approval-mode auto_edit only');
31
+ this.name = 'UnsupportedGeminiPermissionOptionsError';
32
+ }
33
+ }
34
+ /** Thrown when Cursor's Windows installation cannot be reduced to a shell-free node.exe + index.js launch. */
25
35
  export class UnsupportedCursorWindowsRunnerError extends Error {
26
36
  constructor() {
27
- super('cursor runner on Windows is not yet supported/run-verified due to cursor-agent shim spawn issue; use claude/codex or an injected cursor runner until the Windows launcher is run-verified');
37
+ super('cursor runner on Windows could not resolve the installed cursor-agent bundle shell-free; reinstall/update cursor-agent or use claude/codex until node.exe + index.js are available');
28
38
  this.name = 'UnsupportedCursorWindowsRunnerError';
29
39
  }
30
40
  }
31
- export function assertCursorRunnerPlatformSupported(platform = process.platform) {
32
- if (platform === 'win32')
41
+ export function assertCursorRunnerPlatformSupported(platform = process.platform, env = process.env) {
42
+ if (platform !== 'win32')
43
+ return;
44
+ if (resolveSpawnCommand('cursor-agent', [], env, platform).cmd === 'cursor-agent') {
33
45
  throw new UnsupportedCursorWindowsRunnerError();
46
+ }
34
47
  }
35
48
  /**
36
49
  * Make a bare command name spawnable shell-free on Windows. `spawn(shell:false)` cannot execute an npm CLI
37
50
  * shim (a `.cmd`/`.bat`), and routing through a shell would expose the prompt arg to cmd.exe quoting
38
51
  * (injection). npm shims are node wrappers, so we resolve the bare name on PATH and, when it's a node shim,
39
- * run `node <entry.js>` directly (shell-free, args passed safely). A native `.exe` (claude) resolves to itself.
40
- * No-op on POSIX and for any command that is already a path or has an extension. Surfaced by codex on Windows
41
- * (codex is `codex.cmd`, while claude is `claude.exe`), #66.
52
+ * run `node <entry.js>` directly. Cursor's installer uses a PowerShell shim around a bundled
53
+ * `versions/<version>/node.exe + index.js`; that known layout is resolved directly too, without invoking
54
+ * cmd.exe or PowerShell. A native `.exe` (claude) resolves to itself. No-op on POSIX and for any command that
55
+ * is already a path or has an extension. Surfaced by codex/cursor-agent on Windows (#66).
42
56
  */
43
- export function resolveSpawnCommand(cmd, args, env = process.env) {
44
- if (process.platform !== 'win32' || /[\\/]/.test(cmd) || /\.[a-z0-9]+$/i.test(cmd))
57
+ export function resolveSpawnCommand(cmd, args, env = process.env, platform = process.platform) {
58
+ if (platform !== 'win32' || /[\\/]/.test(cmd) || /\.[a-z0-9]+$/i.test(cmd))
45
59
  return { cmd, args: [...args] };
46
- for (const dir of (env['PATH'] ?? '').split(pathDelimiter).filter(Boolean)) {
60
+ const delimiter = platform === 'win32' ? ';' : pathDelimiter;
61
+ for (const dir of (env['PATH'] ?? '').split(delimiter).filter(Boolean)) {
47
62
  for (const ext of ['.exe', '.cmd', '.bat']) {
48
63
  const full = joinPath(dir, cmd + ext);
49
64
  if (!existsSync(full))
@@ -58,6 +73,11 @@ export function resolveSpawnCommand(cmd, args, env = process.env) {
58
73
  if (existsSync(js))
59
74
  return { cmd: process.execPath, args: [js, ...args] };
60
75
  }
76
+ if (cmd.toLowerCase() === 'cursor-agent' && /cursor-agent\.ps1/i.test(shim)) {
77
+ const cursor = resolveCursorAgentBundle(dir, args);
78
+ if (cursor)
79
+ return cursor;
80
+ }
61
81
  }
62
82
  catch { /* unreadable shim — keep searching */ }
63
83
  // A .cmd/.bat whose node entry we can't extract is NOT runnable shell-free — DON'T return it (that would
@@ -66,41 +86,469 @@ export function resolveSpawnCommand(cmd, args, env = process.env) {
66
86
  }
67
87
  return { cmd, args: [...args] };
68
88
  }
89
+ function resolveCursorAgentBundle(dir, args) {
90
+ const direct = cursorBundleAt(dir, args);
91
+ if (direct)
92
+ return direct;
93
+ let versions;
94
+ try {
95
+ versions = readdirSync(joinPath(dir, 'versions'), { withFileTypes: true })
96
+ .filter((entry) => entry.isDirectory() && cursorVersionKey(entry.name) !== undefined)
97
+ .map((entry) => entry.name)
98
+ .sort((a, b) => cursorVersionKey(b).localeCompare(cursorVersionKey(a)));
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ for (const version of versions) {
104
+ const resolved = cursorBundleAt(joinPath(dir, 'versions', version), args);
105
+ if (resolved)
106
+ return resolved;
107
+ }
108
+ return undefined;
109
+ }
110
+ function cursorBundleAt(dir, args) {
111
+ const node = joinPath(dir, 'node.exe');
112
+ const entry = joinPath(dir, 'index.js');
113
+ return existsSync(node) && existsSync(entry) ? { cmd: node, args: [entry, ...args] } : undefined;
114
+ }
115
+ function cursorVersionKey(version) {
116
+ const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-(\d{1,2})-(\d{1,2})-(\d{1,2}))?-[a-f0-9]+$/i.exec(version);
117
+ if (!match)
118
+ return undefined;
119
+ const year = match[1];
120
+ const month = match[2];
121
+ const day = match[3];
122
+ const hour = match[4] ?? '0';
123
+ const minute = match[5] ?? '0';
124
+ const second = match[6] ?? '0';
125
+ return [year, month, day, hour, minute, second].map((part, index) => index === 0 ? part : part.padStart(2, '0')).join('');
126
+ }
127
+ const WINDOWS_TREE_KILL_TIMEOUT_MS = 5_000;
128
+ /** Build the shell-free taskkill invocation used for Windows process-tree termination. */
129
+ export function windowsTreeKillCommand(pid) {
130
+ if (!Number.isSafeInteger(pid) || pid <= 0)
131
+ throw new RangeError(`invalid process id: ${pid}`);
132
+ return { command: 'taskkill.exe', args: ['/PID', String(pid), '/T', '/F'] };
133
+ }
134
+ /** Run taskkill and report whether Windows accepted the process-tree termination request. */
135
+ export function killWindowsProcessTree(pid, spawnCommand = spawn, timeoutMs = WINDOWS_TREE_KILL_TIMEOUT_MS) {
136
+ const { command, args } = windowsTreeKillCommand(pid);
137
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
138
+ throw new RangeError(`invalid taskkill timeout: ${timeoutMs}`);
139
+ return new Promise((resolve) => {
140
+ let settled = false;
141
+ let killer;
142
+ const finish = (ok, terminateKiller = false) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ if (terminateKiller) {
148
+ try {
149
+ killer?.kill?.('SIGKILL');
150
+ }
151
+ catch { /* best-effort watchdog cleanup */ }
152
+ }
153
+ resolve(ok);
154
+ };
155
+ const timer = setTimeout(() => finish(false, true), timeoutMs);
156
+ timer.unref?.();
157
+ try {
158
+ killer = spawnCommand(command, args, { shell: false, windowsHide: true, stdio: 'ignore' });
159
+ killer.once('error', () => finish(false));
160
+ killer.once('close', (code) => finish(code === 0));
161
+ }
162
+ catch {
163
+ finish(false);
164
+ }
165
+ });
166
+ }
167
+ /** A redirected stdout artifact could not be finalized; the subprocess outcome remains available for classification. */
168
+ export class SpawnCaptureFinalizeError extends Error {
169
+ result;
170
+ constructor(result, cause) {
171
+ const detail = cause instanceof Error ? `: ${cause.message}` : '';
172
+ super(`redirected stdout finalization failed${detail}`, { cause });
173
+ this.name = 'SpawnCaptureFinalizeError';
174
+ this.result = result;
175
+ }
176
+ }
177
+ /**
178
+ * Retain a bounded prefix and suffix while counting every byte received. Keeping raw buffers until rendering
179
+ * avoids corrupting multi-byte UTF-8 characters when Node splits a character across stream chunks.
180
+ */
181
+ class BoundedStreamCapture {
182
+ maxBytes;
183
+ headCapacity;
184
+ tailCapacity;
185
+ head;
186
+ tail;
187
+ headLength = 0;
188
+ tailLength = 0;
189
+ tailWriteOffset = 0;
190
+ totalBytes = 0;
191
+ constructor(maxBytes) {
192
+ this.maxBytes = maxBytes;
193
+ this.headCapacity = Math.ceil(maxBytes / 2);
194
+ this.tailCapacity = maxBytes - this.headCapacity;
195
+ }
196
+ append(value) {
197
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
198
+ this.totalBytes += chunk.length;
199
+ let offset = 0;
200
+ if (this.headLength < this.headCapacity) {
201
+ const take = Math.min(this.headCapacity - this.headLength, chunk.length);
202
+ const head = this.ensureHeadCapacity(this.headLength + take);
203
+ chunk.copy(head, this.headLength, 0, take);
204
+ this.headLength += take;
205
+ offset = take;
206
+ }
207
+ if (offset < chunk.length)
208
+ this.appendTail(chunk.subarray(offset));
209
+ }
210
+ result() {
211
+ const head = this.head?.subarray(0, this.headLength) ?? Buffer.alloc(0);
212
+ const tail = this.orderedTail();
213
+ if (this.totalBytes <= this.maxBytes) {
214
+ return {
215
+ text: Buffer.concat([head, tail]).toString('utf8'),
216
+ bytes: this.totalBytes,
217
+ truncated: false,
218
+ };
219
+ }
220
+ const marker = Buffer.from(`\n...[evolver output truncated; total_bytes=${this.totalBytes}]...\n`);
221
+ const retainedBudget = this.maxBytes - marker.length;
222
+ const headBudget = Math.ceil(retainedBudget / 2);
223
+ const tailBudget = retainedBudget - headBudget;
224
+ const retainedHead = trimIncompleteUtf8Suffix(head.subarray(0, headBudget));
225
+ const tailStart = Math.max(0, tail.length - tailBudget);
226
+ const retainedTail = trimUtf8ContinuationPrefix(tail.subarray(tailStart));
227
+ return {
228
+ text: Buffer.concat([retainedHead, marker, retainedTail]).toString('utf8'),
229
+ bytes: this.totalBytes,
230
+ truncated: true,
231
+ };
232
+ }
233
+ ensureHeadCapacity(required) {
234
+ const current = this.head;
235
+ if (current && current.length >= required)
236
+ return current;
237
+ let capacity = current?.length ?? Math.min(4_096, this.headCapacity);
238
+ while (capacity < required)
239
+ capacity = Math.min(this.headCapacity, capacity * 2);
240
+ const next = Buffer.allocUnsafe(capacity);
241
+ if (current)
242
+ current.copy(next, 0, 0, this.headLength);
243
+ this.head = next;
244
+ return next;
245
+ }
246
+ appendTail(incoming) {
247
+ const tail = this.tail ??= Buffer.allocUnsafe(this.tailCapacity);
248
+ if (incoming.length >= this.tailCapacity) {
249
+ incoming.copy(tail, 0, incoming.length - this.tailCapacity);
250
+ this.tailLength = this.tailCapacity;
251
+ this.tailWriteOffset = 0;
252
+ return;
253
+ }
254
+ const first = Math.min(incoming.length, this.tailCapacity - this.tailWriteOffset);
255
+ incoming.copy(tail, this.tailWriteOffset, 0, first);
256
+ if (first < incoming.length)
257
+ incoming.copy(tail, 0, first);
258
+ this.tailWriteOffset = (this.tailWriteOffset + incoming.length) % this.tailCapacity;
259
+ this.tailLength = Math.min(this.tailCapacity, this.tailLength + incoming.length);
260
+ }
261
+ orderedTail() {
262
+ const tail = this.tail;
263
+ if (!tail || this.tailLength === 0)
264
+ return Buffer.alloc(0);
265
+ if (this.tailLength < this.tailCapacity)
266
+ return tail.subarray(0, this.tailLength);
267
+ if (this.tailWriteOffset === 0)
268
+ return tail;
269
+ return Buffer.concat([
270
+ tail.subarray(this.tailWriteOffset),
271
+ tail.subarray(0, this.tailWriteOffset),
272
+ ]);
273
+ }
274
+ }
275
+ function trimIncompleteUtf8Suffix(value) {
276
+ if (value.length === 0)
277
+ return value;
278
+ let lead = value.length - 1;
279
+ while (lead >= 0 && (value[lead] & 0xc0) === 0x80)
280
+ lead -= 1;
281
+ if (lead < 0)
282
+ return Buffer.alloc(0);
283
+ const first = value[lead];
284
+ const expected = first < 0x80 ? 1 : first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc0 ? 2 : 1;
285
+ return value.length - lead < expected ? value.subarray(0, lead) : value;
286
+ }
287
+ function trimUtf8ContinuationPrefix(value) {
288
+ let offset = 0;
289
+ while (offset < value.length && (value[offset] & 0xc0) === 0x80)
290
+ offset += 1;
291
+ return value.subarray(offset);
292
+ }
69
293
  /**
70
294
  * Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
71
295
  * On timeout the WHOLE process group is killed, not just the direct child (finding #39.5): an agent spawns
72
296
  * tool subprocesses (grandchildren) that would otherwise orphan and leak. On POSIX we spawn detached (the
73
- * child becomes its own group leader) and SIGKILL the group via the negative pid; Windows falls back to a
74
- * direct kill (different process-group semantics).
297
+ * child becomes its own group leader) and SIGKILL the group via the negative pid. Windows runs
298
+ * `taskkill.exe /PID <pid> /T /F` without a shell and waits for that command before resolving.
75
299
  */
76
300
  export function spawnCapture(cmd, args, opts) {
301
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_CAPTURE_BYTES;
302
+ if (!Number.isSafeInteger(maxOutputBytes)
303
+ || maxOutputBytes < MIN_MAX_CAPTURE_BYTES
304
+ || maxOutputBytes > DEFAULT_MAX_CAPTURE_BYTES) {
305
+ throw new RangeError(`maxOutputBytes must be an integer between ${MIN_MAX_CAPTURE_BYTES} and ${DEFAULT_MAX_CAPTURE_BYTES}`);
306
+ }
77
307
  return new Promise((resolve, reject) => {
78
- const detached = process.platform !== 'win32';
79
- const r = resolveSpawnCommand(cmd, args, opts.env);
80
- const child = spawn(r.cmd, r.args, { cwd: opts.cwd, shell: false, detached, ...(opts.env ? { env: opts.env } : {}) });
81
- let stdout = '';
82
- let stderr = '';
308
+ if (opts.signal?.aborted) {
309
+ resolve({
310
+ code: null,
311
+ stdout: '',
312
+ stderr: '',
313
+ termination: 'cancelled',
314
+ stdoutBytes: 0,
315
+ stderrBytes: 0,
316
+ stdoutTruncated: false,
317
+ stderrTruncated: false,
318
+ });
319
+ return;
320
+ }
321
+ const platform = opts.processPlatform ?? process.platform;
322
+ const detached = platform !== 'win32';
323
+ const r = resolveSpawnCommand(cmd, args, opts.env, opts.resolvePlatform ?? process.platform);
324
+ let stdoutFd;
325
+ let ownsStdoutFile = false;
326
+ const cleanupOwnedStdoutFile = () => {
327
+ if (!ownsStdoutFile || !opts.stdoutFile)
328
+ return;
329
+ try {
330
+ rmSync(opts.stdoutFile, { force: true });
331
+ ownsStdoutFile = false;
332
+ }
333
+ catch { /* best-effort; a caller ownership hook can retry */ }
334
+ };
335
+ try {
336
+ if (opts.stdoutFile) {
337
+ stdoutFd = openSync(opts.stdoutFile, 'wx', 0o600);
338
+ ownsStdoutFile = true;
339
+ opts.onStdoutFileOpened?.(opts.stdoutFile);
340
+ }
341
+ }
342
+ catch (error) {
343
+ if (stdoutFd !== undefined) {
344
+ try {
345
+ closeSync(stdoutFd);
346
+ }
347
+ catch { /* best-effort cleanup before the child exists */ }
348
+ stdoutFd = undefined;
349
+ }
350
+ cleanupOwnedStdoutFile();
351
+ reject(error);
352
+ return;
353
+ }
354
+ let child;
355
+ try {
356
+ child = spawn(r.cmd, r.args, {
357
+ cwd: opts.cwd,
358
+ shell: false,
359
+ detached,
360
+ ...(opts.env ? { env: opts.env } : {}),
361
+ ...(stdoutFd !== undefined ? { stdio: ['pipe', stdoutFd, 'pipe'] } : {}),
362
+ });
363
+ }
364
+ catch (error) {
365
+ if (stdoutFd !== undefined) {
366
+ try {
367
+ closeSync(stdoutFd);
368
+ }
369
+ catch { /* best-effort cleanup before rejection */ }
370
+ stdoutFd = undefined;
371
+ }
372
+ cleanupOwnedStdoutFile();
373
+ reject(error);
374
+ return;
375
+ }
376
+ const stdoutCapture = new BoundedStreamCapture(maxOutputBytes);
377
+ const stderrCapture = new BoundedStreamCapture(maxOutputBytes);
378
+ let termination = 'exit';
379
+ let killPromise;
380
+ let settled = false;
381
+ let redirectedStdoutBytes;
83
382
  const killTree = () => {
84
- if (detached && typeof child.pid === 'number') {
383
+ if (killPromise)
384
+ return killPromise;
385
+ killPromise = (async () => {
386
+ if (platform === 'win32' && typeof child.pid === 'number') {
387
+ let killed = false;
388
+ try {
389
+ killed = await (opts.windowsProcessTreeKiller ?? killWindowsProcessTree)(child.pid);
390
+ }
391
+ catch {
392
+ // Treat an injected/custom killer rejection like taskkill failure and fall back to the direct child.
393
+ }
394
+ if (killed)
395
+ return;
396
+ }
397
+ if (detached && typeof child.pid === 'number') {
398
+ try {
399
+ process.kill(-child.pid, 'SIGKILL');
400
+ return;
401
+ }
402
+ catch { /* group gone; fall back */ }
403
+ }
404
+ child.kill('SIGKILL');
405
+ })();
406
+ return killPromise;
407
+ };
408
+ const cancel = () => {
409
+ if (termination !== 'exit')
410
+ return;
411
+ termination = 'cancelled';
412
+ void killTree();
413
+ };
414
+ const timeout = () => {
415
+ if (termination !== 'exit')
416
+ return;
417
+ termination = 'timeout';
418
+ void killTree();
419
+ };
420
+ const ignoreProcessSignal = () => { };
421
+ const cleanup = () => {
422
+ clearTimeout(timer);
423
+ opts.signal?.removeEventListener('abort', cancel);
424
+ if (opts.processSignalMode === 'ignore') {
425
+ process.removeListener('SIGINT', ignoreProcessSignal);
426
+ process.removeListener('SIGTERM', ignoreProcessSignal);
427
+ }
428
+ else {
429
+ process.removeListener('SIGINT', cancel);
430
+ process.removeListener('SIGTERM', cancel);
431
+ }
432
+ };
433
+ const settle = async (finish) => {
434
+ if (settled)
435
+ return;
436
+ settled = true;
437
+ if (killPromise)
438
+ await killPromise;
439
+ cleanup();
440
+ let stdoutFileError;
441
+ if (stdoutFd !== undefined) {
442
+ const fd = stdoutFd;
443
+ stdoutFd = undefined;
85
444
  try {
86
- process.kill(-child.pid, 'SIGKILL');
87
- return;
445
+ redirectedStdoutBytes = opts.stdoutFileOps?.size(fd) ?? fstatSync(fd).size;
446
+ }
447
+ catch (error) {
448
+ stdoutFileError = error;
449
+ }
450
+ try {
451
+ (opts.stdoutFileOps?.close ?? closeSync)(fd);
452
+ }
453
+ catch (error) {
454
+ stdoutFileError ??= error;
455
+ try {
456
+ closeSync(fd);
457
+ }
458
+ catch { /* retry a failed/injected close before removing our artifact */ }
88
459
  }
89
- catch { /* group gone; fall back */ }
90
460
  }
91
- child.kill('SIGKILL');
461
+ finish(stdoutFileError);
92
462
  };
93
- const timer = setTimeout(killTree, opts.timeoutMs);
94
- child.stdout?.on('data', (d) => { stdout += d.toString(); });
95
- child.stderr?.on('data', (d) => { stderr += d.toString(); });
96
- child.on('error', (e) => { clearTimeout(timer); reject(e); });
97
- child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
463
+ const timer = setTimeout(timeout, opts.timeoutMs);
464
+ opts.signal?.addEventListener('abort', cancel, { once: true });
465
+ // A detached POSIX child would otherwise survive Ctrl-C/SIGTERM. Cancel first so the bridge can clean its
466
+ // worktree and return a failure instead of leaking an agent or tool subprocess.
467
+ if (opts.processSignalMode === 'ignore') {
468
+ process.on('SIGINT', ignoreProcessSignal);
469
+ process.on('SIGTERM', ignoreProcessSignal);
470
+ }
471
+ else {
472
+ process.once('SIGINT', cancel);
473
+ process.once('SIGTERM', cancel);
474
+ }
475
+ child.stdout?.on('data', (d) => { stdoutCapture.append(d); });
476
+ child.stderr?.on('data', (d) => { stderrCapture.append(d); });
477
+ child.on('error', (e) => {
478
+ void settle(() => {
479
+ cleanupOwnedStdoutFile();
480
+ reject(e);
481
+ });
482
+ });
483
+ child.on('close', (code) => {
484
+ void settle((stdoutFileError) => {
485
+ const stdout = stdoutCapture.result();
486
+ const stderr = stderrCapture.result();
487
+ const result = {
488
+ code,
489
+ stdout: stdout.text,
490
+ stderr: stderr.text,
491
+ termination,
492
+ stdoutBytes: redirectedStdoutBytes ?? stdout.bytes,
493
+ stderrBytes: stderr.bytes,
494
+ stdoutTruncated: stdout.truncated,
495
+ stderrTruncated: stderr.truncated,
496
+ ...(opts.stdoutFile ? { stdoutRedirected: true } : {}),
497
+ };
498
+ if (stdoutFileError !== undefined) {
499
+ cleanupOwnedStdoutFile();
500
+ reject(new SpawnCaptureFinalizeError(result, stdoutFileError));
501
+ return;
502
+ }
503
+ resolve(result);
504
+ });
505
+ });
98
506
  if (opts.input !== undefined) {
99
507
  child.stdin?.write(opts.input);
100
508
  child.stdin?.end();
101
509
  }
102
510
  });
103
511
  }
512
+ /** Map the shared process result into the failure taxonomy used by plain-text runners. */
513
+ export function classifyBasicRunnerResult(runner, result, timeoutMs) {
514
+ if (result.termination === 'timeout') {
515
+ return {
516
+ ok: false,
517
+ output: result.stdout,
518
+ error: `${runner} timed out after ${timeoutMs}ms`,
519
+ failureKind: 'timeout',
520
+ exitCode: result.code,
521
+ };
522
+ }
523
+ if (result.termination === 'cancelled') {
524
+ return {
525
+ ok: false,
526
+ output: result.stdout,
527
+ error: `${runner} execution cancelled`,
528
+ failureKind: 'cancelled',
529
+ exitCode: result.code,
530
+ };
531
+ }
532
+ if (result.code !== 0) {
533
+ return {
534
+ ok: false,
535
+ output: result.stdout,
536
+ error: result.stderr || `${runner} exited with code ${String(result.code)}`,
537
+ failureKind: 'non_zero_exit',
538
+ exitCode: result.code,
539
+ };
540
+ }
541
+ return { ok: true, output: result.stdout };
542
+ }
543
+ function spawnFailureResult(error) {
544
+ return {
545
+ ok: false,
546
+ output: '',
547
+ error: error instanceof Error ? error.message : String(error),
548
+ failureKind: 'spawn_failed',
549
+ exitCode: null,
550
+ };
551
+ }
104
552
  /**
105
553
  * Build the `claude -p` argv for the given options (pure — testable without spawning).
106
554
  * Safety invariant: skipPermissions (bypassing prompts) is only allowed together with a non-empty
@@ -127,12 +575,13 @@ export function claudeRunnerArgs(opts = {}) {
127
575
  export function makeClaudeHeadlessRunner(opts = {}) {
128
576
  const args = claudeRunnerArgs(opts);
129
577
  return async (prompt, ctx) => {
578
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
130
579
  try {
131
- const r = await spawnCapture('claude', args, { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, input: prompt, ...(ctx.env ? { env: ctx.env } : {}) });
132
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
580
+ const result = await spawnCapture('claude', args, { cwd: ctx.cwd, timeoutMs, input: prompt, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
581
+ return classifyBasicRunnerResult('claude', result, timeoutMs);
133
582
  }
134
583
  catch (e) {
135
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
584
+ return spawnFailureResult(e);
136
585
  }
137
586
  };
138
587
  }
@@ -165,12 +614,109 @@ export function codexRunnerArgs(opts = {}) {
165
614
  export function makeCodexHeadlessRunner(opts = {}) {
166
615
  const args = codexRunnerArgs(opts);
167
616
  return async (prompt, ctx) => {
617
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
168
618
  try {
169
- const r = await spawnCapture('codex', [...args, '--cd', ctx.cwd, prompt], { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(ctx.env ? { env: ctx.env } : {}) });
170
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
619
+ const result = await spawnCapture('codex', [...args, '--cd', ctx.cwd, prompt], { cwd: ctx.cwd, timeoutMs, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
620
+ return classifyBasicRunnerResult('codex', result, timeoutMs);
171
621
  }
172
622
  catch (e) {
173
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
623
+ return spawnFailureResult(e);
624
+ }
625
+ };
626
+ }
627
+ const GEMINI_PERMISSION_DENIAL_RE = /agent execution blocked|permission denied|approval required|not approved|denied by (?:policy|user|admin)|tool (?:call )?(?:was )?denied/i;
628
+ const GEMINI_TERMINATION_WARNINGS = [
629
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Agent execution stopped\b/i, error: 'gemini agent execution stopped' },
630
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Loop detected\b/i, error: 'gemini loop detected' },
631
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Maximum session turns exceeded\b/i, error: 'gemini maximum session turns exceeded' },
632
+ ];
633
+ function geminiMessage(value) {
634
+ if (typeof value === 'string')
635
+ return value;
636
+ if (value && typeof value === 'object') {
637
+ const record = value;
638
+ const type = typeof record['type'] === 'string' ? record['type'] : '';
639
+ const message = typeof record['message'] === 'string' ? record['message'] : '';
640
+ return [type, message].filter(Boolean).join(': ');
641
+ }
642
+ return value === undefined ? '' : String(value);
643
+ }
644
+ function geminiWarnings(value) {
645
+ return Array.isArray(value) ? value.map(geminiMessage).filter(Boolean) : [];
646
+ }
647
+ /** Interpret one bounded Gemini subprocess result. Structured output and diagnostics require complete capture. */
648
+ export function classifyGeminiRunnerResult(result, timeoutMs) {
649
+ if (result.termination === 'timeout') {
650
+ return { ok: false, output: result.stdout, error: `gemini timed out after ${timeoutMs}ms`, failureKind: 'timeout', exitCode: result.code };
651
+ }
652
+ if (result.termination === 'cancelled') {
653
+ return { ok: false, output: result.stdout, error: 'gemini execution cancelled', failureKind: 'cancelled', exitCode: result.code };
654
+ }
655
+ if (result.stdoutTruncated || result.stderrTruncated) {
656
+ return {
657
+ ok: false,
658
+ output: result.stdout,
659
+ error: 'gemini output exceeded the capture limit',
660
+ failureKind: 'invalid_output',
661
+ exitCode: result.code,
662
+ };
663
+ }
664
+ let envelope;
665
+ try {
666
+ const parsed = JSON.parse(result.stdout);
667
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
668
+ throw new Error('JSON object required');
669
+ envelope = parsed;
670
+ }
671
+ catch {
672
+ const error = result.stderr || (result.code === 0 ? 'gemini returned invalid JSON output' : `gemini exited with code ${String(result.code)}`);
673
+ return { ok: false, output: result.stdout, error, failureKind: result.code === 0 ? 'invalid_output' : 'non_zero_exit', exitCode: result.code };
674
+ }
675
+ const structuredError = geminiMessage(envelope.error);
676
+ const warnings = geminiWarnings(envelope.warnings);
677
+ const terminationError = result.code === 0
678
+ ? GEMINI_TERMINATION_WARNINGS.find(({ pattern }) => warnings.some((warning) => pattern.test(warning)))?.error
679
+ : undefined;
680
+ if (terminationError) {
681
+ return { ok: false, output: geminiMessage(envelope.response), error: terminationError, failureKind: 'runtime_error', exitCode: result.code };
682
+ }
683
+ const denial = [structuredError, ...warnings, result.stderr].find((message) => GEMINI_PERMISSION_DENIAL_RE.test(message));
684
+ if (denial) {
685
+ return { ok: false, output: geminiMessage(envelope.response), error: denial, failureKind: 'permission_denied', exitCode: result.code };
686
+ }
687
+ if (result.code !== 0) {
688
+ return { ok: false, output: geminiMessage(envelope.response), error: structuredError || result.stderr || `gemini exited with code ${String(result.code)}`, failureKind: 'non_zero_exit', exitCode: result.code };
689
+ }
690
+ if (structuredError) {
691
+ return { ok: false, output: geminiMessage(envelope.response), error: structuredError, failureKind: 'runtime_error', exitCode: result.code };
692
+ }
693
+ return { ok: true, output: geminiMessage(envelope.response), exitCode: result.code };
694
+ }
695
+ /** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
696
+ export function geminiRunnerArgs(opts = {}) {
697
+ if (opts.skipPermissions || (opts.allowedTools?.length ?? 0) > 0)
698
+ throw new UnsupportedGeminiPermissionOptionsError();
699
+ const args = ['--output-format', 'json', '--approval-mode', 'auto_edit', '--skip-trust'];
700
+ if (opts.model)
701
+ args.push('--model', opts.model);
702
+ return args;
703
+ }
704
+ /** Headless Gemini runner with structured failure classification; stdout text alone never proves execution success. */
705
+ export function makeGeminiHeadlessRunner(opts = {}) {
706
+ const args = geminiRunnerArgs(opts);
707
+ return async (prompt, ctx) => {
708
+ try {
709
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
710
+ const result = await spawnCapture('gemini', [...args, '--prompt', prompt], {
711
+ cwd: ctx.cwd,
712
+ timeoutMs,
713
+ ...(ctx.env ? { env: ctx.env } : {}),
714
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
715
+ });
716
+ return classifyGeminiRunnerResult(result, timeoutMs);
717
+ }
718
+ catch (error) {
719
+ return { ok: false, output: '', error: error instanceof Error ? error.message : String(error), failureKind: 'spawn_failed', exitCode: null };
174
720
  }
175
721
  };
176
722
  }
@@ -185,10 +731,10 @@ export function makeCodexHeadlessRunner(opts = {}) {
185
731
  // - `--model <model>` exists (e.g. gpt-5, sonnet-4, sonnet-4-thinking); `--list-models` enumerates.
186
732
  // - auth: `CURSOR_API_KEY` or `--api-key` (the spec's envAllow prefix is CURSOR_).
187
733
  // - cursor has its OWN `--sandbox enabled|disabled` and `-w/--worktree`; we still wrap with our git worktree.
188
- // STILL NOT run-verified end to end (needs an authed cursor-agent; claude/codex were each run-verified). Two known
189
- // gaps surfaced while testing: (1) on Windows `cursor-agent.cmd` is a powershell shim, NOT an npm node-shim, so
190
- // resolveSpawnCommand cannot rewrite it; the built-in runner fail-fasts there instead of surfacing ENOENT;
191
- // (2) the installed launcher's version-discovery regex rejects the real version dir name (Cursor's own bug).
734
+ // STILL NOT run-verified end to end (needs an authed cursor-agent; claude/codex were each run-verified). The
735
+ // Windows launcher is a PowerShell shim and its own version regex rejects the current timestamped version-dir
736
+ // shape. resolveSpawnCommand therefore bypasses both scripts and runs the newest verified node.exe + index.js
737
+ // bundle directly, shell-free. If that known bundle layout cannot be found, the runner still fail-fasts.
192
738
  //
193
739
  // SAFETY: `-p --force --trust` auto-approves shell+write with no verified per-run allowlist/sandbox mapping.
194
740
  // Until Cursor can really map agentOptions into per-run permissions, skipPermissions is refused outright. The
@@ -215,13 +761,20 @@ export function cursorRunnerArgs(opts = {}) {
215
761
  export function makeCursorHeadlessRunner(opts = {}, platform = process.platform) {
216
762
  const args = cursorRunnerArgs(opts);
217
763
  return async (prompt, ctx) => {
218
- assertCursorRunnerPlatformSupported(platform);
764
+ assertCursorRunnerPlatformSupported(platform, ctx.env ?? process.env);
765
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
219
766
  try {
220
- const r = await spawnCapture('cursor-agent', [...args, prompt], { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(ctx.env ? { env: ctx.env } : {}) });
221
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
767
+ const result = await spawnCapture('cursor-agent', [...args, prompt], {
768
+ cwd: ctx.cwd,
769
+ timeoutMs,
770
+ resolvePlatform: platform,
771
+ ...(ctx.env ? { env: ctx.env } : {}),
772
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
773
+ });
774
+ return classifyBasicRunnerResult('cursor', result, timeoutMs);
222
775
  }
223
776
  catch (e) {
224
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
777
+ return spawnFailureResult(e);
225
778
  }
226
779
  };
227
780
  }
@@ -230,6 +783,14 @@ const RUNNER_SPECS = {
230
783
  codex: { name: 'codex', makeRunner: makeCodexHeadlessRunner, envAllow: { prefixes: ['OPENAI_', 'CODEX_'] } },
231
784
  // cursor keeps only its OWN auth env (CURSOR_); like every runner it never inherits another's vendor key.
232
785
  cursor: { name: 'cursor', makeRunner: makeCursorHeadlessRunner, envAllow: { prefixes: ['CURSOR_'] } },
786
+ gemini: {
787
+ name: 'gemini',
788
+ makeRunner: makeGeminiHeadlessRunner,
789
+ envAllow: {
790
+ prefixes: ['GEMINI_'],
791
+ keys: ['GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_GENAI_USE_VERTEXAI'],
792
+ },
793
+ },
233
794
  };
234
795
  /** Resolve a runner spec by name (default 'claude' — byte-identical to the pre-registry behavior). */
235
796
  export function getRunnerSpec(name = 'claude') {