agentxchain 2.155.72 → 2.156.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +4 -8
  2. package/bin/agentxchain.js +22 -0
  3. package/dashboard/app.js +54 -0
  4. package/dashboard/components/org-audit-trail.js +161 -0
  5. package/dashboard/components/org-history.js +140 -0
  6. package/dashboard/components/org-overview.js +145 -0
  7. package/dashboard/components/org-runs.js +168 -0
  8. package/dashboard/index.html +4 -0
  9. package/package.json +4 -5
  10. package/scripts/migrate-node-test-to-vitest.mjs +98 -0
  11. package/scripts/release-postflight.sh +1 -1
  12. package/scripts/release-preflight.sh +5 -5
  13. package/scripts/verify-post-publish.sh +1 -1
  14. package/src/commands/ci-report.js +80 -0
  15. package/src/commands/doctor.js +22 -1
  16. package/src/commands/intake-approve.js +1 -0
  17. package/src/commands/replay.js +1 -0
  18. package/src/commands/run.js +50 -0
  19. package/src/commands/serve.js +64 -0
  20. package/src/commands/step.js +63 -1
  21. package/src/commands/verify.js +1 -0
  22. package/src/lib/adapters/local-cli-adapter.js +326 -2
  23. package/src/lib/api/execution-worker.js +192 -0
  24. package/src/lib/api/hosted-runner.js +494 -0
  25. package/src/lib/api/job-queue.js +152 -0
  26. package/src/lib/api/org-state-aggregator.js +428 -0
  27. package/src/lib/api/project-registry.js +148 -0
  28. package/src/lib/api/protocol-bridge.js +476 -0
  29. package/src/lib/approval-policy.js +12 -0
  30. package/src/lib/ci-reporter.js +188 -0
  31. package/src/lib/claude-local-auth.js +89 -1
  32. package/src/lib/connector-probe.js +21 -0
  33. package/src/lib/continuous-run.js +51 -3
  34. package/src/lib/dashboard/bridge-server.js +10 -5
  35. package/src/lib/dispatch-bundle.js +7 -3
  36. package/src/lib/dispatch-progress.js +9 -0
  37. package/src/lib/governed-state.js +5 -4
  38. package/src/lib/intake.js +32 -6
  39. package/src/lib/normalized-config.js +4 -0
  40. package/src/lib/recovery-classification.js +158 -0
  41. package/src/lib/report.js +91 -0
  42. package/src/lib/run-events.js +7 -1
  43. package/src/lib/schemas/agentxchain-config.schema.json +10 -0
  44. package/src/lib/scope-overlap.js +214 -0
  45. package/src/lib/turn-checkpoint.js +33 -3
  46. package/src/lib/turn-result-validator.js +47 -6
  47. package/src/lib/validation.js +11 -3
  48. package/src/lib/verification-replay.js +125 -4
  49. package/src/lib/vision-reader.js +16 -1
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import chalk from 'chalk';
25
- import { existsSync, readFileSync } from 'fs';
25
+ import { existsSync, readFileSync, unlinkSync } from 'fs';
26
26
  import { join } from 'path';
27
27
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
28
28
  import {
@@ -76,6 +76,8 @@ import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
76
76
  import { consumeNextApprovedIntent } from '../lib/intake.js';
77
77
  import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
78
78
  import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
79
+ import { getDispatchProgressRelativePath } from '../lib/dispatch-progress.js';
80
+ import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
79
81
 
80
82
  export async function stepCommand(opts) {
81
83
  const context = loadProjectContext();
@@ -192,6 +194,7 @@ export async function stepCommand(opts) {
192
194
  process.exit(1);
193
195
  }
194
196
 
197
+ guardResumeWorkerLiveness(root, targetTurn);
195
198
  skipAssignment = true;
196
199
  console.log(chalk.yellow(`Resuming active turn: ${targetTurn.turn_id}`));
197
200
  } else if (activeCount >= maxConcurrent) {
@@ -272,6 +275,7 @@ export async function stepCommand(opts) {
272
275
  process.exit(1);
273
276
  }
274
277
 
278
+ guardResumeWorkerLiveness(root, targetTurn);
275
279
  console.log(chalk.yellow(`Re-dispatching blocked turn: ${targetTurn.turn_id}`));
276
280
  const reactivated = reactivateGovernedRun(root, state, { via: 'step --resume', notificationConfig: config });
277
281
  if (!reactivated.ok) {
@@ -999,6 +1003,21 @@ export async function stepCommand(opts) {
999
1003
  }
1000
1004
 
1001
1005
  printAcceptSummary(acceptResult, config);
1006
+
1007
+ // Auto-checkpoint accepted turn so workspace is clean for next assignment
1008
+ if (!opts.noCheckpoint && existsSync(join(root, '.git'))) {
1009
+ const checkpoint = checkpointAcceptedTurn(root, { turnId: turn.turn_id });
1010
+ if (!checkpoint.ok) {
1011
+ console.log(` ${chalk.yellow('Checkpoint:')} accepted but checkpoint failed`);
1012
+ console.log(` ${chalk.dim('Error:')} ${checkpoint.error}`);
1013
+ console.log(` ${chalk.dim('Retry:')} agentxchain checkpoint-turn --turn ${turn.turn_id}`);
1014
+ console.log('');
1015
+ process.exit(1);
1016
+ }
1017
+ if (!checkpoint.skipped) {
1018
+ console.log(` ${chalk.dim('Checkpoint:')} ${checkpoint.checkpoint_sha}`);
1019
+ }
1020
+ }
1002
1021
  } else {
1003
1022
  // Reject and potentially retry
1004
1023
  console.log(chalk.yellow('Validation failed:'));
@@ -1039,6 +1058,49 @@ export async function stepCommand(opts) {
1039
1058
  }
1040
1059
  }
1041
1060
 
1061
+ function guardResumeWorkerLiveness(root, turn) {
1062
+ if (!turn || turn.worker_pid == null) {
1063
+ return;
1064
+ }
1065
+
1066
+ if (isWorkerAlive(turn.worker_pid)) {
1067
+ console.log(chalk.red(`Worker process (PID ${turn.worker_pid}) is still alive.`));
1068
+ console.log(chalk.dim('The previous dispatch appears to still be running.'));
1069
+ console.log(chalk.dim('Wait for it to complete, or kill it first, then retry.'));
1070
+ process.exit(1);
1071
+ }
1072
+
1073
+ console.log(chalk.yellow(`Detected crashed worker (PID ${turn.worker_pid}). Re-dispatching turn ${turn.turn_id}...`));
1074
+ cleanupStaleDispatchProgress(root, turn.turn_id);
1075
+ }
1076
+
1077
+ function isWorkerAlive(pid) {
1078
+ const numericPid = Number(pid);
1079
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
1080
+ return false;
1081
+ }
1082
+
1083
+ try {
1084
+ process.kill(numericPid, 0);
1085
+ return true;
1086
+ } catch {
1087
+ return false;
1088
+ }
1089
+ }
1090
+
1091
+ function cleanupStaleDispatchProgress(root, turnId) {
1092
+ const progressPath = join(root, getDispatchProgressRelativePath(turnId));
1093
+ if (!existsSync(progressPath)) {
1094
+ return;
1095
+ }
1096
+
1097
+ try {
1098
+ unlinkSync(progressPath);
1099
+ } catch {
1100
+ // Best-effort cleanup: resume can still proceed and rewrite fresh progress.
1101
+ }
1102
+ }
1103
+
1042
1104
  function printGhostTurnRecovery(ghostTurns) {
1043
1105
  console.log(chalk.red.bold('Ghost turn detected — subprocess never started.'));
1044
1106
  console.log('');
@@ -244,6 +244,7 @@ export async function verifyTurnCommand(turnId, opts = {}) {
244
244
  root,
245
245
  verification: turnResult.verification,
246
246
  timeoutMs,
247
+ allowCommandExecution: Boolean(opts.execute),
247
248
  }),
248
249
  };
249
250
 
@@ -34,7 +34,14 @@ import {
34
34
  getClaudeSubprocessAuthIssue,
35
35
  hasClaudeAuthenticationFailureText,
36
36
  hasClaudeNodeIncompatibilityText,
37
+ hasCodexAuthenticationFailureText,
38
+ hasRateLimitOutput,
37
39
  isClaudeLocalCliRuntime,
40
+ isCodexLocalCliRuntime,
41
+ isCursorLocalCliRuntime,
42
+ isWindsurfLocalCliRuntime,
43
+ isOpenCodeLocalCliRuntime,
44
+ parseRateLimitResetMs,
38
45
  resolveClaudeCompatibleNodeBinary,
39
46
  } from '../claude-local-auth.js';
40
47
 
@@ -49,6 +56,12 @@ const DIAGNOSTIC_ENV_KEYS = [
49
56
  const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
50
57
  const DEFAULT_STARTUP_WATCHDOG_MS = 180_000;
51
58
  const DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS = 10_000;
59
+ const DEFAULT_STARTUP_HEARTBEAT_MS = 30_000;
60
+ // RB-6: liveness heartbeat interval. Unlike the startup heartbeat (which stops
61
+ // at first output), this keeps the dispatch-progress `last_activity_at` fresh
62
+ // for the whole lifetime of an alive subprocess, so a long output-silent turn
63
+ // (e.g. one running a slow test suite) is not misclassified as a stalled turn.
64
+ const DEFAULT_LIVENESS_HEARTBEAT_MS = 60_000;
52
65
 
53
66
  /**
54
67
  * Launch a local CLI subprocess for a governed turn.
@@ -97,6 +110,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
97
110
  }
98
111
  const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
99
112
  const startupWatchdogKillGraceMs = resolveStartupWatchdogKillGraceMs(options.startupWatchdogKillGraceMs);
113
+ const startupHeartbeatMs = resolveStartupHeartbeatMs(config, runtime, options.startupHeartbeatMs);
114
+ const livenessHeartbeatMs = resolveLivenessHeartbeatMs(config, runtime, options.livenessHeartbeatMs);
100
115
 
101
116
  // Read the dispatch bundle prompt
102
117
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -116,8 +131,28 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
116
131
  return { ok: false, error: `Cannot resolve CLI command for runtime "${runtimeId}". Expected "command" field in runtime config.` };
117
132
  }
118
133
 
119
- // Compute timeout from deadline or default (20 minutes)
120
- const timeoutMs = turn.deadline_at
134
+ const compatibility = validateLocalCliCommandCompatibility({ command, args, runtimeId });
135
+ if (!compatibility.ok) {
136
+ const logs = [];
137
+ appendDiagnostic(logs, 'command_compatibility_failed', compatibility.diagnostic);
138
+ return {
139
+ ok: false,
140
+ blocked: true,
141
+ classified: {
142
+ error_class: compatibility.error_class,
143
+ recovery: compatibility.recovery,
144
+ },
145
+ error: compatibility.error,
146
+ logs,
147
+ };
148
+ }
149
+
150
+ // Compute timeout from explicit dispatch deadline, turn deadline, or default (20 minutes).
151
+ const timeoutMs = options.dispatchTimeoutMs != null
152
+ ? options.dispatchTimeoutMs
153
+ : options.dispatchDeadlineAt
154
+ ? Math.max(0, new Date(options.dispatchDeadlineAt).getTime() - Date.now())
155
+ : turn.deadline_at
121
156
  ? Math.max(0, new Date(turn.deadline_at).getTime() - Date.now())
122
157
  : 1200000;
123
158
 
@@ -201,6 +236,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
201
236
  let firstOutputLatencyMs = null;
202
237
  let startupWatchdog = null;
203
238
  let startupSigkillHandle = null;
239
+ let startupHeartbeat = null;
240
+ let livenessHeartbeat = null;
204
241
  let startupTimedOut = false;
205
242
  let startupFailureType = null;
206
243
  let stdoutBytes = 0;
@@ -210,6 +247,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
210
247
  const settle = (result) => {
211
248
  if (settled) return;
212
249
  settled = true;
250
+ clearLivenessHeartbeat();
213
251
  resolve(result);
214
252
  };
215
253
 
@@ -224,6 +262,85 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
224
262
  }
225
263
  };
226
264
 
265
+ const clearStartupHeartbeat = () => {
266
+ if (startupHeartbeat) {
267
+ clearInterval(startupHeartbeat);
268
+ startupHeartbeat = null;
269
+ }
270
+ };
271
+
272
+ const clearLivenessHeartbeat = () => {
273
+ if (livenessHeartbeat) {
274
+ clearInterval(livenessHeartbeat);
275
+ livenessHeartbeat = null;
276
+ }
277
+ };
278
+
279
+ // RB-6: keep `last_activity_at` fresh for the full lifetime of an alive
280
+ // subprocess (NOT cleared at first output, unlike the startup heartbeat), so
281
+ // a long output-silent turn — e.g. one running a slow test suite — is not
282
+ // misclassified as stalled by the stale-turn watchdog. Genuine hangs are
283
+ // still bounded by the per-turn hard timeout.
284
+ const armLivenessHeartbeat = () => {
285
+ if (livenessHeartbeat || !(livenessHeartbeatMs > 0 && Number.isFinite(livenessHeartbeatMs))) {
286
+ return;
287
+ }
288
+ livenessHeartbeat = setInterval(() => {
289
+ if (settled) {
290
+ clearLivenessHeartbeat();
291
+ return;
292
+ }
293
+ const payload = {
294
+ liveness_heartbeat_ms: livenessHeartbeatMs,
295
+ pid: child.pid ?? null,
296
+ spawn_confirmed_at: spawnConfirmedAt,
297
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
298
+ first_output_at: firstOutputAt,
299
+ stdout_bytes: stdoutBytes,
300
+ stderr_bytes: stderrBytes,
301
+ };
302
+ appendDiagnostic(logs, 'liveness_heartbeat', payload);
303
+ if (options.onLivenessHeartbeat) {
304
+ try {
305
+ options.onLivenessHeartbeat(payload);
306
+ } catch {}
307
+ }
308
+ }, livenessHeartbeatMs);
309
+ if (typeof livenessHeartbeat.unref === 'function') {
310
+ livenessHeartbeat.unref();
311
+ }
312
+ };
313
+
314
+ const armStartupHeartbeat = () => {
315
+ if (startupHeartbeat || !(startupHeartbeatMs > 0 && Number.isFinite(startupHeartbeatMs))) {
316
+ return;
317
+ }
318
+ startupHeartbeat = setInterval(() => {
319
+ if (firstOutputAt || settled) {
320
+ clearStartupHeartbeat();
321
+ return;
322
+ }
323
+ const payload = {
324
+ startup_heartbeat_ms: startupHeartbeatMs,
325
+ startup_watchdog_ms: startupWatchdogMs,
326
+ pid: child.pid ?? null,
327
+ spawn_confirmed_at: spawnConfirmedAt,
328
+ elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
329
+ stdout_bytes: stdoutBytes,
330
+ stderr_bytes: stderrBytes,
331
+ };
332
+ appendDiagnostic(logs, 'startup_heartbeat', payload);
333
+ if (options.onStartupHeartbeat) {
334
+ try {
335
+ options.onStartupHeartbeat(payload);
336
+ } catch {}
337
+ }
338
+ }, startupHeartbeatMs);
339
+ if (typeof startupHeartbeat.unref === 'function') {
340
+ startupHeartbeat.unref();
341
+ }
342
+ };
343
+
227
344
  const armStartupWatchdog = () => {
228
345
  if (startupWatchdog || !(startupWatchdogMs > 0 && Number.isFinite(startupWatchdogMs))) {
229
346
  return;
@@ -269,6 +386,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
269
386
  firstOutputStream = stream;
270
387
  firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
271
388
  clearStartupWatchdog();
389
+ clearStartupHeartbeat();
272
390
  appendDiagnostic(logs, 'first_output', {
273
391
  at: firstOutputAt,
274
392
  stream,
@@ -296,6 +414,8 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
296
414
  } catch {}
297
415
  }
298
416
  armStartupWatchdog();
417
+ armStartupHeartbeat();
418
+ armLivenessHeartbeat();
299
419
  });
300
420
 
301
421
  // Collect stdout/stderr
@@ -369,6 +489,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
369
489
  const onAbort = () => {
370
490
  logs.push('[adapter] Abort signal received. Sending SIGTERM.');
371
491
  clearStartupWatchdog();
492
+ clearStartupHeartbeat();
372
493
  clearTimeout(timeoutHandle);
373
494
  clearTimeout(sigkillHandle);
374
495
  clearTimeout(abortSigkillHandle);
@@ -389,6 +510,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
389
510
  // Process exit
390
511
  child.on('close', (exitCode, killSignal) => {
391
512
  clearStartupWatchdog();
513
+ clearStartupHeartbeat();
392
514
  clearTimeout(timeoutHandle);
393
515
  clearTimeout(sigkillHandle);
394
516
  clearTimeout(abortSigkillHandle);
@@ -453,6 +575,22 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
453
575
  error: `Claude local_cli authentication failed. ${recovery}`,
454
576
  logs,
455
577
  });
578
+ } else if (isCodexLocalCliRuntime(runtime) && hasCodexAuthFailureOutput(logs)) {
579
+ const recovery = 'Refresh OpenAI credentials before resuming: export a valid OPENAI_API_KEY, then run agentxchain step --resume.';
580
+ settle({
581
+ ok: false,
582
+ blocked: true,
583
+ exitCode,
584
+ timedOut: false,
585
+ aborted: false,
586
+ firstOutputAt,
587
+ classified: {
588
+ error_class: 'codex_auth_failed',
589
+ recovery,
590
+ },
591
+ error: `Codex local_cli authentication failed. ${recovery}`,
592
+ logs,
593
+ });
456
594
  } else if (isClaudeLocalCliRuntime(runtime) && hasClaudeNodeRuntimeIncompatibilityOutput(logs)) {
457
595
  const recovery = 'Run AgentXchain with Node.js 20.5+ available to the Claude local_cli runtime, then resume continuous mode.';
458
596
  settle({
@@ -469,6 +607,26 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
469
607
  error: `Claude local_cli runtime is using an incompatible Node.js version. ${recovery}`,
470
608
  logs,
471
609
  });
610
+ } else if (hasRateLimitOutput(logs)) {
611
+ const resetMs = parseRateLimitResetMs(logs.join('\n'));
612
+ const resetDetail = resetMs ? ` Resets in ${Math.ceil(resetMs / 1000)}s.` : '';
613
+ const recovery = `Provider rate-limited.${resetDetail} Will retry with backoff.`;
614
+ settle({
615
+ ok: false,
616
+ blocked: true,
617
+ exitCode,
618
+ timedOut: false,
619
+ aborted: false,
620
+ firstOutputAt,
621
+ classified: {
622
+ error_class: 'rate_limited',
623
+ retryable: true,
624
+ recovery,
625
+ reset_ms: resetMs,
626
+ },
627
+ error: `Subprocess rate-limited by provider. ${recovery}`,
628
+ logs,
629
+ });
472
630
  } else if (startupTimedOut) {
473
631
  settle({
474
632
  ok: false,
@@ -524,6 +682,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
524
682
 
525
683
  child.on('error', (err) => {
526
684
  clearStartupWatchdog();
685
+ clearStartupHeartbeat();
527
686
  clearTimeout(timeoutHandle);
528
687
  clearTimeout(sigkillHandle);
529
688
  clearTimeout(abortSigkillHandle);
@@ -653,6 +812,32 @@ function resolveStartupWatchdogMs(config, runtime) {
653
812
  return DEFAULT_STARTUP_WATCHDOG_MS;
654
813
  }
655
814
 
815
+ function resolveStartupHeartbeatMs(config, runtime, override) {
816
+ if (Number.isInteger(override) && override > 0) {
817
+ return override;
818
+ }
819
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_heartbeat_ms) && runtime.startup_heartbeat_ms > 0) {
820
+ return runtime.startup_heartbeat_ms;
821
+ }
822
+ if (Number.isInteger(config?.run_loop?.startup_heartbeat_ms) && config.run_loop.startup_heartbeat_ms > 0) {
823
+ return config.run_loop.startup_heartbeat_ms;
824
+ }
825
+ return DEFAULT_STARTUP_HEARTBEAT_MS;
826
+ }
827
+
828
+ function resolveLivenessHeartbeatMs(config, runtime, override) {
829
+ if (Number.isInteger(override) && override > 0) {
830
+ return override;
831
+ }
832
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.liveness_heartbeat_ms) && runtime.liveness_heartbeat_ms > 0) {
833
+ return runtime.liveness_heartbeat_ms;
834
+ }
835
+ if (Number.isInteger(config?.run_loop?.liveness_heartbeat_ms) && config.run_loop.liveness_heartbeat_ms > 0) {
836
+ return config.run_loop.liveness_heartbeat_ms;
837
+ }
838
+ return DEFAULT_LIVENESS_HEARTBEAT_MS;
839
+ }
840
+
656
841
  function resolveStartupWatchdogKillGraceMs(value) {
657
842
  if (Number.isInteger(value) && value >= 0) {
658
843
  return value;
@@ -660,6 +845,137 @@ function resolveStartupWatchdogKillGraceMs(value) {
660
845
  return DEFAULT_STARTUP_WATCHDOG_SIGKILL_GRACE_MS;
661
846
  }
662
847
 
848
+ function validateLocalCliCommandCompatibility({ command, args = [], runtimeId = null }) {
849
+ const tokens = [command, ...args].filter((token) => typeof token === 'string');
850
+ const binaryName = command ? command.split('/').filter(Boolean).pop() || command : '';
851
+ const runtimeShape = { command: tokens };
852
+ const outputFormatIndex = tokens.findIndex((token) => token === '--output-format');
853
+ const outputFormatValue = outputFormatIndex >= 0 ? tokens[outputFormatIndex + 1] : null;
854
+ const usesStreamJson = tokens.includes('--output-format=stream-json')
855
+ || outputFormatValue === 'stream-json';
856
+ const usesPrint = tokens.includes('--print') || tokens.includes('-p');
857
+ const hasVerbose = tokens.includes('--verbose');
858
+ const usesCodex = isCodexLocalCliRuntime(runtimeShape);
859
+ const usesCodexExec = tokens.includes('exec');
860
+ const hasCodexJson = tokens.includes('--json');
861
+
862
+ if (binaryName === 'claude' && usesPrint && usesStreamJson && !hasVerbose) {
863
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Claude local_cli runtime';
864
+ const recovery = `${runtimeLabel} uses "claude --print --output-format stream-json" without "--verbose". Add "--verbose" to the command array before dispatching again.`;
865
+ return {
866
+ ok: false,
867
+ error_class: 'local_cli_command_incompatible',
868
+ recovery,
869
+ error: recovery,
870
+ diagnostic: {
871
+ runtime_id: runtimeId,
872
+ binary: binaryName,
873
+ rule: 'claude_print_stream_json_requires_verbose',
874
+ has_print: usesPrint,
875
+ has_stream_json: usesStreamJson,
876
+ has_verbose: hasVerbose,
877
+ recovery,
878
+ },
879
+ };
880
+ }
881
+
882
+ if (usesCodex && !usesCodexExec) {
883
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
884
+ const recovery = `${runtimeLabel} uses "codex" without the "exec" subcommand. Governed local runs require "codex exec" for non-interactive execution.`;
885
+ return {
886
+ ok: false,
887
+ error_class: 'local_cli_command_incompatible',
888
+ recovery,
889
+ error: recovery,
890
+ diagnostic: {
891
+ runtime_id: runtimeId,
892
+ binary: binaryName,
893
+ rule: 'codex_requires_exec',
894
+ has_exec: usesCodexExec,
895
+ recovery,
896
+ },
897
+ };
898
+ }
899
+
900
+ if (usesCodex && usesCodexExec && !hasCodexJson) {
901
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Codex local_cli runtime';
902
+ const recovery = `${runtimeLabel} uses "codex exec" without "--json". Add "--json" so subprocess output is machine-readable diagnostics while turn results remain staged on disk.`;
903
+ return {
904
+ ok: false,
905
+ error_class: 'local_cli_command_incompatible',
906
+ recovery,
907
+ error: recovery,
908
+ diagnostic: {
909
+ runtime_id: runtimeId,
910
+ binary: binaryName,
911
+ rule: 'codex_exec_requires_json',
912
+ has_exec: usesCodexExec,
913
+ has_json: hasCodexJson,
914
+ recovery,
915
+ },
916
+ };
917
+ }
918
+
919
+ const usesCursor = isCursorLocalCliRuntime(runtimeShape);
920
+ if (usesCursor && !tokens.includes('--background-agent') && !tokens.includes('agent')) {
921
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Cursor local_cli runtime';
922
+ const recovery = `${runtimeLabel} uses "cursor" without a background agent flag. Governed local runs require Cursor's agent or background-agent mode for non-interactive execution.`;
923
+ return {
924
+ ok: false,
925
+ error_class: 'local_cli_command_incompatible',
926
+ recovery,
927
+ error: recovery,
928
+ diagnostic: {
929
+ runtime_id: runtimeId,
930
+ binary: binaryName,
931
+ rule: 'cursor_requires_agent_mode',
932
+ has_agent_flag: false,
933
+ recovery,
934
+ },
935
+ };
936
+ }
937
+
938
+ const usesWindsurf = isWindsurfLocalCliRuntime(runtimeShape);
939
+ if (usesWindsurf && !tokens.includes('--agent')) {
940
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'Windsurf local_cli runtime';
941
+ const recovery = `${runtimeLabel} uses "windsurf" without an agent flag. Governed local runs require Windsurf's --agent mode for non-interactive execution.`;
942
+ return {
943
+ ok: false,
944
+ error_class: 'local_cli_command_incompatible',
945
+ recovery,
946
+ error: recovery,
947
+ diagnostic: {
948
+ runtime_id: runtimeId,
949
+ binary: binaryName,
950
+ rule: 'windsurf_requires_agent_mode',
951
+ has_agent_flag: false,
952
+ recovery,
953
+ },
954
+ };
955
+ }
956
+
957
+ const usesOpenCode = isOpenCodeLocalCliRuntime(runtimeShape);
958
+ if (usesOpenCode && !tokens.includes('--non-interactive')) {
959
+ const runtimeLabel = runtimeId ? `Runtime "${runtimeId}"` : 'OpenCode local_cli runtime';
960
+ const recovery = `${runtimeLabel} uses "opencode" without --non-interactive. Governed local runs require OpenCode's non-interactive mode for unattended execution.`;
961
+ return {
962
+ ok: false,
963
+ error_class: 'local_cli_command_incompatible',
964
+ recovery,
965
+ error: recovery,
966
+ diagnostic: {
967
+ runtime_id: runtimeId,
968
+ binary: binaryName,
969
+ rule: 'opencode_requires_non_interactive',
970
+ has_non_interactive_flag: false,
971
+ recovery,
972
+ },
973
+ };
974
+ }
975
+
976
+ return { ok: true };
977
+ }
978
+
663
979
  /**
664
980
  * Check if the staged result file exists and has meaningful content.
665
981
  * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
@@ -687,6 +1003,11 @@ function hasClaudeAuthFailureOutput(logs) {
687
1003
  return logs.some((line) => hasClaudeAuthenticationFailureText(line));
688
1004
  }
689
1005
 
1006
+ function hasCodexAuthFailureOutput(logs) {
1007
+ if (!Array.isArray(logs)) return false;
1008
+ return logs.some((line) => hasCodexAuthenticationFailureText(line));
1009
+ }
1010
+
690
1011
  function hasClaudeNodeRuntimeIncompatibilityOutput(logs) {
691
1012
  if (!Array.isArray(logs)) return false;
692
1013
  return hasClaudeNodeIncompatibilityText(logs.join('\n'));
@@ -777,3 +1098,6 @@ function appendDiagnosticExcerpt(existing, chunk, limit) {
777
1098
  export { resolveCommand };
778
1099
  export { resolvePromptTransport };
779
1100
  export { resolveStartupWatchdogMs };
1101
+ export { resolveStartupHeartbeatMs };
1102
+ export { resolveLivenessHeartbeatMs };
1103
+ export { validateLocalCliCommandCompatibility };