@evomap/evolver-core 2.0.0-beta.2 → 2.0.0-beta.22

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 (240) hide show
  1. package/assets/gep/genes.jsonl +5 -5
  2. package/dist/algo/bans.d.ts +10 -1
  3. package/dist/algo/bans.js +56 -6
  4. package/dist/algo/candidateAssembly.d.ts +25 -2
  5. package/dist/algo/candidateAssembly.js +171 -26
  6. package/dist/algo/capabilityCandidates.js +10 -0
  7. package/dist/algo/conversationSniffer.js +25 -8
  8. package/dist/algo/cycleEngine.d.ts +63 -5
  9. package/dist/algo/cycleEngine.js +320 -42
  10. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  11. package/dist/algo/evolutionEvent.d.ts +26 -0
  12. package/dist/algo/evolutionEvent.js +32 -0
  13. package/dist/algo/exploration.d.ts +7 -0
  14. package/dist/algo/exploration.js +16 -3
  15. package/dist/algo/geneHealth.d.ts +36 -3
  16. package/dist/algo/geneHealth.js +47 -4
  17. package/dist/algo/geneIntake.d.ts +46 -1
  18. package/dist/algo/geneIntake.js +121 -10
  19. package/dist/algo/geneSelection.d.ts +126 -6
  20. package/dist/algo/geneSelection.js +479 -36
  21. package/dist/algo/index.d.ts +6 -1
  22. package/dist/algo/index.js +6 -1
  23. package/dist/algo/kautoProjection.d.ts +41 -0
  24. package/dist/algo/kautoProjection.js +95 -0
  25. package/dist/algo/kautoValidator.d.ts +68 -0
  26. package/dist/algo/kautoValidator.js +256 -0
  27. package/dist/algo/memoryGraph.d.ts +62 -0
  28. package/dist/algo/memoryGraph.js +86 -0
  29. package/dist/algo/orchestrator.d.ts +17 -1
  30. package/dist/algo/orchestrator.js +30 -4
  31. package/dist/algo/publishEligibility.d.ts +34 -0
  32. package/dist/algo/publishEligibility.js +52 -0
  33. package/dist/algo/solidify.d.ts +11 -2
  34. package/dist/algo/solidify.js +37 -7
  35. package/dist/algo/ucb1.d.ts +53 -0
  36. package/dist/algo/ucb1.js +156 -0
  37. package/dist/assetrepair/hubRejection.d.ts +12 -0
  38. package/dist/assetrepair/hubRejection.js +109 -0
  39. package/dist/assetrepair/index.d.ts +2 -0
  40. package/dist/assetrepair/index.js +2 -0
  41. package/dist/assetrepair/repair.d.ts +33 -0
  42. package/dist/assetrepair/repair.js +155 -0
  43. package/dist/assetstore/assetSidecarRecords.d.ts +28 -0
  44. package/dist/assetstore/assetSidecarRecords.js +384 -0
  45. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  46. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  47. package/dist/assetstore/assetStoreHealth.d.ts +81 -0
  48. package/dist/assetstore/assetStoreHealth.js +319 -0
  49. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  50. package/dist/assetstore/assetStoreLayout.js +6 -0
  51. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  52. package/dist/assetstore/assetStoreStorage.js +336 -0
  53. package/dist/assetstore/assetSyncLedger.d.ts +91 -1
  54. package/dist/assetstore/assetSyncLedger.js +718 -59
  55. package/dist/assetstore/foreignJsonlSource.d.ts +48 -0
  56. package/dist/assetstore/foreignJsonlSource.js +150 -0
  57. package/dist/assetstore/index.d.ts +5 -0
  58. package/dist/assetstore/index.js +5 -0
  59. package/dist/assetstore/learningHistory.js +3 -3
  60. package/dist/assetstore/localAssetStoreSnapshot.d.ts +51 -0
  61. package/dist/assetstore/localAssetStoreSnapshot.js +329 -0
  62. package/dist/assetstore/localJsonl.d.ts +12 -1
  63. package/dist/assetstore/localJsonl.js +251 -42
  64. package/dist/assetstore/provenance.d.ts +93 -4
  65. package/dist/assetstore/provenance.js +368 -84
  66. package/dist/assetstore/provider.d.ts +63 -0
  67. package/dist/assetstore/provider.js +97 -6
  68. package/dist/assetstore/reviewFilter.d.ts +19 -1
  69. package/dist/assetstore/reviewFilter.js +39 -1
  70. package/dist/assetstore/reviewLedger.d.ts +8 -2
  71. package/dist/assetstore/reviewLedger.js +71 -45
  72. package/dist/assetstore/unionReadStore.d.ts +25 -0
  73. package/dist/assetstore/unionReadStore.js +119 -0
  74. package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
  75. package/dist/benchmark/antiGeneBenchmark.js +4 -3
  76. package/dist/benchmark/antiGeneRollout.d.ts +2 -0
  77. package/dist/benchmark/antiGeneRollout.js +4 -3
  78. package/dist/benchmark/index.d.ts +3 -1
  79. package/dist/benchmark/index.js +3 -1
  80. package/dist/benchmark/selectionFlatAbstention.d.ts +152 -0
  81. package/dist/benchmark/selectionFlatAbstention.js +481 -0
  82. package/dist/benchmark/triggerShift.d.ts +62 -0
  83. package/dist/benchmark/triggerShift.js +106 -0
  84. package/dist/bootstrap/envFingerprint.d.ts +9 -0
  85. package/dist/bootstrap/envFingerprint.js +5 -0
  86. package/dist/bootstrap/index.d.ts +3 -1
  87. package/dist/bootstrap/index.js +3 -1
  88. package/dist/bootstrap/lifecycleBootstrap.d.ts +111 -0
  89. package/dist/bootstrap/lifecycleBootstrap.js +433 -0
  90. package/dist/bootstrap/v1EnvCompat.d.ts +113 -0
  91. package/dist/bootstrap/v1EnvCompat.js +303 -0
  92. package/dist/events/eventArchive.d.ts +2 -0
  93. package/dist/events/eventArchive.js +13 -3
  94. package/dist/events/eventSchema.d.ts +7 -7
  95. package/dist/events/eventStore.d.ts +2 -0
  96. package/dist/events/eventStore.js +5 -1
  97. package/dist/events/ingest.d.ts +2 -1
  98. package/dist/events/ingest.js +15 -0
  99. package/dist/events/paths.d.ts +12 -10
  100. package/dist/events/paths.js +24 -20
  101. package/dist/events/public.d.ts +2 -2
  102. package/dist/events/public.js +2 -2
  103. package/dist/events/reports.d.ts +2 -0
  104. package/dist/events/reports.js +4 -0
  105. package/dist/exec/autoExec.d.ts +68 -4
  106. package/dist/exec/autoExec.js +392 -33
  107. package/dist/exec/autonomousCycle.d.ts +31 -4
  108. package/dist/exec/autonomousCycle.js +71 -13
  109. package/dist/exec/claudeBridge.d.ts +80 -15
  110. package/dist/exec/claudeBridge.js +845 -76
  111. package/dist/exec/executionBinding.d.ts +414 -0
  112. package/dist/exec/executionBinding.js +588 -0
  113. package/dist/exec/index.d.ts +1 -0
  114. package/dist/exec/index.js +1 -0
  115. package/dist/exec/openPrRegistry.d.ts +8 -2
  116. package/dist/exec/openPrRegistry.js +32 -22
  117. package/dist/exec/prompt.js +14 -1
  118. package/dist/exec/proofOfWork.d.ts +1 -1
  119. package/dist/exec/proofOfWork.js +2 -2
  120. package/dist/exec/runnerRegistry.d.ts +153 -36
  121. package/dist/exec/runnerRegistry.js +848 -65
  122. package/dist/exec/selfPr.js +1 -7
  123. package/dist/feedback/envelope.d.ts +61 -0
  124. package/dist/feedback/envelope.js +168 -0
  125. package/dist/feedback/index.d.ts +1 -0
  126. package/dist/feedback/index.js +1 -0
  127. package/dist/hooks/hooks.js +1 -0
  128. package/dist/hub/assetCallLog.d.ts +35 -1
  129. package/dist/hub/assetCallLog.js +124 -1
  130. package/dist/hub/bindings.d.ts +8 -1
  131. package/dist/hub/bindings.js +29 -8
  132. package/dist/hub/capability.d.ts +130 -4
  133. package/dist/hub/conversationDistiller.d.ts +19 -0
  134. package/dist/hub/conversationDistiller.js +115 -37
  135. package/dist/hub/fake.d.ts +3 -2
  136. package/dist/hub/fake.js +2 -1
  137. package/dist/hub/index.d.ts +1 -0
  138. package/dist/hub/index.js +1 -0
  139. package/dist/hub/questionGenerator.d.ts +5 -1
  140. package/dist/hub/questionGenerator.js +8 -6
  141. package/dist/hub/recipeCompose.d.ts +27 -0
  142. package/dist/hub/recipeCompose.js +90 -0
  143. package/dist/hub/sanitize.js +122 -7
  144. package/dist/index.d.ts +6 -1
  145. package/dist/index.js +7 -1
  146. package/dist/issueReporter/index.d.ts +156 -0
  147. package/dist/issueReporter/index.js +1688 -0
  148. package/dist/mailbox/dispatch.d.ts +1 -1
  149. package/dist/mailbox/dispatch.js +22 -6
  150. package/dist/mailbox/envelope.d.ts +7 -1
  151. package/dist/mailbox/envelope.js +9 -2
  152. package/dist/mailbox/ipcServer.d.ts +12 -2
  153. package/dist/mailbox/ipcServer.js +183 -13
  154. package/dist/mailbox/store.d.ts +89 -3
  155. package/dist/mailbox/store.js +895 -41
  156. package/dist/modelCompatibility.d.ts +164 -0
  157. package/dist/modelCompatibility.js +309 -0
  158. package/dist/observers/valueDigestObserver.d.ts +9 -0
  159. package/dist/observers/valueDigestObserver.js +35 -2
  160. package/dist/ops/cleanup.js +1 -1
  161. package/dist/ops/evolutionGraphProjection.d.ts +20 -0
  162. package/dist/ops/evolutionGraphProjection.js +315 -0
  163. package/dist/ops/index.d.ts +2 -1
  164. package/dist/ops/index.js +2 -1
  165. package/dist/ops/savingsCore.js +1 -2
  166. package/dist/ops/selfUpdate.d.ts +18 -1
  167. package/dist/ops/selfUpdate.js +88 -23
  168. package/dist/ops/valueOutreach.d.ts +3 -1
  169. package/dist/ops/valueOutreach.js +5 -1
  170. package/dist/personality/schema.d.ts +24 -24
  171. package/dist/schema/evolutionGraph.d.ts +784 -0
  172. package/dist/schema/evolutionGraph.js +187 -0
  173. package/dist/schema/index.d.ts +1 -0
  174. package/dist/schema/index.js +1 -0
  175. package/dist/schema/proofOfWork.d.ts +125 -6
  176. package/dist/schema/proofOfWork.js +102 -4
  177. package/dist/schema/signal.d.ts +3 -3
  178. package/dist/schema/signal.js +1 -1
  179. package/dist/shadow/shadowHub.js +1 -0
  180. package/dist/signals/curriculum.d.ts +55 -0
  181. package/dist/signals/curriculum.js +202 -0
  182. package/dist/signals/cycleHistoryFromEvents.js +17 -8
  183. package/dist/signals/expand.d.ts +15 -1
  184. package/dist/signals/expand.js +169 -1
  185. package/dist/signals/extractor.d.ts +2 -2
  186. package/dist/signals/extractor.js +31 -6
  187. package/dist/signals/index.d.ts +4 -1
  188. package/dist/signals/index.js +4 -1
  189. package/dist/signals/metaSignals.d.ts +4 -0
  190. package/dist/signals/metaSignals.js +42 -0
  191. package/dist/signals/scopeVocabulary.d.ts +75 -0
  192. package/dist/signals/scopeVocabulary.js +91 -0
  193. package/dist/signals/signalGate.js +1 -1
  194. package/dist/signals/taskDomain.d.ts +22 -0
  195. package/dist/signals/taskDomain.js +43 -0
  196. package/dist/strategy/constraintAblation.d.ts +64 -0
  197. package/dist/strategy/constraintAblation.js +2820 -0
  198. package/dist/strategy/constraintAblationPredicates.d.ts +31 -0
  199. package/dist/strategy/constraintAblationPredicates.js +339 -0
  200. package/dist/strategy/index.d.ts +2 -1
  201. package/dist/strategy/index.js +2 -1
  202. package/dist/trace/index.d.ts +3 -1
  203. package/dist/trace/index.js +3 -1
  204. package/dist/trace/learningTrace.d.ts +216 -0
  205. package/dist/trace/learningTrace.js +298 -0
  206. package/dist/trace/proxyTurns.d.ts +31 -0
  207. package/dist/trace/proxyTurns.js +137 -0
  208. package/dist/trace/trajectory.d.ts +8 -0
  209. package/dist/trace/trajectory.js +14 -2
  210. package/dist/util/fetchPort.d.ts +1 -0
  211. package/dist/util/fetchPort.js +11 -0
  212. package/dist/util/fileLock.d.ts +113 -7
  213. package/dist/util/fileLock.js +1035 -80
  214. package/dist/util/index.d.ts +3 -1
  215. package/dist/util/index.js +2 -1
  216. package/dist/verify/index.d.ts +2 -1
  217. package/dist/verify/index.js +1 -1
  218. package/dist/verify/sandboxRunner.d.ts +30 -0
  219. package/dist/verify/sandboxRunner.js +370 -27
  220. package/dist/verify/sandboxedValidation.d.ts +16 -2
  221. package/dist/verify/sandboxedValidation.js +200 -22
  222. package/dist/verify/validation.d.ts +35 -4
  223. package/dist/verify/validation.js +164 -16
  224. package/dist/wire/geneHints.d.ts +88 -16
  225. package/dist/wire/geneHints.js +124 -15
  226. package/dist/wire/index.d.ts +8 -3
  227. package/dist/wire/index.js +2 -2
  228. package/dist/wire/schemaGate.d.ts +21 -0
  229. package/dist/wire/schemaGate.js +119 -10
  230. package/dist/workflow/dsl.d.ts +24 -3
  231. package/dist/workflow/dsl.js +4 -0
  232. package/dist/workflow/engine.d.ts +5 -1
  233. package/dist/workflow/engine.js +3 -0
  234. package/dist/workflow/index.d.ts +3 -1
  235. package/dist/workflow/index.js +3 -1
  236. package/dist/workflow/runtime.d.ts +110 -0
  237. package/dist/workflow/runtime.js +1298 -0
  238. package/dist/workflow/stateStore.d.ts +172 -0
  239. package/dist/workflow/stateStore.js +1044 -0
  240. package/package.json +12 -4
@@ -5,8 +5,35 @@
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, readdirSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
9
10
  export const DEFAULT_TIMEOUT_MS = 600_000;
11
+ export const MAX_AGENT_SESSION_ID_CHARS = 128;
12
+ const NATIVE_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ /** Per-stream stdout/stderr capture ceiling. A child can emit indefinitely without growing the parent heap. */
14
+ export const DEFAULT_MAX_CAPTURE_BYTES = 1_048_576;
15
+ const MIN_MAX_CAPTURE_BYTES = 256;
16
+ export class AgentSessionResumeError extends Error {
17
+ code;
18
+ constructor(code, message) {
19
+ super(message);
20
+ this.code = code;
21
+ this.name = 'AgentSessionResumeError';
22
+ }
23
+ }
24
+ /** Validate before spawn so malformed or cross-harness session targets always fail closed. */
25
+ export function validateAgentSessionResume(resume, expectedRunner) {
26
+ if (resume.runner !== expectedRunner) {
27
+ throw new AgentSessionResumeError('runner_mismatch', `session resume runner '${resume.runner}' does not match selected runner '${expectedRunner}'`);
28
+ }
29
+ if (expectedRunner !== 'claude' && expectedRunner !== 'cursor') {
30
+ throw new AgentSessionResumeError('unsupported_runner', `runner '${expectedRunner}' does not support native session resume`);
31
+ }
32
+ if (!NATIVE_SESSION_ID_PATTERN.test(resume.sessionId)) {
33
+ throw new AgentSessionResumeError('invalid_session_id', `session resume identifier must be ${MAX_AGENT_SESSION_ID_CHARS} characters or fewer and use only letters, numbers, '.', '_', or '-'`);
34
+ }
35
+ return resume;
36
+ }
10
37
  /** Thrown when permission bypass is requested without bounding the agent's tools (would be an unbounded autonomous agent). */
11
38
  export class UnboundedSkipPermissionsError extends Error {
12
39
  constructor() {
@@ -14,6 +41,13 @@ export class UnboundedSkipPermissionsError extends Error {
14
41
  this.name = 'UnboundedSkipPermissionsError';
15
42
  }
16
43
  }
44
+ /** Thrown when Codex permission options cannot be enforced by its CLI. */
45
+ export class UnsupportedCodexPermissionOptionsError extends Error {
46
+ constructor() {
47
+ super('codex runner does not support skipPermissions or allowedTools: the bypass is danger-full-access and Codex has no per-tool allowlist');
48
+ this.name = 'UnsupportedCodexPermissionOptionsError';
49
+ }
50
+ }
17
51
  /** Thrown when Cursor skipPermissions is requested before the runner can enforce per-run permissions. */
18
52
  export class UnsupportedCursorSkipPermissionsError extends Error {
19
53
  constructor() {
@@ -21,6 +55,20 @@ export class UnsupportedCursorSkipPermissionsError extends Error {
21
55
  this.name = 'UnsupportedCursorSkipPermissionsError';
22
56
  }
23
57
  }
58
+ /** Thrown when Cursor workspace trust is requested without verified host containment. */
59
+ export class UnsupportedCursorWorkspaceTrustError extends Error {
60
+ constructor() {
61
+ super('cursor runner does not support workspaceTrust: --trust grants host filesystem and network access that a Git worktree cannot contain');
62
+ this.name = 'UnsupportedCursorWorkspaceTrustError';
63
+ }
64
+ }
65
+ /** Thrown when Gemini permission options cannot be mapped to a verified bounded CLI contract. */
66
+ export class UnsupportedGeminiPermissionOptionsError extends Error {
67
+ constructor() {
68
+ 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');
69
+ this.name = 'UnsupportedGeminiPermissionOptionsError';
70
+ }
71
+ }
24
72
  /** Thrown when Cursor's Windows installation cannot be reduced to a shell-free node.exe + index.js launch. */
25
73
  export class UnsupportedCursorWindowsRunnerError extends Error {
26
74
  constructor() {
@@ -114,47 +162,526 @@ function cursorVersionKey(version) {
114
162
  const second = match[6] ?? '0';
115
163
  return [year, month, day, hour, minute, second].map((part, index) => index === 0 ? part : part.padStart(2, '0')).join('');
116
164
  }
165
+ const WINDOWS_TREE_KILL_TIMEOUT_MS = 5_000;
166
+ /** Build the shell-free taskkill invocation used for Windows process-tree termination. */
167
+ export function windowsTreeKillCommand(pid) {
168
+ if (!Number.isSafeInteger(pid) || pid <= 0)
169
+ throw new RangeError(`invalid process id: ${pid}`);
170
+ return { command: 'taskkill.exe', args: ['/PID', String(pid), '/T', '/F'] };
171
+ }
172
+ /** Run taskkill and report whether Windows accepted the process-tree termination request. */
173
+ export function killWindowsProcessTree(pid, spawnCommand = spawn, timeoutMs = WINDOWS_TREE_KILL_TIMEOUT_MS) {
174
+ const { command, args } = windowsTreeKillCommand(pid);
175
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
176
+ throw new RangeError(`invalid taskkill timeout: ${timeoutMs}`);
177
+ return new Promise((resolve) => {
178
+ let settled = false;
179
+ let killer;
180
+ const finish = (ok, terminateKiller = false) => {
181
+ if (settled)
182
+ return;
183
+ settled = true;
184
+ clearTimeout(timer);
185
+ if (terminateKiller) {
186
+ try {
187
+ killer?.kill?.('SIGKILL');
188
+ }
189
+ catch { /* best-effort watchdog cleanup */ }
190
+ }
191
+ resolve(ok);
192
+ };
193
+ const timer = setTimeout(() => finish(false, true), timeoutMs);
194
+ timer.unref?.();
195
+ try {
196
+ killer = spawnCommand(command, args, { shell: false, windowsHide: true, stdio: 'ignore' });
197
+ killer.once('error', () => finish(false));
198
+ killer.once('close', (code) => finish(code === 0));
199
+ }
200
+ catch {
201
+ finish(false);
202
+ }
203
+ });
204
+ }
205
+ /** Thrown when Cursor allowedTools are requested without a verified per-tool CLI allowlist. */
206
+ export class UnsupportedCursorAllowedToolsError extends Error {
207
+ constructor() {
208
+ super('cursor runner does not support allowedTools: cursor-agent has no verified per-run tool allowlist');
209
+ this.name = 'UnsupportedCursorAllowedToolsError';
210
+ }
211
+ }
212
+ /** A redirected stdout artifact could not be finalized; the subprocess outcome remains available for classification. */
213
+ export class SpawnCaptureFinalizeError extends Error {
214
+ result;
215
+ constructor(result, cause) {
216
+ const detail = cause instanceof Error ? `: ${cause.message}` : '';
217
+ super(`redirected stdout finalization failed${detail}`, { cause });
218
+ this.name = 'SpawnCaptureFinalizeError';
219
+ this.result = result;
220
+ }
221
+ }
222
+ /**
223
+ * Retain a bounded prefix and suffix while counting every byte received. Keeping raw buffers until rendering
224
+ * avoids corrupting multi-byte UTF-8 characters when Node splits a character across stream chunks.
225
+ */
226
+ class BoundedStreamCapture {
227
+ maxBytes;
228
+ headCapacity;
229
+ tailCapacity;
230
+ head;
231
+ tail;
232
+ headLength = 0;
233
+ tailLength = 0;
234
+ tailWriteOffset = 0;
235
+ totalBytes = 0;
236
+ constructor(maxBytes) {
237
+ this.maxBytes = maxBytes;
238
+ this.headCapacity = Math.ceil(maxBytes / 2);
239
+ this.tailCapacity = maxBytes - this.headCapacity;
240
+ }
241
+ append(value) {
242
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
243
+ this.totalBytes += chunk.length;
244
+ let offset = 0;
245
+ if (this.headLength < this.headCapacity) {
246
+ const take = Math.min(this.headCapacity - this.headLength, chunk.length);
247
+ const head = this.ensureHeadCapacity(this.headLength + take);
248
+ chunk.copy(head, this.headLength, 0, take);
249
+ this.headLength += take;
250
+ offset = take;
251
+ }
252
+ if (offset < chunk.length)
253
+ this.appendTail(chunk.subarray(offset));
254
+ }
255
+ result() {
256
+ const head = this.head?.subarray(0, this.headLength) ?? Buffer.alloc(0);
257
+ const tail = this.orderedTail();
258
+ if (this.totalBytes <= this.maxBytes) {
259
+ return {
260
+ text: Buffer.concat([head, tail]).toString('utf8'),
261
+ bytes: this.totalBytes,
262
+ truncated: false,
263
+ };
264
+ }
265
+ const marker = Buffer.from(`\n...[evolver output truncated; total_bytes=${this.totalBytes}]...\n`);
266
+ const retainedBudget = this.maxBytes - marker.length;
267
+ const headBudget = Math.ceil(retainedBudget / 2);
268
+ const tailBudget = retainedBudget - headBudget;
269
+ const retainedHead = trimIncompleteUtf8Suffix(head.subarray(0, headBudget));
270
+ const tailStart = Math.max(0, tail.length - tailBudget);
271
+ const retainedTail = trimUtf8ContinuationPrefix(tail.subarray(tailStart));
272
+ return {
273
+ text: Buffer.concat([retainedHead, marker, retainedTail]).toString('utf8'),
274
+ bytes: this.totalBytes,
275
+ truncated: true,
276
+ };
277
+ }
278
+ ensureHeadCapacity(required) {
279
+ const current = this.head;
280
+ if (current && current.length >= required)
281
+ return current;
282
+ let capacity = current?.length ?? Math.min(4_096, this.headCapacity);
283
+ while (capacity < required)
284
+ capacity = Math.min(this.headCapacity, capacity * 2);
285
+ const next = Buffer.allocUnsafe(capacity);
286
+ if (current)
287
+ current.copy(next, 0, 0, this.headLength);
288
+ this.head = next;
289
+ return next;
290
+ }
291
+ appendTail(incoming) {
292
+ const tail = this.tail ??= Buffer.allocUnsafe(this.tailCapacity);
293
+ if (incoming.length >= this.tailCapacity) {
294
+ incoming.copy(tail, 0, incoming.length - this.tailCapacity);
295
+ this.tailLength = this.tailCapacity;
296
+ this.tailWriteOffset = 0;
297
+ return;
298
+ }
299
+ const first = Math.min(incoming.length, this.tailCapacity - this.tailWriteOffset);
300
+ incoming.copy(tail, this.tailWriteOffset, 0, first);
301
+ if (first < incoming.length)
302
+ incoming.copy(tail, 0, first);
303
+ this.tailWriteOffset = (this.tailWriteOffset + incoming.length) % this.tailCapacity;
304
+ this.tailLength = Math.min(this.tailCapacity, this.tailLength + incoming.length);
305
+ }
306
+ orderedTail() {
307
+ const tail = this.tail;
308
+ if (!tail || this.tailLength === 0)
309
+ return Buffer.alloc(0);
310
+ if (this.tailLength < this.tailCapacity)
311
+ return tail.subarray(0, this.tailLength);
312
+ if (this.tailWriteOffset === 0)
313
+ return tail;
314
+ return Buffer.concat([
315
+ tail.subarray(this.tailWriteOffset),
316
+ tail.subarray(0, this.tailWriteOffset),
317
+ ]);
318
+ }
319
+ }
320
+ function trimIncompleteUtf8Suffix(value) {
321
+ if (value.length === 0)
322
+ return value;
323
+ let lead = value.length - 1;
324
+ while (lead >= 0 && (value[lead] & 0xc0) === 0x80)
325
+ lead -= 1;
326
+ if (lead < 0)
327
+ return Buffer.alloc(0);
328
+ const first = value[lead];
329
+ const expected = first < 0x80 ? 1 : first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc0 ? 2 : 1;
330
+ return value.length - lead < expected ? value.subarray(0, lead) : value;
331
+ }
332
+ function trimUtf8ContinuationPrefix(value) {
333
+ let offset = 0;
334
+ while (offset < value.length && (value[offset] & 0xc0) === 0x80)
335
+ offset += 1;
336
+ return value.subarray(offset);
337
+ }
117
338
  /**
118
339
  * Promise wrapper over spawn (shell:false). Optionally writes `input` to stdin; resolves with stdout/exit.
119
340
  * On timeout the WHOLE process group is killed, not just the direct child (finding #39.5): an agent spawns
120
341
  * tool subprocesses (grandchildren) that would otherwise orphan and leak. On POSIX we spawn detached (the
121
- * child becomes its own group leader) and SIGKILL the group via the negative pid; Windows falls back to a
122
- * direct kill (different process-group semantics).
342
+ * child becomes its own group leader) and SIGKILL the group via the negative pid. Windows runs
343
+ * `taskkill.exe /PID <pid> /T /F` without a shell and waits for that command before resolving.
123
344
  */
124
345
  export function spawnCapture(cmd, args, opts) {
346
+ const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_CAPTURE_BYTES;
347
+ if (!Number.isSafeInteger(maxOutputBytes)
348
+ || maxOutputBytes < MIN_MAX_CAPTURE_BYTES
349
+ || maxOutputBytes > DEFAULT_MAX_CAPTURE_BYTES) {
350
+ throw new RangeError(`maxOutputBytes must be an integer between ${MIN_MAX_CAPTURE_BYTES} and ${DEFAULT_MAX_CAPTURE_BYTES}`);
351
+ }
125
352
  return new Promise((resolve, reject) => {
126
- const detached = process.platform !== 'win32';
353
+ if (opts.signal?.aborted) {
354
+ resolve({
355
+ code: null,
356
+ stdout: '',
357
+ stderr: '',
358
+ termination: 'cancelled',
359
+ stdoutBytes: 0,
360
+ stderrBytes: 0,
361
+ stdoutTruncated: false,
362
+ stderrTruncated: false,
363
+ });
364
+ return;
365
+ }
366
+ const platform = opts.processPlatform ?? process.platform;
367
+ const detached = platform !== 'win32';
127
368
  const r = resolveSpawnCommand(cmd, args, opts.env, opts.resolvePlatform ?? process.platform);
128
- const child = spawn(r.cmd, r.args, { cwd: opts.cwd, shell: false, detached, ...(opts.env ? { env: opts.env } : {}) });
129
- let stdout = '';
130
- let stderr = '';
369
+ let stdoutFd;
370
+ let ownsStdoutFile = false;
371
+ const cleanupOwnedStdoutFile = () => {
372
+ if (!ownsStdoutFile || !opts.stdoutFile)
373
+ return;
374
+ try {
375
+ rmSync(opts.stdoutFile, { force: true });
376
+ ownsStdoutFile = false;
377
+ }
378
+ catch { /* best-effort; a caller ownership hook can retry */ }
379
+ };
380
+ try {
381
+ if (opts.stdoutFile) {
382
+ stdoutFd = openSync(opts.stdoutFile, 'wx', 0o600);
383
+ ownsStdoutFile = true;
384
+ opts.onStdoutFileOpened?.(opts.stdoutFile);
385
+ }
386
+ }
387
+ catch (error) {
388
+ if (stdoutFd !== undefined) {
389
+ try {
390
+ closeSync(stdoutFd);
391
+ }
392
+ catch { /* best-effort cleanup before the child exists */ }
393
+ stdoutFd = undefined;
394
+ }
395
+ cleanupOwnedStdoutFile();
396
+ reject(error);
397
+ return;
398
+ }
399
+ let child;
400
+ try {
401
+ child = (opts.spawnCommand ?? spawn)(r.cmd, r.args, {
402
+ cwd: opts.cwd,
403
+ shell: false,
404
+ detached,
405
+ ...(opts.env ? { env: opts.env } : {}),
406
+ ...(stdoutFd !== undefined ? { stdio: ['pipe', stdoutFd, 'pipe'] } : {}),
407
+ });
408
+ }
409
+ catch (error) {
410
+ if (stdoutFd !== undefined) {
411
+ try {
412
+ closeSync(stdoutFd);
413
+ }
414
+ catch { /* best-effort cleanup before rejection */ }
415
+ stdoutFd = undefined;
416
+ }
417
+ cleanupOwnedStdoutFile();
418
+ reject(error);
419
+ return;
420
+ }
421
+ const stdoutCapture = new BoundedStreamCapture(maxOutputBytes);
422
+ const stderrCapture = new BoundedStreamCapture(maxOutputBytes);
423
+ let termination = 'exit';
424
+ let killPromise;
425
+ let settled = false;
426
+ let redirectedStdoutBytes;
131
427
  const killTree = () => {
132
- if (detached && typeof child.pid === 'number') {
428
+ if (killPromise)
429
+ return killPromise;
430
+ killPromise = (async () => {
431
+ if (platform === 'win32' && typeof child.pid === 'number') {
432
+ let killed = false;
433
+ try {
434
+ killed = await (opts.windowsProcessTreeKiller ?? killWindowsProcessTree)(child.pid);
435
+ }
436
+ catch {
437
+ // Treat an injected/custom killer rejection like taskkill failure and fall back to the direct child.
438
+ }
439
+ if (killed)
440
+ return;
441
+ }
442
+ if (detached && typeof child.pid === 'number') {
443
+ try {
444
+ process.kill(-child.pid, 'SIGKILL');
445
+ return;
446
+ }
447
+ catch { /* group gone; fall back */ }
448
+ }
449
+ child.kill('SIGKILL');
450
+ })();
451
+ return killPromise;
452
+ };
453
+ const cancel = () => {
454
+ if (termination !== 'exit')
455
+ return;
456
+ termination = 'cancelled';
457
+ void killTree();
458
+ };
459
+ const timeout = () => {
460
+ if (termination !== 'exit')
461
+ return;
462
+ termination = 'timeout';
463
+ void killTree();
464
+ };
465
+ const ignoreProcessSignal = () => { };
466
+ const cleanup = () => {
467
+ clearTimeout(timer);
468
+ opts.signal?.removeEventListener('abort', cancel);
469
+ if (opts.processSignalMode === 'ignore') {
470
+ process.removeListener('SIGINT', ignoreProcessSignal);
471
+ process.removeListener('SIGTERM', ignoreProcessSignal);
472
+ }
473
+ else {
474
+ process.removeListener('SIGINT', cancel);
475
+ process.removeListener('SIGTERM', cancel);
476
+ }
477
+ };
478
+ const settle = async (finish) => {
479
+ if (settled)
480
+ return;
481
+ settled = true;
482
+ if (killPromise)
483
+ await killPromise;
484
+ cleanup();
485
+ let stdoutFileError;
486
+ if (stdoutFd !== undefined) {
487
+ const fd = stdoutFd;
488
+ stdoutFd = undefined;
133
489
  try {
134
- process.kill(-child.pid, 'SIGKILL');
135
- return;
490
+ redirectedStdoutBytes = opts.stdoutFileOps?.size(fd) ?? fstatSync(fd).size;
491
+ }
492
+ catch (error) {
493
+ stdoutFileError = error;
494
+ }
495
+ try {
496
+ (opts.stdoutFileOps?.close ?? closeSync)(fd);
497
+ }
498
+ catch (error) {
499
+ stdoutFileError ??= error;
500
+ try {
501
+ closeSync(fd);
502
+ }
503
+ catch { /* retry a failed/injected close before removing our artifact */ }
136
504
  }
137
- catch { /* group gone; fall back */ }
138
505
  }
139
- child.kill('SIGKILL');
506
+ finish(stdoutFileError);
140
507
  };
141
- const timer = setTimeout(killTree, opts.timeoutMs);
142
- child.stdout?.on('data', (d) => { stdout += d.toString(); });
143
- child.stderr?.on('data', (d) => { stderr += d.toString(); });
144
- child.on('error', (e) => { clearTimeout(timer); reject(e); });
145
- child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
146
- if (opts.input !== undefined) {
147
- child.stdin?.write(opts.input);
148
- child.stdin?.end();
508
+ const timer = setTimeout(timeout, opts.timeoutMs);
509
+ opts.signal?.addEventListener('abort', cancel, { once: true });
510
+ // A detached POSIX child would otherwise survive Ctrl-C/SIGTERM. Cancel first so the bridge can clean its
511
+ // worktree and return a failure instead of leaking an agent or tool subprocess.
512
+ if (opts.processSignalMode === 'ignore') {
513
+ process.on('SIGINT', ignoreProcessSignal);
514
+ process.on('SIGTERM', ignoreProcessSignal);
149
515
  }
516
+ else {
517
+ process.once('SIGINT', cancel);
518
+ process.once('SIGTERM', cancel);
519
+ }
520
+ child.stdout?.on('data', (d) => { stdoutCapture.append(d); });
521
+ child.stderr?.on('data', (d) => { stderrCapture.append(d); });
522
+ const stdinCompletion = opts.input === undefined
523
+ ? Promise.resolve(undefined)
524
+ : new Promise((resolveInput) => {
525
+ const stdin = child.stdin;
526
+ if (!stdin) {
527
+ resolveInput(new Error('runner stdin is unavailable'));
528
+ void killTree();
529
+ return;
530
+ }
531
+ let completed = false;
532
+ const complete = (error) => {
533
+ if (completed)
534
+ return false;
535
+ completed = true;
536
+ resolveInput(error);
537
+ return true;
538
+ };
539
+ stdin.on('error', (error) => {
540
+ if (complete(error instanceof Error ? error : new Error(String(error)))) {
541
+ void killTree();
542
+ }
543
+ });
544
+ stdin.on('finish', () => complete());
545
+ stdin.on('close', () => {
546
+ if (complete(new Error('runner stdin closed before prompt delivery'))) {
547
+ void killTree();
548
+ }
549
+ });
550
+ try {
551
+ stdin.end(opts.input);
552
+ }
553
+ catch (error) {
554
+ if (complete(error instanceof Error ? error : new Error(String(error)))) {
555
+ void killTree();
556
+ }
557
+ }
558
+ });
559
+ child.on('error', (e) => {
560
+ void settle(() => {
561
+ cleanupOwnedStdoutFile();
562
+ reject(e);
563
+ });
564
+ });
565
+ child.on('close', (code) => {
566
+ void stdinCompletion.then((inputError) => {
567
+ void settle((stdoutFileError) => {
568
+ const stdout = stdoutCapture.result();
569
+ const stderr = stderrCapture.result();
570
+ const result = {
571
+ code,
572
+ stdout: stdout.text,
573
+ stderr: stderr.text,
574
+ termination,
575
+ stdoutBytes: redirectedStdoutBytes ?? stdout.bytes,
576
+ stderrBytes: stderr.bytes,
577
+ stdoutTruncated: stdout.truncated,
578
+ stderrTruncated: stderr.truncated,
579
+ ...(opts.stdoutFile ? { stdoutRedirected: true } : {}),
580
+ };
581
+ if (stdoutFileError !== undefined) {
582
+ cleanupOwnedStdoutFile();
583
+ reject(new SpawnCaptureFinalizeError(result, stdoutFileError));
584
+ return;
585
+ }
586
+ if (inputError && termination === 'exit' && code === 0) {
587
+ cleanupOwnedStdoutFile();
588
+ reject(inputError);
589
+ return;
590
+ }
591
+ resolve(result);
592
+ });
593
+ });
594
+ });
595
+ // Defined input is delivered and closed by stdin.end(opts.input) above. With no input, still close the pipe so
596
+ // EOF-driven CLIs cannot hang.
597
+ if (opts.input === undefined)
598
+ child.stdin?.end();
150
599
  });
151
600
  }
601
+ /** Map the shared process result into the failure taxonomy used by plain-text runners. */
602
+ export function classifyBasicRunnerResult(runner, result, timeoutMs, resume) {
603
+ if (result.termination === 'timeout') {
604
+ return {
605
+ ok: false,
606
+ output: resume ? '' : result.stdout,
607
+ error: `${runner} timed out after ${timeoutMs}ms`,
608
+ failureKind: 'timeout',
609
+ exitCode: result.code,
610
+ };
611
+ }
612
+ if (result.termination === 'cancelled') {
613
+ return {
614
+ ok: false,
615
+ output: resume ? '' : result.stdout,
616
+ error: `${runner} execution cancelled`,
617
+ failureKind: 'cancelled',
618
+ exitCode: result.code,
619
+ };
620
+ }
621
+ // Runner stdout is agent content and may legitimately discuss these errors, including on a later failure.
622
+ const diagnostic = result.stderr;
623
+ if (result.code !== 0 && /permission denied|access denied|not authorized|unauthorized|authentication required|please (?:log|sign) in|login required/i.test(diagnostic)) {
624
+ return {
625
+ ok: false,
626
+ output: resume ? '' : result.stdout,
627
+ error: `${runner} permission denied while executing${resume ? ' resumed session' : ''}`,
628
+ failureKind: 'permission_denied',
629
+ exitCode: result.code,
630
+ };
631
+ }
632
+ const escapedResumeId = resume?.sessionId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
633
+ const contextualMissingSession = escapedResumeId
634
+ ? new RegExp(`(?:session|conversation|chat)\\s+(?:(?:with\\s+)?id[:=]?\\s+)?["']?${escapedResumeId}["']?\\s+(?:was\\s+)?(?:not found|does not exist)`, 'i').test(diagnostic)
635
+ : false;
636
+ if (resume && (contextualMissingSession || /(?:session|conversation|chat) (?:was )?not found|no (?:conversation|session|chat) found|invalid session(?: id)?|unable to resume|cannot resume|(?:session|conversation|chat) does not exist|expired session/i.test(diagnostic))) {
637
+ return {
638
+ ok: false,
639
+ output: '',
640
+ error: `${runner} resume session is missing, stale, or unavailable`,
641
+ failureKind: 'runtime_error',
642
+ exitCode: result.code,
643
+ };
644
+ }
645
+ if (result.code !== 0) {
646
+ const error = result.stderr || `${runner} exited with code ${String(result.code)}`;
647
+ const safeError = resume
648
+ ? error.replaceAll(resume.sessionId, '[session-id]')
649
+ : error;
650
+ return {
651
+ ok: false,
652
+ output: result.stdout,
653
+ error: safeError,
654
+ failureKind: 'non_zero_exit',
655
+ exitCode: result.code,
656
+ };
657
+ }
658
+ return { ok: true, output: result.stdout };
659
+ }
660
+ function spawnFailureResult(error) {
661
+ return {
662
+ ok: false,
663
+ output: '',
664
+ error: error instanceof Error ? error.message : String(error),
665
+ failureKind: 'spawn_failed',
666
+ exitCode: null,
667
+ };
668
+ }
669
+ export const CLAUDE_SAFE_AUTONOMOUS_TOOLS = ['Read', 'Edit', 'Write', 'Glob', 'Grep'];
670
+ const CLAUDE_SAFE_AUTONOMOUS_TOOL_SET = new Set(CLAUDE_SAFE_AUTONOMOUS_TOOLS);
671
+ export function hasBoundedClaudeFileAccess(opts) {
672
+ return opts?.permissionMode === 'acceptEdits'
673
+ && opts.skipPermissions !== true
674
+ && (opts.allowedTools?.length ?? 0) === 0
675
+ && Array.isArray(opts.tools)
676
+ && opts.tools.length > 0
677
+ && opts.tools.every((tool) => CLAUDE_SAFE_AUTONOMOUS_TOOL_SET.has(tool));
678
+ }
152
679
  /**
153
- * Build the `claude -p` argv for the given options (pure testable without spawning).
680
+ * Build the `claude -p` argv for the given options (pure and testable without spawning).
154
681
  * Safety invariant: skipPermissions (bypassing prompts) is only allowed together with a non-empty
155
- * allowedTools otherwise it would be an unattended agent with full tools and no gate; refuse loudly.
682
+ * allowedTools; otherwise it would be an unattended agent with full tools and no gate; refuse loudly.
156
683
  */
157
- export function claudeRunnerArgs(opts = {}) {
684
+ export function claudeRunnerArgs(opts = {}, resume) {
158
685
  const bounded = !!(opts.allowedTools && opts.allowedTools.length > 0);
159
686
  if (opts.skipPermissions && !bounded)
160
687
  throw new UnboundedSkipPermissionsError();
@@ -163,24 +690,36 @@ export function claudeRunnerArgs(opts = {}) {
163
690
  args.push('--dangerously-skip-permissions');
164
691
  if (opts.allowedTools && opts.allowedTools.length > 0)
165
692
  args.push('--allowedTools', ...opts.allowedTools);
693
+ if (opts.permissionMode)
694
+ args.push('--permission-mode', opts.permissionMode);
695
+ if (opts.tools && opts.tools.length > 0)
696
+ args.push('--tools', opts.tools.join(','));
697
+ if (opts.permissionMode === 'acceptEdits') {
698
+ args.push('--strict-mcp-config', '--disable-slash-commands', '--setting-sources', '');
699
+ }
166
700
  if (opts.model)
167
701
  args.push('--model', opts.model);
702
+ if (resume) {
703
+ validateAgentSessionResume(resume, 'claude');
704
+ args.push('--resume', resume.sessionId);
705
+ }
168
706
  return args;
169
707
  }
170
708
  /**
171
- * Build a headless `claude -p` agent runner. Prompt fed via stdin (no shell, no argv length limit). For
172
- * unattended evolution set { skipPermissions: true, allowedTools: ['Read','Edit','Write'] } bypass the
173
- * permission prompts but bound the agent to file edits. Validated end to end against a real agent.
709
+ * Build a headless `claude -p` agent runner. Prompt is fed via stdin (no shell, no argv length limit).
710
+ * For unattended edits, prefer permissionMode: 'acceptEdits' with the bounded file/search tool list.
174
711
  */
175
712
  export function makeClaudeHeadlessRunner(opts = {}) {
176
713
  const args = claudeRunnerArgs(opts);
177
714
  return async (prompt, ctx) => {
715
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
716
+ const runArgs = ctx.resume ? claudeRunnerArgs(opts, ctx.resume) : args;
178
717
  try {
179
- const r = await spawnCapture('claude', args, { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, input: prompt, ...(ctx.env ? { env: ctx.env } : {}) });
180
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
718
+ const result = await spawnCapture('claude', runArgs, { cwd: ctx.cwd, timeoutMs, input: prompt, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
719
+ return classifyBasicRunnerResult('claude', result, timeoutMs, ctx.resume);
181
720
  }
182
721
  catch (e) {
183
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
722
+ return spawnFailureResult(e);
184
723
  }
185
724
  };
186
725
  }
@@ -188,37 +727,246 @@ export function makeClaudeHeadlessRunner(opts = {}) {
188
727
  export const claudeHeadlessRunner = makeClaudeHeadlessRunner();
189
728
  // --- Codex runner (#66 multi-harness) ---
190
729
  /**
191
- * Build the `codex exec` argv (pure). Verified live against codex-cli 0.137.0:
192
- * - sandboxed default → `exec --sandbox workspace-write`: edits the workspace non-interactively (read-only,
193
- * the codex default, cannot write). The wrapper's worktree + allowedRoots are the outer containment.
194
- * - skipPermissions (bounded) `exec --dangerously-bypass-approvals-and-sandbox`: full bypass, intended for
195
- * an already-externally-sandboxed run (our throwaway worktree). The `skip⇒bounded` invariant is the explicit
196
- * acknowledgement guard, same shape as claude.
730
+ * Build the `codex exec` argv (pure). Verified live against codex-cli 0.144.6:
731
+ * - sandboxed default → `--ask-for-approval never exec --sandbox workspace-write`: edits the workspace
732
+ * without waiting for interactive approval. The wrapper's worktree + allowedRoots are the outer containment.
733
+ * - permission overrides fail closed: Codex has no per-tool allowlist, and a Git worktree does not contain
734
+ * danger-full-access host filesystem or network access.
197
735
  */
198
736
  export function codexRunnerArgs(opts = {}) {
199
- const bounded = !!(opts.allowedTools && opts.allowedTools.length > 0);
200
- if (opts.skipPermissions && !bounded)
201
- throw new UnboundedSkipPermissionsError(); // same invariant as claude skip⇒allowedTools
202
- const args = ['exec'];
203
- // codex bounds via sandbox mode (not a per-tool allowlist): workspace-write is the safe autonomous default;
204
- // the explicit bypass removes the inner sandbox for a run the wrapper already isolates.
205
- args.push('--sandbox', opts.skipPermissions ? 'danger-full-access' : 'workspace-write');
206
- if (opts.skipPermissions)
207
- args.push('--dangerously-bypass-approvals-and-sandbox');
737
+ if (opts.skipPermissions || opts.allowedTools !== undefined) {
738
+ throw new UnsupportedCodexPermissionOptionsError();
739
+ }
740
+ const args = ['--ask-for-approval', 'never', 'exec'];
741
+ args.push('--sandbox', 'workspace-write');
742
+ args.push('--ephemeral');
208
743
  if (opts.model)
209
744
  args.push('--model', opts.model);
210
745
  return args;
211
746
  }
212
- /** Headless `codex exec` runner. Working root pinned with `--cd`; prompt is the trailing positional arg (shell:false). */
213
- export function makeCodexHeadlessRunner(opts = {}) {
747
+ /** Headless `codex exec` runner. Working root pinned with `--cd`; prompt is sent over stdin. */
748
+ export function makeCodexHeadlessRunner(opts = {}, spawnCaptureFn = spawnCapture) {
214
749
  const args = codexRunnerArgs(opts);
215
750
  return async (prompt, ctx) => {
751
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
216
752
  try {
217
- const r = await spawnCapture('codex', [...args, '--cd', ctx.cwd, prompt], { cwd: ctx.cwd, timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(ctx.env ? { env: ctx.env } : {}) });
218
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
753
+ const result = await spawnCaptureFn('codex', [...args, '--cd', ctx.cwd, '-'], { cwd: ctx.cwd, timeoutMs, input: prompt, ...(ctx.env ? { env: ctx.env } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
754
+ return classifyBasicRunnerResult('codex', result, timeoutMs);
219
755
  }
220
756
  catch (e) {
221
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
757
+ return spawnFailureResult(e);
758
+ }
759
+ };
760
+ }
761
+ const GEMINI_PERMISSION_DENIAL_RE = /agent execution blocked|permission denied|approval required|not approved|denied by (?:policy|user|admin)|tool (?:call )?(?:was )?denied/i;
762
+ const GEMINI_TERMINATION_WARNINGS = [
763
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Agent execution stopped\b/i, error: 'gemini agent execution stopped' },
764
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Loop detected\b/i, error: 'gemini loop detected' },
765
+ { pattern: /^(?:[^:\r\n]{1,80}:\s*)?Maximum session turns exceeded\b/i, error: 'gemini maximum session turns exceeded' },
766
+ ];
767
+ function geminiMessage(value) {
768
+ if (typeof value === 'string')
769
+ return value;
770
+ if (value && typeof value === 'object') {
771
+ const record = value;
772
+ const type = typeof record['type'] === 'string' ? record['type'] : '';
773
+ const message = typeof record['message'] === 'string' ? record['message'] : '';
774
+ return [type, message].filter(Boolean).join(': ');
775
+ }
776
+ return value === undefined ? '' : String(value);
777
+ }
778
+ function geminiWarnings(value) {
779
+ return Array.isArray(value) ? value.map(geminiMessage).filter(Boolean) : [];
780
+ }
781
+ /** Accept only native-session-safe ids so Learning Ops never joins on garbage envelope fields. */
782
+ function geminiSessionId(value) {
783
+ if (typeof value !== 'string')
784
+ return undefined;
785
+ const sessionId = value.trim();
786
+ return NATIVE_SESSION_ID_PATTERN.test(sessionId) ? sessionId : undefined;
787
+ }
788
+ function withGeminiSessionId(result, envelope) {
789
+ const sessionId = geminiSessionId(envelope.session_id);
790
+ return sessionId !== undefined ? { ...result, sessionId } : result;
791
+ }
792
+ /**
793
+ * Gemini sometimes prints non-JSON diagnostics before the structured envelope.
794
+ * Prefer pure JSON; otherwise accept the last top-level JSON object in stdout.
795
+ */
796
+ function parseGeminiStdout(stdout) {
797
+ const trimmed = stdout.trim();
798
+ if (!trimmed)
799
+ return null;
800
+ try {
801
+ const parsed = JSON.parse(trimmed);
802
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
803
+ return parsed;
804
+ }
805
+ catch {
806
+ // fall through to last-object recovery
807
+ }
808
+ for (let index = trimmed.lastIndexOf('{'); index >= 0; index = trimmed.lastIndexOf('{', index - 1)) {
809
+ try {
810
+ const parsed = JSON.parse(trimmed.slice(index));
811
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
812
+ return parsed;
813
+ }
814
+ catch {
815
+ // keep scanning earlier braces
816
+ }
817
+ }
818
+ return null;
819
+ }
820
+ /** Interpret one bounded Gemini subprocess result. Structured output and diagnostics require complete capture. */
821
+ export function classifyGeminiRunnerResult(result, timeoutMs) {
822
+ if (result.termination === 'timeout') {
823
+ return { ok: false, output: result.stdout, error: `gemini timed out after ${timeoutMs}ms`, failureKind: 'timeout', exitCode: result.code };
824
+ }
825
+ if (result.termination === 'cancelled') {
826
+ return { ok: false, output: result.stdout, error: 'gemini execution cancelled', failureKind: 'cancelled', exitCode: result.code };
827
+ }
828
+ if (result.stdoutTruncated || result.stderrTruncated) {
829
+ return {
830
+ ok: false,
831
+ output: result.stdout,
832
+ error: 'gemini output exceeded the capture limit',
833
+ failureKind: 'invalid_output',
834
+ exitCode: result.code,
835
+ };
836
+ }
837
+ const envelope = parseGeminiStdout(result.stdout);
838
+ if (!envelope) {
839
+ const error = result.stderr || (result.code === 0 ? 'gemini returned invalid JSON output' : `gemini exited with code ${String(result.code)}`);
840
+ return { ok: false, output: result.stdout, error, failureKind: result.code === 0 ? 'invalid_output' : 'non_zero_exit', exitCode: result.code };
841
+ }
842
+ const structuredError = geminiMessage(envelope.error);
843
+ const warnings = geminiWarnings(envelope.warnings);
844
+ const terminationError = result.code === 0
845
+ ? GEMINI_TERMINATION_WARNINGS.find(({ pattern }) => warnings.some((warning) => pattern.test(warning)))?.error
846
+ : undefined;
847
+ if (terminationError) {
848
+ return withGeminiSessionId({ ok: false, output: geminiMessage(envelope.response), error: terminationError, failureKind: 'runtime_error', exitCode: result.code }, envelope);
849
+ }
850
+ const denial = [structuredError, ...warnings, result.stderr].find((message) => GEMINI_PERMISSION_DENIAL_RE.test(message));
851
+ if (denial) {
852
+ return withGeminiSessionId({ ok: false, output: geminiMessage(envelope.response), error: denial, failureKind: 'permission_denied', exitCode: result.code }, envelope);
853
+ }
854
+ if (result.code !== 0) {
855
+ return withGeminiSessionId({ ok: false, output: geminiMessage(envelope.response), error: structuredError || result.stderr || `gemini exited with code ${String(result.code)}`, failureKind: 'non_zero_exit', exitCode: result.code }, envelope);
856
+ }
857
+ if (structuredError) {
858
+ return withGeminiSessionId({ ok: false, output: geminiMessage(envelope.response), error: structuredError, failureKind: 'runtime_error', exitCode: result.code }, envelope);
859
+ }
860
+ return withGeminiSessionId({ ok: true, output: geminiMessage(envelope.response), exitCode: result.code }, envelope);
861
+ }
862
+ /** Build verified Gemini CLI argv. The prompt is appended separately as one argv element with shell:false. */
863
+ export function geminiRunnerArgs(opts = {}) {
864
+ if (opts.skipPermissions || (opts.allowedTools?.length ?? 0) > 0)
865
+ throw new UnsupportedGeminiPermissionOptionsError();
866
+ const args = [
867
+ '--output-format', 'json',
868
+ '--approval-mode', 'auto_edit',
869
+ '--skip-trust',
870
+ '--extensions', 'none',
871
+ '--allowed-mcp-server-names', '__evolver_no_mcp__',
872
+ ];
873
+ if (opts.model)
874
+ args.push('--model', opts.model);
875
+ return args;
876
+ }
877
+ const GEMINI_AUTH_FILE_MAX_BYTES = 1_048_576;
878
+ function copyGeminiAuthFile(sourceDir, targetDir, name) {
879
+ const source = joinPath(sourceDir, name);
880
+ try {
881
+ const stat = lstatSync(source);
882
+ if (!stat.isFile() || stat.size > GEMINI_AUTH_FILE_MAX_BYTES)
883
+ return;
884
+ const target = joinPath(targetDir, name);
885
+ copyFileSync(source, target);
886
+ chmodSync(target, 0o600);
887
+ }
888
+ catch {
889
+ // Missing or unreadable optional auth state must fail closed in Gemini itself.
890
+ }
891
+ }
892
+ function sanitizedGeminiAuthSettings(sourceDir) {
893
+ try {
894
+ const parsed = JSON.parse(readFileSync(joinPath(sourceDir, 'settings.json'), 'utf8'));
895
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
896
+ return {};
897
+ const security = parsed['security'];
898
+ if (!security || typeof security !== 'object' || Array.isArray(security))
899
+ return {};
900
+ const auth = security['auth'];
901
+ if (!auth || typeof auth !== 'object' || Array.isArray(auth))
902
+ return {};
903
+ const selectedType = auth['selectedType'];
904
+ return typeof selectedType === 'string' && selectedType.length <= 128
905
+ ? { security: { auth: { selectedType } } }
906
+ : {};
907
+ }
908
+ catch {
909
+ return {};
910
+ }
911
+ }
912
+ function isolatedGeminiEnv(env, removeTempDir) {
913
+ const root = mkdtempSync(joinPath(tmpdir(), 'evolver-gemini-'));
914
+ const globalDir = joinPath(root, '.gemini');
915
+ mkdirSync(globalDir, { mode: 0o700 });
916
+ const sourceHome = env['GEMINI_CLI_HOME'] || env['HOME'];
917
+ const sourceDir = sourceHome ? joinPath(sourceHome, '.gemini') : undefined;
918
+ if (sourceDir) {
919
+ copyGeminiAuthFile(sourceDir, globalDir, 'oauth_creds.json');
920
+ copyGeminiAuthFile(sourceDir, globalDir, 'google_accounts.json');
921
+ }
922
+ const authSettings = sourceDir ? sanitizedGeminiAuthSettings(sourceDir) : {};
923
+ writeFileSync(joinPath(globalDir, 'settings.json'), `${JSON.stringify(authSettings)}\n`, { mode: 0o600 });
924
+ const systemSettings = joinPath(root, 'system-settings.json');
925
+ const systemDefaults = joinPath(root, 'system-defaults.json');
926
+ writeFileSync(systemDefaults, '{}\n', { mode: 0o600 });
927
+ writeFileSync(systemSettings, '{"hooksConfig":{"enabled":false},"admin":{"mcp":{"enabled":false}}}\n', { mode: 0o600 });
928
+ const isolatedEnv = { ...env };
929
+ for (const name of Object.keys(isolatedEnv)) {
930
+ if (name.startsWith('GEMINI_CLI_') || name.startsWith('XDG_'))
931
+ delete isolatedEnv[name];
932
+ }
933
+ return {
934
+ env: {
935
+ ...isolatedEnv,
936
+ GEMINI_CLI_HOME: root,
937
+ GEMINI_CLI_SYSTEM_DEFAULTS_PATH: systemDefaults,
938
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettings,
939
+ },
940
+ cleanup: () => removeTempDir(root),
941
+ };
942
+ }
943
+ /** Headless Gemini runner with structured failure classification; stdout text alone never proves execution success. */
944
+ export function makeGeminiHeadlessRunner(opts = {}, removeTempDir = (path) => rmSync(path, { recursive: true, force: true })) {
945
+ const args = geminiRunnerArgs(opts);
946
+ return async (prompt, ctx) => {
947
+ let cleanup = () => { };
948
+ try {
949
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
950
+ const isolated = isolatedGeminiEnv({ ...(ctx.env ?? process.env) }, removeTempDir);
951
+ cleanup = isolated.cleanup;
952
+ const result = await spawnCapture('gemini', [...args, '--prompt', prompt], {
953
+ cwd: ctx.cwd,
954
+ timeoutMs,
955
+ env: isolated.env,
956
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
957
+ });
958
+ return classifyGeminiRunnerResult(result, timeoutMs);
959
+ }
960
+ catch (error) {
961
+ return { ok: false, output: '', error: error instanceof Error ? error.message : String(error), failureKind: 'spawn_failed', exitCode: null };
962
+ }
963
+ finally {
964
+ try {
965
+ cleanup();
966
+ }
967
+ catch {
968
+ // Cleanup is best-effort and must not replace the subprocess result.
969
+ }
222
970
  }
223
971
  };
224
972
  }
@@ -233,56 +981,91 @@ export function makeCodexHeadlessRunner(opts = {}) {
233
981
  // - `--model <model>` exists (e.g. gpt-5, sonnet-4, sonnet-4-thinking); `--list-models` enumerates.
234
982
  // - auth: `CURSOR_API_KEY` or `--api-key` (the spec's envAllow prefix is CURSOR_).
235
983
  // - cursor has its OWN `--sandbox enabled|disabled` and `-w/--worktree`; we still wrap with our git worktree.
236
- // STILL NOT run-verified end to end (needs an authed cursor-agent; claude/codex were each run-verified). The
237
- // Windows launcher is a PowerShell shim and its own version regex rejects the current timestamped version-dir
238
- // shape. resolveSpawnCommand therefore bypasses both scripts and runs the newest verified node.exe + index.js
239
- // bundle directly, shell-free. If that known bundle layout cannot be found, the runner still fail-fasts.
984
+ // Bundle and auth preflight verified on Windows with Cursor Agent 2026.06.15. The Windows launcher is a
985
+ // PowerShell shim and its own version regex rejects the current timestamped version-dir shape. resolveSpawnCommand
986
+ // therefore bypasses both scripts and runs the newest verified node.exe + index.js bundle directly, shell-free.
987
+ // If that known bundle layout cannot be found, the runner still fail-fasts.
240
988
  //
241
- // SAFETY: `-p --force --trust` auto-approves shell+write with no verified per-run allowlist/sandbox mapping.
242
- // Until Cursor can really map agentOptions into per-run permissions, skipPermissions is refused outright. The
243
- // wrapper worktree still contains default cursor runs, but it is not a permissions/sandbox substitute for skip.
244
- // Safe default keeps skip OFF (CURSOR_DEFAULT_AGENT_OPTIONS), so the wiring is exercised by tests with fakes.
989
+ // SAFETY: `--trust`, `--force`, permission bypass, and per-tool allowlists remain fail-closed. The worktree is a
990
+ // measurement/patch-containment boundary, not an OS/network sandbox.
245
991
  /**
246
992
  * Build the `cursor-agent` argv (pure). Ground-truth from `cursor-agent --help` (#66): base `-p --output-format
247
993
  * text` (headless, write+shell access). `--model` is a real flag. skipPermissions is rejected until Cursor has a
248
994
  * verified per-run allowlist/sandbox mapping; allowedTools is not emitted because cursor has no per-tool allowlist.
249
995
  */
250
- export function cursorRunnerArgs(opts = {}) {
996
+ export function cursorRunnerArgs(opts = {}, resume, managedWorktreeName) {
251
997
  if (opts.skipPermissions)
252
998
  throw new UnsupportedCursorSkipPermissionsError();
999
+ if (opts.workspaceTrust !== undefined)
1000
+ throw new UnsupportedCursorWorkspaceTrustError();
1001
+ if (opts.allowedTools !== undefined) {
1002
+ throw new UnsupportedCursorAllowedToolsError();
1003
+ }
253
1004
  const args = ['-p', '--output-format', 'text'];
254
1005
  if (opts.model)
255
1006
  args.push('--model', opts.model);
1007
+ if (resume) {
1008
+ validateAgentSessionResume(resume, 'cursor');
1009
+ args.push('--resume', resume.sessionId);
1010
+ }
1011
+ if (managedWorktreeName) {
1012
+ if (!NATIVE_SESSION_ID_PATTERN.test(managedWorktreeName)) {
1013
+ throw new AgentSessionResumeError('invalid_session_id', 'managed worktree name is invalid');
1014
+ }
1015
+ args.push('--worktree', managedWorktreeName, '--skip-worktree-setup');
1016
+ }
256
1017
  return args;
257
1018
  }
1019
+ function cursorManagedWorktreePath(stdout) {
1020
+ const match = /^Using worktree: (.+)$/m.exec(stdout);
1021
+ return match?.[1]?.trim();
1022
+ }
258
1023
  /**
259
1024
  * Headless `cursor-agent` runner. Prompt passed as the trailing positional arg (shell:false, no injection risk;
260
- * docs show `cursor-agent -p "<prompt>"`). cwd is set via spawn. SCAFFOLD run-verify against a real
261
- * cursor-agent before autonomous use (see the block comment above for what is doc-confirmed vs unverified).
1025
+ * docs show `cursor-agent -p "<prompt>"`). cwd is set via spawn. Workspace trust must be certified by the
1026
+ * bridge refuses built-in autonomous Cursor until host containment is verified.
262
1027
  */
263
1028
  export function makeCursorHeadlessRunner(opts = {}, platform = process.platform) {
264
1029
  const args = cursorRunnerArgs(opts);
265
1030
  return async (prompt, ctx) => {
1031
+ const runArgs = ctx.resume || ctx.managedWorktreeName
1032
+ ? cursorRunnerArgs(opts, ctx.resume, ctx.managedWorktreeName)
1033
+ : args;
266
1034
  assertCursorRunnerPlatformSupported(platform, ctx.env ?? process.env);
1035
+ const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;
267
1036
  try {
268
- const r = await spawnCapture('cursor-agent', [...args, prompt], {
1037
+ const result = await spawnCapture('cursor-agent', [...runArgs, prompt], {
269
1038
  cwd: ctx.cwd,
270
- timeoutMs: ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1039
+ timeoutMs,
271
1040
  resolvePlatform: platform,
272
1041
  ...(ctx.env ? { env: ctx.env } : {}),
1042
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
273
1043
  });
274
- return r.code === 0 ? { ok: true, output: r.stdout } : { ok: false, output: r.stdout, error: r.stderr || `exit ${r.code}` };
1044
+ const classified = classifyBasicRunnerResult('cursor', result, timeoutMs, ctx.resume);
1045
+ const managedWorktreePath = cursorManagedWorktreePath(`${result.stdout}\n${result.stderr}`);
1046
+ return managedWorktreePath ? { ...classified, managedWorktreePath } : classified;
275
1047
  }
276
1048
  catch (e) {
277
- return { ok: false, output: '', error: e instanceof Error ? e.message : String(e) };
1049
+ return spawnFailureResult(e);
278
1050
  }
279
1051
  };
280
1052
  }
281
1053
  const RUNNER_SPECS = {
282
- claude: { name: 'claude', makeRunner: makeClaudeHeadlessRunner, envAllow: { prefixes: ['ANTHROPIC_', 'CLAUDE_'] } },
1054
+ // AWS_ prefix is allowlisted so the claude CLI can authenticate against Amazon Bedrock (CLAUDE_CODE_USE_BEDROCK=1
1055
+ // + AWS_REGION/AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY). This is a legitimate runner-auth channel,
1056
+ // symmetric to CURSOR_/OPENAI_ for the other runners; no other runner inherits it.
1057
+ claude: { name: 'claude', makeRunner: makeClaudeHeadlessRunner, envAllow: { prefixes: ['ANTHROPIC_', 'CLAUDE_', 'AWS_'] } },
283
1058
  codex: { name: 'codex', makeRunner: makeCodexHeadlessRunner, envAllow: { prefixes: ['OPENAI_', 'CODEX_'] } },
284
1059
  // cursor keeps only its OWN auth env (CURSOR_); like every runner it never inherits another's vendor key.
285
1060
  cursor: { name: 'cursor', makeRunner: makeCursorHeadlessRunner, envAllow: { prefixes: ['CURSOR_'] } },
1061
+ gemini: {
1062
+ name: 'gemini',
1063
+ makeRunner: makeGeminiHeadlessRunner,
1064
+ envAllow: {
1065
+ prefixes: ['GEMINI_'],
1066
+ keys: ['GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS', 'GOOGLE_CLOUD_PROJECT', 'GOOGLE_CLOUD_LOCATION', 'GOOGLE_GENAI_USE_VERTEXAI'],
1067
+ },
1068
+ },
286
1069
  };
287
1070
  /** Resolve a runner spec by name (default 'claude' — byte-identical to the pre-registry behavior). */
288
1071
  export function getRunnerSpec(name = 'claude') {