@the-open-engine/zeroshot 6.39.0 → 6.39.2

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 (88) hide show
  1. package/README.md +39 -1
  2. package/cli/index.js +90 -24
  3. package/cluster-templates/conductor-bootstrap.json +1 -1
  4. package/lib/agent-cli-provider/adapters/claude.js +1 -1
  5. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  6. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  7. package/lib/agent-cli-provider/adapters/codex.js +26 -1
  8. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  9. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/adapters/omp.js +1 -1
  11. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  12. package/lib/agent-cli-provider/omp/sdk-environment-policy.d.ts +4 -0
  13. package/lib/agent-cli-provider/omp/sdk-environment-policy.d.ts.map +1 -0
  14. package/lib/agent-cli-provider/omp/sdk-environment-policy.js +37 -0
  15. package/lib/agent-cli-provider/omp/sdk-environment-policy.js.map +1 -0
  16. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.js +4 -15
  18. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.js.map +1 -1
  19. package/lib/agent-cli-provider/omp/sdk-process-result.d.ts +2 -0
  20. package/lib/agent-cli-provider/omp/sdk-process-result.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/omp/sdk-process-result.js +2 -2
  22. package/lib/agent-cli-provider/omp/sdk-process-result.js.map +1 -1
  23. package/lib/agent-cli-provider/omp/sdk-process-runner.d.ts.map +1 -1
  24. package/lib/agent-cli-provider/omp/sdk-process-runner.js +6 -1
  25. package/lib/agent-cli-provider/omp/sdk-process-runner.js.map +1 -1
  26. package/lib/agent-cli-provider/provider-registry.d.ts +3 -1
  27. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  28. package/lib/agent-cli-provider/provider-registry.js +9 -2
  29. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  30. package/lib/agent-cli-provider/single-agent-runtime.d.ts +4 -1
  31. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  32. package/lib/agent-cli-provider/single-agent-runtime.js +18 -15
  33. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  34. package/lib/agent-cli-provider/types.d.ts +3 -0
  35. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  36. package/lib/agent-cli-provider/types.js.map +1 -1
  37. package/lib/cluster-worker/engine-adapter-common.js +18 -0
  38. package/lib/cluster-worker/engine-adapter.js +2 -1
  39. package/lib/detached-startup.d.ts +1 -0
  40. package/lib/detached-startup.js +2 -1
  41. package/lib/git-remote-utils.js +22 -5
  42. package/lib/start-cluster-environment.d.ts +1 -0
  43. package/lib/start-cluster-run-options.d.ts +1 -0
  44. package/lib/start-cluster-run-options.js +1 -0
  45. package/npm-shrinkwrap.json +6 -8
  46. package/package.json +4 -3
  47. package/scripts/omp/runtime-identities.js +31 -18
  48. package/scripts/omp/runtime.js +2 -0
  49. package/scripts/rust-distribution.js +13 -31
  50. package/src/agent/agent-lifecycle.js +17 -1
  51. package/src/agent/output-extraction-failures.js +73 -0
  52. package/src/agent/output-extraction-failures.ts +100 -0
  53. package/src/agent/output-extraction-types.ts +5 -0
  54. package/src/agent/provider-terminal-failure.js +2 -2
  55. package/src/agent/provider-terminal-failure.ts +4 -2
  56. package/src/agent-cli-provider/adapters/claude.ts +1 -1
  57. package/src/agent-cli-provider/adapters/codex.ts +36 -1
  58. package/src/agent-cli-provider/adapters/omp.ts +2 -1
  59. package/src/agent-cli-provider/omp/sdk-environment-policy.ts +38 -0
  60. package/src/agent-cli-provider/omp/sdk-process-private-runtime.ts +4 -16
  61. package/src/agent-cli-provider/omp/sdk-process-result.ts +4 -2
  62. package/src/agent-cli-provider/omp/sdk-process-runner.ts +7 -1
  63. package/src/agent-cli-provider/provider-registry.ts +10 -2
  64. package/src/agent-cli-provider/single-agent-runtime.ts +24 -15
  65. package/src/agent-cli-provider/types.ts +3 -0
  66. package/src/agents/git-pusher-template.js +78 -105
  67. package/src/attach/socket-paths.js +1 -1
  68. package/src/attach/socket-paths.ts +1 -1
  69. package/src/copy-containment.js +191 -0
  70. package/src/copy-containment.ts +259 -0
  71. package/src/copy-worker.js +29 -30
  72. package/src/copy-worker.ts +43 -30
  73. package/src/foreground-benchmark-run.js +24 -12
  74. package/src/isolation-manager.js +59 -20
  75. package/src/legacy-lib/detached-startup.ts +3 -1
  76. package/src/legacy-lib/git-remote-utils.ts +23 -5
  77. package/src/legacy-lib/start-cluster-environment.ts +1 -0
  78. package/src/legacy-lib/start-cluster-run-options.ts +2 -0
  79. package/src/omp-session-verifier.js +8 -5
  80. package/src/orchestrator.js +55 -3
  81. package/src/pr-body-template.js +71 -0
  82. package/src/preflight.js +31 -6
  83. package/src/providers/index.js +3 -3
  84. package/src/task-execution-context.js +22 -0
  85. package/src/watcher-prompt-channel.js +1 -1
  86. package/src/worktree-claude-config.js +25 -0
  87. package/src/worktree-claude-config.ts +28 -0
  88. package/task-lib/runner.js +57 -22
@@ -4,6 +4,7 @@ const { writeBenchmarkResultBundle } = require('./foreground-benchmark-files');
4
4
  const TERMINAL_TOPICS = ['CLUSTER_COMPLETE', 'CLUSTER_FAILED'];
5
5
  const VERIFIER_ELIGIBLE = new Set(['completed', 'task_failure']);
6
6
  const EXIT_CODES = Object.freeze({
7
+ task_failure: 23,
7
8
  provider_failure: 20,
8
9
  engine_failure: 21,
9
10
  cancelled: 22,
@@ -40,20 +41,24 @@ function requireSettledStatus(orchestrator, clusterId) {
40
41
  return status;
41
42
  }
42
43
 
43
- function writeForegroundResult({ orchestrator, cluster, clusterId, resultPath, cancelled }) {
44
- const status = requireSettledStatus(orchestrator, clusterId);
45
- const terminals = terminalMessages(cluster, clusterId);
46
- let result;
44
+ function buildForegroundResult({ orchestrator, cluster, clusterId, cancelled }) {
45
+ const finalRun = orchestrator.getFinalRun?.(clusterId);
46
+ const status = finalRun?.status || requireSettledStatus(orchestrator, clusterId);
47
+ const terminals = finalRun?.terminalMessages || terminalMessages(cluster, clusterId);
47
48
  if (cancelled && terminals.length === 0) {
48
- result = buildCancelledResult({ runId: clusterId, agents: status.agents });
49
- } else {
50
- result = buildBenchmarkResult({
51
- runId: clusterId,
52
- terminalMessages: terminals,
53
- agents: status.agents,
54
- });
49
+ return buildCancelledResult({ runId: clusterId, agents: status.agents });
55
50
  }
56
- const snapshot = cluster.messageBus.readSnapshot(clusterId);
51
+ return buildBenchmarkResult({
52
+ runId: clusterId,
53
+ terminalMessages: terminals,
54
+ agents: status.agents,
55
+ });
56
+ }
57
+
58
+ function writeForegroundResult({ orchestrator, cluster, clusterId, resultPath, cancelled }) {
59
+ const result = buildForegroundResult({ orchestrator, cluster, clusterId, cancelled });
60
+ const snapshot =
61
+ orchestrator.getFinalRun?.(clusterId)?.snapshot || cluster.messageBus.readSnapshot(clusterId);
57
62
  return writeBenchmarkResultBundle(resultPath, result, snapshot);
58
63
  }
59
64
 
@@ -64,8 +69,15 @@ function exitCodeForResult(result) {
64
69
  return exitCode;
65
70
  }
66
71
 
72
+ function exitCodeForForegroundResult(result) {
73
+ if (result.outcome === 'completed') return 0;
74
+ return EXIT_CODES[result.outcome] ?? exitCodeForResult(result);
75
+ }
76
+
67
77
  module.exports = {
78
+ buildForegroundResult,
68
79
  exitCodeForResult,
80
+ exitCodeForForegroundResult,
69
81
  isForegroundStatusSettled,
70
82
  terminalMessages,
71
83
  writeForegroundResult,
@@ -34,6 +34,14 @@ const { resolveProviderCredentialPaths } = require('../lib/provider-credential-p
34
34
  const { getProvider } = require('./providers');
35
35
  const { readRepoSettings } = require('../lib/repo-settings');
36
36
  const { provisionClaudeCredentials } = require('./claude-credentials');
37
+ const {
38
+ CopyContainmentError,
39
+ copyErrorFromPayload,
40
+ createCopyBoundary,
41
+ resolveCopyPath,
42
+ resolveSourcePath,
43
+ validateRelativePath,
44
+ } = require('./copy-containment');
37
45
 
38
46
  const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
39
47
  const FRESH_BASE_REF_PREFIX = 'refs/zeroshot/base-fetch';
@@ -1298,6 +1306,7 @@ class IsolationManager {
1298
1306
  // Phase 1: Collect all files and directories
1299
1307
  const files = [];
1300
1308
  const directories = new Set();
1309
+ const copyBoundary = createCopyBoundary(src, dest);
1301
1310
 
1302
1311
  const shouldIgnoreFsError = (err) =>
1303
1312
  err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
@@ -1328,12 +1337,13 @@ class IsolationManager {
1328
1337
  }
1329
1338
  };
1330
1339
 
1331
- function handleEntry(entry, srcPath, relPath, relativePath) {
1340
+ function handleEntry(entry, relPath, relativePath, ancestorDirectories) {
1332
1341
  if (entry.isSymbolicLink()) {
1333
- const targetStats = fs.statSync(srcPath);
1342
+ const resolvedSourcePath = resolveSourcePath(copyBoundary, relPath);
1343
+ const targetStats = fs.statSync(resolvedSourcePath);
1334
1344
  if (targetStats.isDirectory()) {
1335
1345
  directories.add(relPath);
1336
- collectFiles(srcPath, relPath);
1346
+ collectFiles(relPath, ancestorDirectories);
1337
1347
  return;
1338
1348
  }
1339
1349
 
@@ -1344,7 +1354,7 @@ class IsolationManager {
1344
1354
 
1345
1355
  if (entry.isDirectory()) {
1346
1356
  directories.add(relPath);
1347
- collectFiles(srcPath, relPath);
1357
+ collectFiles(relPath, ancestorDirectories);
1348
1358
  return;
1349
1359
  }
1350
1360
 
@@ -1352,7 +1362,15 @@ class IsolationManager {
1352
1362
  ensureParentDirTracked(relativePath);
1353
1363
  }
1354
1364
 
1355
- function collectFiles(currentSrc, relativePath = '') {
1365
+ function collectFiles(relativePath = '', ancestorDirectories = new Set()) {
1366
+ const currentSrc = relativePath
1367
+ ? resolveSourcePath(copyBoundary, relativePath)
1368
+ : copyBoundary.sourceRoot.canonicalPath;
1369
+ if (ancestorDirectories.has(currentSrc)) {
1370
+ throw new CopyContainmentError(relativePath, 'source directory symlink creates a cycle');
1371
+ }
1372
+ const childAncestors = new Set(ancestorDirectories);
1373
+ childAncestors.add(currentSrc);
1356
1374
  const entries = readEntries(currentSrc);
1357
1375
 
1358
1376
  for (const entry of entries) {
@@ -1360,11 +1378,11 @@ class IsolationManager {
1360
1378
  continue;
1361
1379
  }
1362
1380
 
1363
- const srcPath = path.join(currentSrc, entry.name);
1364
- const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
1381
+ const entryName = validateRelativePath(entry.name);
1382
+ const relPath = relativePath ? path.join(relativePath, entryName) : entryName;
1365
1383
 
1366
1384
  try {
1367
- handleEntry(entry, srcPath, relPath, relativePath);
1385
+ handleEntry(entry, relPath, relativePath, childAncestors);
1368
1386
  } catch (err) {
1369
1387
  if (shouldIgnoreFsError(err)) {
1370
1388
  continue;
@@ -1374,7 +1392,7 @@ class IsolationManager {
1374
1392
  }
1375
1393
  }
1376
1394
 
1377
- collectFiles(src);
1395
+ collectFiles();
1378
1396
 
1379
1397
  // Phase 2: Create directory structure (sequential - must exist before file copy)
1380
1398
  // Sort directories by depth to ensure parents are created before children
@@ -1385,7 +1403,7 @@ class IsolationManager {
1385
1403
  });
1386
1404
 
1387
1405
  for (const dir of sortedDirs) {
1388
- const destDir = path.join(dest, dir);
1406
+ const { destinationPath: destDir } = resolveCopyPath(copyBoundary, dir);
1389
1407
  try {
1390
1408
  fs.mkdirSync(destDir, { recursive: true });
1391
1409
  } catch (err) {
@@ -1399,10 +1417,9 @@ class IsolationManager {
1399
1417
  // For small file counts (<100), use synchronous copy (worker overhead not worth it)
1400
1418
  if (files.length < 100) {
1401
1419
  for (const relPath of files) {
1402
- const srcPath = path.join(src, relPath);
1403
- const destPath = path.join(dest, relPath);
1404
1420
  try {
1405
- fs.copyFileSync(srcPath, destPath);
1421
+ const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relPath);
1422
+ fs.copyFileSync(sourcePath, destinationPath);
1406
1423
  } catch (err) {
1407
1424
  if (err.code !== 'EACCES' && err.code !== 'EPERM' && err.code !== 'ENOENT') {
1408
1425
  throw err;
@@ -1424,18 +1441,22 @@ class IsolationManager {
1424
1441
  }
1425
1442
 
1426
1443
  // Spawn workers and wait for completion
1444
+ const workers = [];
1427
1445
  const workerPromises = chunks.map((chunk) => {
1428
1446
  return new Promise((resolve, reject) => {
1429
1447
  const worker = new Worker(workerPath, {
1430
1448
  workerData: {
1431
1449
  files: chunk,
1432
- sourceBase: src,
1433
- destBase: dest,
1450
+ sourceBase: copyBoundary.sourceRoot.canonicalPath,
1451
+ destBase: copyBoundary.destinationRoot.canonicalPath,
1452
+ expectedBoundary: copyBoundary,
1434
1453
  },
1435
1454
  });
1455
+ workers.push(worker);
1456
+ let result = null;
1436
1457
 
1437
- worker.on('message', (result) => {
1438
- resolve(result);
1458
+ worker.on('message', (workerResult) => {
1459
+ result = workerResult;
1439
1460
  });
1440
1461
 
1441
1462
  worker.on('error', (err) => {
@@ -1445,6 +1466,12 @@ class IsolationManager {
1445
1466
  worker.on('exit', (code) => {
1446
1467
  if (code !== 0) {
1447
1468
  reject(new Error(`Worker exited with code ${code}`));
1469
+ } else if (!result) {
1470
+ reject(new Error('Copy worker exited without reporting a result'));
1471
+ } else if (result.error) {
1472
+ reject(copyErrorFromPayload(result.error));
1473
+ } else {
1474
+ resolve(result);
1448
1475
  }
1449
1476
  });
1450
1477
  });
@@ -1453,7 +1480,12 @@ class IsolationManager {
1453
1480
  // Wait for all workers to complete (proper async/await - no busy-wait!)
1454
1481
  // FIX: Previous version used busy-wait which blocked the event loop,
1455
1482
  // preventing worker thread messages from being processed (timeout bug)
1456
- await Promise.all(workerPromises);
1483
+ try {
1484
+ await Promise.all(workerPromises);
1485
+ } catch (err) {
1486
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
1487
+ throw err;
1488
+ }
1457
1489
  }
1458
1490
 
1459
1491
  /**
@@ -2383,8 +2415,15 @@ class IsolationManager {
2383
2415
  // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
2384
2416
  // Compose project — only a project scoped to the worktree directory basename, which is
2385
2417
  // the only kind zeroshot could itself have created, is touched.
2386
- const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2387
- const teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
2418
+ let teardown = { shouldTeardown: false };
2419
+ try {
2420
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2421
+ teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
2422
+ } catch (error) {
2423
+ console.warn(
2424
+ `[IsolationManager] Skipping Docker Compose teardown in ${worktreeInfo.path}: ${error.message}`
2425
+ );
2426
+ }
2388
2427
  if (teardown.shouldTeardown) {
2389
2428
  try {
2390
2429
  runSync('docker', teardown.args, {
@@ -26,6 +26,7 @@ interface RunOptions extends Record<string, unknown> {
26
26
  mergeQueue?: unknown;
27
27
  pr?: unknown;
28
28
  prBase?: unknown;
29
+ prBody?: unknown;
29
30
  ship?: unknown;
30
31
  worktree?: unknown;
31
32
  }
@@ -236,9 +237,10 @@ async function registerDetachedSetupCluster({
236
237
  setupStartedAt: Date.now(),
237
238
  setupStage: 'starting',
238
239
  autoPr: plan.delivery !== 'none',
239
- prOptions: runOptions.prBase
240
+ prOptions: plan.delivery !== 'none'
240
241
  ? {
241
242
  prBase: runOptions.prBase,
243
+ prBody: typeof runOptions.prBody === 'string' ? runOptions.prBody : null,
242
244
  mergeQueue: runOptions.mergeQueue || false,
243
245
  closeIssue: runOptions.closeIssue || null,
244
246
  autoMerge: plan.autoMerge,
@@ -181,6 +181,25 @@ function parseGitRemoteUrl(remoteUrl: unknown): GitContext | null {
181
181
  return null;
182
182
  }
183
183
 
184
+ function parseFetchRemoteLine(line: string): readonly [string, string] | null {
185
+ // Partial clones append their filter after the fetch marker, for example
186
+ // `origin https://github.com/org/repo.git (fetch) [blob:none]`.
187
+ const fetchMarker = ' (fetch)';
188
+ const fetchMarkerIndex = line.lastIndexOf(fetchMarker);
189
+ if (fetchMarkerIndex < 1) return null;
190
+ const suffix = line.slice(fetchMarkerIndex + fetchMarker.length);
191
+ if (suffix.length > 0 && !(suffix.startsWith(' [') && suffix.endsWith(']'))) return null;
192
+
193
+ const remoteLine = line.slice(0, fetchMarkerIndex);
194
+ const separatorIndex = remoteLine.search(/\s/);
195
+ if (separatorIndex < 1) {
196
+ return null;
197
+ }
198
+
199
+ const remoteUrl = remoteLine.slice(separatorIndex).trim();
200
+ return remoteUrl.length > 0 ? [remoteLine.slice(0, separatorIndex), remoteUrl] : null;
201
+ }
202
+
184
203
  /**
185
204
  * Detect git repository context from current working directory.
186
205
  * Returns provider context extracted from git remote URL.
@@ -205,15 +224,14 @@ function detectGitContext(cwd = process.cwd()): GitContextWithRemote | null {
205
224
 
206
225
  const supportedRemotes = new Map<string, GitContextWithRemote>();
207
226
  for (const line of remoteOutput.split(/\r?\n/)) {
208
- const match = /^(\S+)\s+(.+)\s+\(fetch\)$/.exec(line);
209
- if (!match) {
227
+ const remoteParts = parseFetchRemoteLine(line);
228
+ if (!remoteParts) {
210
229
  continue;
211
230
  }
212
231
 
213
- const remoteCandidate = match[1];
214
- const remoteUrl = match[2];
232
+ const [remoteCandidate, remoteUrl] = remoteParts;
215
233
  const remote = normalizeGitRemoteName(remoteCandidate);
216
- if (!remote || remoteUrl === undefined) {
234
+ if (!remote) {
217
235
  continue;
218
236
  }
219
237
  const context = parseGitRemoteUrl(remoteUrl);
@@ -10,6 +10,7 @@ interface RunOptions extends Record<string, unknown> {
10
10
  mount?: readonly string[] | null;
11
11
  noIsolation?: unknown;
12
12
  prBase?: unknown;
13
+ prBody?: unknown;
13
14
  }
14
15
 
15
16
  interface MountSpec {
@@ -28,6 +28,7 @@ interface RunOptions extends Record<string, unknown> {
28
28
  noMounts?: unknown;
29
29
  pr?: unknown;
30
30
  prBase?: unknown;
31
+ prBody?: unknown;
31
32
  preparedWorktree?: unknown;
32
33
  requiredQualityGates?: unknown;
33
34
  ship?: unknown;
@@ -201,6 +202,7 @@ function buildStartOptionsFromPlan({
201
202
  containerHome: optionalValue(options.containerHome),
202
203
  forceProvider: optionalValue(forceProvider),
203
204
  prBase: environment ? resolvePrBase(options) : optionalValue(options.prBase),
205
+ prBody: typeof options.prBody === 'string' ? options.prBody : undefined,
204
206
  mergeQueue: environment ? resolveMergeQueue(options) : optionalValue(options.mergeQueue),
205
207
  closeIssue: environment ? resolveCloseIssue(options) : optionalValue(options.closeIssue),
206
208
  ship: plan.delivery === 'ship',
@@ -399,7 +399,10 @@ function streamSessionJsonl(fd, describePath, limits) {
399
399
  `${describePath} record ${records} is not valid JSON: ${error.message}`
400
400
  );
401
401
  }
402
- if (header === null) header = parsed;
402
+ // OMP 17.2.1 may reserve its first JSONL record for mutable title metadata before writing the
403
+ // authoritative session record. Accept exactly that one known preamble position; every other
404
+ // ordering still fails closed in parseSessionHeader below.
405
+ if (header === null && !(records === 1 && parsed?.type === 'title')) header = parsed;
403
406
  collectCanonicalBlobRefs(parsed, blobRefs, limits);
404
407
  }
405
408
 
@@ -430,9 +433,9 @@ function streamSessionJsonl(fd, describePath, limits) {
430
433
  };
431
434
  }
432
435
 
433
- /** The session's first record is OMP's session header (`{type:"session", version, id, cwd, ...}`
434
- * session-manager.ts `#resetToNewSession`). Its `id` is the authoritative session identity written
435
- * to disk and its `cwd` is the workspace the session belongs to. */
436
+ /** The session header (`{type:"session", version, id, cwd, ...}`) is either the first record or
437
+ * follows OMP 17.2.1's single leading title-metadata record. Its `id` is the authoritative session
438
+ * identity written to disk and its `cwd` is the workspace the session belongs to. */
436
439
  function parseSessionHeader(header, describePath) {
437
440
  if (!header || typeof header !== 'object' || Array.isArray(header)) {
438
441
  fail('session-header-missing', `${describePath} has no session header record.`);
@@ -440,7 +443,7 @@ function parseSessionHeader(header, describePath) {
440
443
  if (header.type !== 'session') {
441
444
  fail(
442
445
  'session-header-invalid',
443
- `${describePath} first record is type ${JSON.stringify(header.type)}, not "session".`
446
+ `${describePath} first non-title-preamble record is type ${JSON.stringify(header.type)}, not "session".`
444
447
  );
445
448
  }
446
449
  if (typeof header.id !== 'string' || header.id.length === 0) {
@@ -262,6 +262,7 @@ function buildPrOptions(options, requiredQualityGates) {
262
262
  prBase: options.prBase || null,
263
263
  mergeQueue: options.mergeQueue || false,
264
264
  closeIssue: options.closeIssue || null,
265
+ prBody: typeof options.prBody === 'string' ? options.prBody : null,
265
266
  gitRemote: options.gitRemote || null,
266
267
  autoMerge,
267
268
  ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
@@ -306,6 +307,9 @@ class Orchestrator {
306
307
  this.closed = false;
307
308
  this._conductorWatchdogs = new Set();
308
309
  this._clusterRunBoundaries = new Map();
310
+ // Successful auto-cleanup closes and removes the live cluster before a foreground caller can
311
+ // build its authoritative result. Preserve only the bounded terminal handoff for this process.
312
+ this._finalRuns = new Map();
309
313
 
310
314
  // Track if clusters are loaded (for lazy loading pattern)
311
315
  this._clustersLoaded = options.skipLoad === true;
@@ -2038,6 +2042,7 @@ class Orchestrator {
2038
2042
  prBase: options.prBase,
2039
2043
  mergeQueue: options.mergeQueue,
2040
2044
  closeIssue: options.closeIssue,
2045
+ prBody: options.prBody,
2041
2046
  requiredQualityGates: options.requiredQualityGates,
2042
2047
  autoMerge: resolveRunPlan(options).autoMerge,
2043
2048
  gitRemote: gitContext?.remote || options.gitRemote,
@@ -2155,8 +2160,16 @@ class Orchestrator {
2155
2160
  // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
2156
2161
  // Compose project — only a project scoped to the worktree directory basename, which is
2157
2162
  // the only kind zeroshot could itself have created, is touched.
2158
- const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2159
- const teardown = resolveWorktreeComposeTeardown(worktreePath);
2163
+ let teardown;
2164
+ try {
2165
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2166
+ teardown = resolveWorktreeComposeTeardown(worktreePath);
2167
+ } catch (error) {
2168
+ this._log(
2169
+ `[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${error.message}`
2170
+ );
2171
+ return;
2172
+ }
2160
2173
  if (!teardown.shouldTeardown) {
2161
2174
  if (teardown.composePath) {
2162
2175
  this._log(
@@ -2420,6 +2433,7 @@ class Orchestrator {
2420
2433
  cluster.pid = null; // Clear PID - cluster is no longer running
2421
2434
 
2422
2435
  if (shouldAutoCleanWorktree) {
2436
+ this._captureFinalRun(clusterId, cluster);
2423
2437
  // Close message bus and ledger (same as kill() path)
2424
2438
  cluster.messageBus.close();
2425
2439
  }
@@ -2552,6 +2566,35 @@ class Orchestrator {
2552
2566
  cluster.messageBus.close();
2553
2567
  }
2554
2568
  }
2569
+ this._finalRuns.clear();
2570
+ }
2571
+
2572
+ _captureFinalRun(clusterId, cluster) {
2573
+ const snapshot = cluster.messageBus.readSnapshot(clusterId);
2574
+ const terminalMessages = ['CLUSTER_COMPLETE', 'CLUSTER_FAILED']
2575
+ .flatMap((topic) => cluster.messageBus.query({ cluster_id: clusterId, topic }))
2576
+ .sort((left, right) => {
2577
+ const a = BigInt(left.sequence);
2578
+ const b = BigInt(right.sequence);
2579
+ return a < b ? -1 : a > b ? 1 : 0;
2580
+ });
2581
+ this._finalRuns.set(clusterId, {
2582
+ snapshot,
2583
+ terminalMessages,
2584
+ status: {
2585
+ id: clusterId,
2586
+ state: cluster.state,
2587
+ isZombie: false,
2588
+ pid: null,
2589
+ createdAt: cluster.createdAt,
2590
+ agents: cluster.agents.map((agent) => agent.getState()),
2591
+ messageCount: snapshot.messageCount,
2592
+ setupLogPath: cluster.setupLogPath || null,
2593
+ setupStage: cluster.setupStage || null,
2594
+ failureInfo: cluster.failureInfo || null,
2595
+ runMode: cluster.runMode || null,
2596
+ },
2597
+ });
2555
2598
  }
2556
2599
 
2557
2600
  /**
@@ -4217,7 +4260,7 @@ Continue from where you left off. Review your previous output to understand what
4217
4260
 
4218
4261
  // Get issue context from ledger
4219
4262
  const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' });
4220
- const issueNumber = issueMsg?.content?.data?.number || 'unknown';
4263
+ const issueNumber = issueMsg?.content?.data?.issue_number || 'unknown';
4221
4264
  const issueTitle = issueMsg?.content?.data?.title || 'Implementation';
4222
4265
 
4223
4266
  // Generate the final prompt in one typed assembly pass. Issue values are
@@ -4290,11 +4333,16 @@ Continue from where you left off. Review your previous output to understand what
4290
4333
  * @returns {Object} Cluster status
4291
4334
  */
4292
4335
  getStatus(clusterId) {
4336
+ const finalRun = this._finalRuns.get(clusterId);
4337
+ if (finalRun) return finalRun.status;
4293
4338
  const cluster = this.clusters.get(clusterId);
4294
4339
  if (!cluster) {
4295
4340
  throw new Error(`Cluster ${clusterId} not found`);
4296
4341
  }
4342
+ return this._getLiveStatus(clusterId, cluster);
4343
+ }
4297
4344
 
4345
+ _getLiveStatus(clusterId, cluster) {
4298
4346
  // Detect zombie clusters: state=running but no backing process
4299
4347
  let state = cluster.state;
4300
4348
  let isZombie = false;
@@ -4378,6 +4426,10 @@ Continue from where you left off. Review your previous output to understand what
4378
4426
  return this.clusters.get(clusterId);
4379
4427
  }
4380
4428
 
4429
+ getFinalRun(clusterId) {
4430
+ return this._finalRuns.get(clusterId) || null;
4431
+ }
4432
+
4381
4433
  /**
4382
4434
  * Export cluster conversation
4383
4435
  * @param {String} clusterId - Cluster ID
@@ -0,0 +1,71 @@
1
+ const MAX_PR_BODY_LENGTH = 65536;
2
+
3
+ function normalizeIssueNumber(value) {
4
+ const candidate = typeof value === 'number' ? String(value) : value;
5
+ if (typeof candidate !== 'string') return 'unknown';
6
+ const trimmed = candidate.trim();
7
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) ? trimmed : 'unknown';
8
+ }
9
+
10
+ function normalizeIssueTitle(value) {
11
+ if (typeof value !== 'string' || value.trim() === '') return 'Implementation';
12
+ const normalized = [...value]
13
+ .map((character) => {
14
+ const codePoint = character.codePointAt(0);
15
+ return codePoint < 0x20 || codePoint === 0x7f ? ' ' : character;
16
+ })
17
+ .join('')
18
+ .trim();
19
+ return normalized || 'Implementation';
20
+ }
21
+
22
+ function resolveIssueContext(options = {}) {
23
+ const issueNumber = normalizeIssueNumber(options.issueNumber);
24
+ const issueTitle = normalizeIssueTitle(options.issueTitle);
25
+ const issueReference =
26
+ options.includeIssueReference === false || issueNumber === 'unknown'
27
+ ? ''
28
+ : `Closes #${issueNumber}`;
29
+ return { issueNumber, issueTitle, issueReference };
30
+ }
31
+
32
+ function normalizePrBodyTemplate(value) {
33
+ if (value === undefined || value === null) return null;
34
+ if (typeof value !== 'string') throw new TypeError('PR body template must be a string');
35
+ if (value.includes('\0')) throw new TypeError('PR body template must not contain NUL bytes');
36
+ if (value.length > MAX_PR_BODY_LENGTH) {
37
+ throw new TypeError(`PR body template must not exceed ${MAX_PR_BODY_LENGTH} characters`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Render a bounded PR body. Missing issue metadata expands to empty text so
44
+ * manual tasks never leak internal sentinel values into pull requests.
45
+ */
46
+ function renderPullRequestBody(template, options = {}) {
47
+ const issueContext = resolveIssueContext(options);
48
+ const normalizedTemplate = normalizePrBodyTemplate(template);
49
+ if (normalizedTemplate === null) return issueContext.issueReference;
50
+
51
+ const hasIssue = issueContext.issueNumber !== 'unknown';
52
+ const substitutions = {
53
+ '{{issue_number}}': hasIssue ? issueContext.issueNumber : '',
54
+ '{{issue_title}}': hasIssue ? issueContext.issueTitle : '',
55
+ '{{issue_reference}}': hasIssue ? issueContext.issueReference : '',
56
+ };
57
+ let rendered = normalizedTemplate;
58
+ for (const [token, replacement] of Object.entries(substitutions)) {
59
+ rendered = rendered.replaceAll(token, replacement);
60
+ }
61
+ if (rendered.length > MAX_PR_BODY_LENGTH) {
62
+ throw new TypeError(`Rendered PR body must not exceed ${MAX_PR_BODY_LENGTH} characters`);
63
+ }
64
+ return rendered;
65
+ }
66
+
67
+ module.exports = {
68
+ MAX_PR_BODY_LENGTH,
69
+ renderPullRequestBody,
70
+ resolveIssueContext,
71
+ };
package/src/preflight.js CHANGED
@@ -29,6 +29,10 @@ const {
29
29
  } = require('../lib/provider-names');
30
30
  const { detectGitContext } = require('../lib/git-remote-utils');
31
31
  const { readKeychainCredentials } = require('./claude-credentials');
32
+ const {
33
+ TASK_EXECUTION_CONTEXT_ENV,
34
+ resolveTaskExecutionContext,
35
+ } = require('./task-execution-context');
32
36
 
33
37
  /**
34
38
  * Validation result
@@ -164,6 +168,13 @@ function checkClaudeAuth() {
164
168
  ...(method && { method }),
165
169
  });
166
170
 
171
+ // Claude Code supports gateway bearer authentication independently of Anthropic API keys.
172
+ // This is the documented path for OpenRouter and enterprise LLM gateways, where
173
+ // ANTHROPIC_API_KEY is intentionally present but empty to avoid credential conflicts.
174
+ if (process.env.ANTHROPIC_AUTH_TOKEN) {
175
+ return authResult(true, null, 'auth_token');
176
+ }
177
+
167
178
  // Check for Bedrock bearer token (highest priority)
168
179
  if (process.env.AWS_BEARER_TOKEN_BEDROCK) {
169
180
  if (!process.env.AWS_REGION) {
@@ -481,7 +492,7 @@ function validateProviderExecutionSettings(providerName, settings, options) {
481
492
  return [];
482
493
  }
483
494
 
484
- const executionContext = options.requireDocker ? 'docker' : 'detached';
495
+ const executionContext = resolvePreflightExecutionContext(options);
485
496
  const error = metadata.settingsValidator(providerSettings, { executionContext });
486
497
  if (!error) return [];
487
498
 
@@ -519,11 +530,20 @@ function validateProvider(providerName, options) {
519
530
  return { errors: [...isolationErrors, ...result.errors], warnings: result.warnings };
520
531
  }
521
532
 
522
- const result = validateRegistryCliProvider(providerName);
533
+ const result = validateRegistryCliProvider(
534
+ providerName,
535
+ resolvePreflightExecutionContext(options)
536
+ );
523
537
  return { errors: [...isolationErrors, ...result.errors], warnings: result.warnings };
524
538
  }
525
539
 
526
- function validateRegistryCliProvider(providerName) {
540
+ function resolvePreflightExecutionContext(options) {
541
+ const executionContext =
542
+ options.executionContext ?? (options.requireDocker ? 'docker' : 'detached');
543
+ return resolveTaskExecutionContext({ [TASK_EXECUTION_CONTEXT_ENV]: executionContext });
544
+ }
545
+
546
+ function validateRegistryCliProvider(providerName, executionContext) {
527
547
  const metadata = getProviderMetadata(providerName);
528
548
  const { command } = resolveProviderCommand(providerName);
529
549
  const installSteps = metadata.installInstructions.split('\n').filter(Boolean);
@@ -537,7 +557,7 @@ function validateRegistryCliProvider(providerName) {
537
557
  }
538
558
 
539
559
  const { getProvider } = require('./providers');
540
- if (getProvider(providerName).isAvailable()) {
560
+ if (getProvider(providerName).isAvailable({ executionContext })) {
541
561
  return { errors: [], warnings: [] };
542
562
  }
543
563
 
@@ -642,6 +662,7 @@ function validateGitRequirement() {
642
662
  * @param {boolean} options.quiet - Suppress success messages
643
663
  * @param {string} options.claudeCommand - Custom Claude command (from settings)
644
664
  * @param {string} options.provider - Provider override
665
+ * @param {'host'|'detached'|'docker'|'benchmark'} options.executionContext - Provider execution boundary
645
666
  * @returns {ValidationResult}
646
667
  */
647
668
  async function runPreflight(options = {}) {
@@ -670,10 +691,14 @@ async function runPreflight(options = {}) {
670
691
  const providerName = normalizeProviderName(
671
692
  options.provider || settings.defaultProvider || getDefaultProviderId()
672
693
  );
694
+ const providerOptions = {
695
+ ...options,
696
+ executionContext: resolvePreflightExecutionContext(options),
697
+ };
673
698
 
674
- const providerResult = validateProvider(providerName, options);
699
+ const providerResult = validateProvider(providerName, providerOptions);
675
700
  errors.push(...providerResult.errors);
676
- errors.push(...validateProviderExecutionSettings(providerName, settings, options));
701
+ errors.push(...validateProviderExecutionSettings(providerName, settings, providerOptions));
677
702
  warnings.push(...providerResult.warnings);
678
703
 
679
704
  // 4. Check issue provider CLI (if required)
@@ -32,13 +32,13 @@ class RuntimeProvider extends BaseProvider {
32
32
  return this._adapter;
33
33
  }
34
34
 
35
- isAvailable() {
35
+ isAvailable(context = {}) {
36
36
  if (this._usesBundledRunner()) {
37
- return helper.probeRuntimeProviderCli(this.name).available;
37
+ return helper.probeRuntimeProviderCli(this.name, undefined, undefined, context).available;
38
38
  }
39
39
  const { command } = resolveProviderCommand(this.name);
40
40
  if (!commandExists(command)) return false;
41
- return helper.probeRuntimeProviderCli(this.name).available;
41
+ return helper.probeRuntimeProviderCli(this.name, undefined, undefined, context).available;
42
42
  }
43
43
 
44
44
  getCliPath() {