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
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Org Runs component — renders cross-project run list as a filterable data table.
3
+ *
4
+ * Pure render function: takes data, returns HTML string.
5
+ * Follows the same pattern as ledger.js filter bar and run-history.js table.
6
+ */
7
+
8
+ function esc(str) {
9
+ if (str == null) return '';
10
+ return String(str)
11
+ .replace(/&/g, '&')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function badge(label, color = 'var(--text-dim)') {
19
+ return `<span class="badge" style="color:${color};border-color:${color}">${esc(label)}</span>`;
20
+ }
21
+
22
+ function statusBadge(status) {
23
+ switch (status) {
24
+ case 'active': return badge('active', 'var(--green)');
25
+ case 'blocked': return badge('blocked', 'var(--yellow)');
26
+ case 'completed': return badge('completed', 'var(--green)');
27
+ case 'failed': return badge('failed', 'var(--red)');
28
+ default: return badge(status || 'unknown', 'var(--text-dim)');
29
+ }
30
+ }
31
+
32
+ function phaseBadge(phase) {
33
+ switch (phase) {
34
+ case 'planning': return badge('planning', 'var(--accent)');
35
+ case 'implementation': return badge('implementation', '#38bdf8');
36
+ case 'qa': return badge('qa', 'var(--yellow)');
37
+ default: return badge(phase || 'unknown', 'var(--text-dim)');
38
+ }
39
+ }
40
+
41
+ function formatUsd(amount) {
42
+ return '$' + (amount || 0).toFixed(2);
43
+ }
44
+
45
+ function relativeTime(isoString) {
46
+ if (!isoString) return '—';
47
+ try {
48
+ const dt = new Date(isoString);
49
+ const now = Date.now();
50
+ const diffMs = now - dt.getTime();
51
+ if (diffMs < 0) return esc(isoString);
52
+ const seconds = Math.floor(diffMs / 1000);
53
+ if (seconds < 60) return `${seconds}s ago`;
54
+ const minutes = Math.floor(seconds / 60);
55
+ if (minutes < 60) return `${minutes}m ago`;
56
+ const hours = Math.floor(minutes / 60);
57
+ if (hours < 24) return `${hours}h ago`;
58
+ const days = Math.floor(hours / 24);
59
+ return `${days}d ago`;
60
+ } catch {
61
+ return esc(isoString);
62
+ }
63
+ }
64
+
65
+ function truncateRunId(runId) {
66
+ if (!runId) return '—';
67
+ return runId.length > 20 ? runId.slice(0, 20) + '...' : runId;
68
+ }
69
+
70
+ function renderFilterBar(runs, filter) {
71
+ const projectSet = new Set(runs.map(r => r.project_name));
72
+ const projectOptions = ['all', ...Array.from(projectSet).sort()];
73
+ const phaseOptions = ['all', 'planning', 'implementation', 'qa'];
74
+ const statusOptions = ['all', 'active', 'blocked', 'completed', 'failed'];
75
+
76
+ function options(list, selected) {
77
+ return list.map(v =>
78
+ `<option value="${esc(v)}"${v === selected ? ' selected' : ''}>${esc(v)}</option>`
79
+ ).join('');
80
+ }
81
+
82
+ return `<div class="filter-bar">
83
+ <label class="filter-control">
84
+ Project
85
+ <select data-view-control="org-runs-project">
86
+ ${options(projectOptions, filter?.project || 'all')}
87
+ </select>
88
+ </label>
89
+ <label class="filter-control">
90
+ Phase
91
+ <select data-view-control="org-runs-phase">
92
+ ${options(phaseOptions, filter?.phase || 'all')}
93
+ </select>
94
+ </label>
95
+ <label class="filter-control">
96
+ Status
97
+ <select data-view-control="org-runs-status">
98
+ ${options(statusOptions, filter?.status || 'all')}
99
+ </select>
100
+ </label>
101
+ </div>`;
102
+ }
103
+
104
+ function applyFilter(runs, filter) {
105
+ let filtered = runs;
106
+ if (filter?.project && filter.project !== 'all') {
107
+ filtered = filtered.filter(r => r.project_name === filter.project);
108
+ }
109
+ if (filter?.phase && filter.phase !== 'all') {
110
+ filtered = filtered.filter(r => r.phase === filter.phase);
111
+ }
112
+ if (filter?.status && filter.status !== 'all') {
113
+ filtered = filtered.filter(r => r.status === filter.status);
114
+ }
115
+ return filtered;
116
+ }
117
+
118
+ /**
119
+ * @param {{ orgRuns: object, liveMeta: object, filter?: object }} data
120
+ * @returns {string} HTML string
121
+ */
122
+ export function render(data) {
123
+ const runsData = data?.orgRuns?.data || data?.orgRuns || null;
124
+ const runs = Array.isArray(runsData) ? runsData : (runsData?.data || []);
125
+ const filter = data?.filter || {};
126
+
127
+ if (!runs.length && !filter.project && !filter.phase && !filter.status) {
128
+ return `<div class="placeholder">
129
+ <h2>Org Runs</h2>
130
+ <p>No cross-project runs available. Register projects and start governed runs.</p>
131
+ </div>`;
132
+ }
133
+
134
+ const filtered = applyFilter(runs, filter);
135
+
136
+ const tableRows = filtered.map(r => `<tr>
137
+ <td>${esc(r.project_name)}</td>
138
+ <td class="mono">${esc(truncateRunId(r.run_id))}</td>
139
+ <td>${phaseBadge(r.phase)}</td>
140
+ <td>${statusBadge(r.status)}</td>
141
+ <td>${r.turns_completed || 0}</td>
142
+ <td>${formatUsd(r.cost_usd)}</td>
143
+ <td>${relativeTime(r.started_at)}</td>
144
+ <td>${relativeTime(r.updated_at)}</td>
145
+ </tr>`).join('');
146
+
147
+ return `<div class="section">
148
+ <h3>Org Runs (${filtered.length})</h3>
149
+ ${renderFilterBar(runs, filter)}
150
+ <table class="data-table">
151
+ <thead>
152
+ <tr>
153
+ <th>Project</th>
154
+ <th>Run ID</th>
155
+ <th>Phase</th>
156
+ <th>Status</th>
157
+ <th>Turns</th>
158
+ <th>Cost</th>
159
+ <th>Started</th>
160
+ <th>Updated</th>
161
+ </tr>
162
+ </thead>
163
+ <tbody>
164
+ ${tableRows || '<tr><td colspan="8" style="text-align:center;color:var(--text-dim)">No runs match filters</td></tr>'}
165
+ </tbody>
166
+ </table>
167
+ </div>`;
168
+ }
@@ -395,6 +395,10 @@
395
395
  </header>
396
396
  <div class="action-banner" id="action-banner"></div>
397
397
  <nav>
398
+ <a href="#org-overview">Org Overview</a>
399
+ <a href="#org-runs">Org Runs</a>
400
+ <a href="#org-history">Org History</a>
401
+ <a href="#org-audit-trail">Audit Trail</a>
398
402
  <a href="#initiative">Initiative</a>
399
403
  <a href="#cross-repo">Cross-Repo</a>
400
404
  <a href="#timeline" class="active">Timeline</a>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.155.72",
3
+ "version": "2.156.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,8 @@
26
26
  ],
27
27
  "scripts": {
28
28
  "dev": "node bin/agentxchain.js",
29
- "test": "npm run test:vitest && npm run test:node",
30
- "test:vitest": "vitest run --reporter=verbose",
31
- "test:beta": "node --test test/beta-tester-scenarios/*.test.js",
32
- "test:node": "node --test --test-timeout=60000 --test-concurrency=4 test/*.test.js test/beta-tester-scenarios/*.test.js",
29
+ "test": "vitest run --reporter=verbose",
30
+ "test:watch": "vitest --reporter=verbose",
33
31
  "preflight:release": "bash scripts/release-preflight.sh",
34
32
  "preflight:release:strict": "bash scripts/release-preflight.sh --strict",
35
33
  "check:release-alignment": "node scripts/check-release-alignment.mjs",
@@ -77,6 +75,7 @@
77
75
  "node": ">=18.17.0 || >=20.5.0"
78
76
  },
79
77
  "devDependencies": {
78
+ "js-yaml": "^4.1.1",
80
79
  "vitest": "^4.1.2"
81
80
  }
82
81
  }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
7
+ const CLI_ROOT = join(SCRIPT_DIR, '..');
8
+ const TEST_ROOT = process.argv[2] ? resolve(process.argv[2]) : join(CLI_ROOT, 'test');
9
+
10
+ function walk(dir) {
11
+ const entries = readdirSync(dir, { withFileTypes: true });
12
+ const files = [];
13
+ for (const entry of entries) {
14
+ const absolute = join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ files.push(...walk(absolute));
17
+ } else if (entry.isFile() && entry.name.endsWith('.test.js')) {
18
+ files.push(absolute);
19
+ }
20
+ }
21
+ return files;
22
+ }
23
+
24
+ function parseNamedSpecifiers(specifiers) {
25
+ return specifiers
26
+ .split(',')
27
+ .map((part) => part.trim())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ function migrateNamedSpecifiers(specifiers) {
32
+ let importsBefore = false;
33
+ let importsAfter = false;
34
+ const migrated = parseNamedSpecifiers(specifiers).map((specifier) => {
35
+ if (specifier === 'before') {
36
+ importsBefore = true;
37
+ return 'beforeAll';
38
+ }
39
+ if (specifier === 'after') {
40
+ importsAfter = true;
41
+ return 'afterAll';
42
+ }
43
+ return specifier;
44
+ });
45
+ return {
46
+ importsBefore,
47
+ importsAfter,
48
+ importLine: `import { ${migrated.join(', ')} } from 'vitest';`,
49
+ };
50
+ }
51
+
52
+ function migrateSource(source) {
53
+ let importsBefore = false;
54
+ let importsAfter = false;
55
+ let changed = false;
56
+
57
+ let next = source.replace(
58
+ /^import\s+test\s+from\s+['"]node:test['"];$/gm,
59
+ () => {
60
+ changed = true;
61
+ return "import { test } from 'vitest';";
62
+ },
63
+ );
64
+
65
+ next = next.replace(
66
+ /^import\s+\{([^}]+)\}\s+from\s+['"]node:test['"];$/gm,
67
+ (_match, specifiers) => {
68
+ const migrated = migrateNamedSpecifiers(specifiers);
69
+ importsBefore ||= migrated.importsBefore;
70
+ importsAfter ||= migrated.importsAfter;
71
+ changed = true;
72
+ return migrated.importLine;
73
+ },
74
+ );
75
+
76
+ if (importsBefore) {
77
+ next = next.replace(/\bbefore(?=\s*\()/g, 'beforeAll');
78
+ }
79
+ if (importsAfter) {
80
+ next = next.replace(/\bafter(?=\s*\()/g, 'afterAll');
81
+ }
82
+
83
+ return { changed, source: next };
84
+ }
85
+
86
+ const changedFiles = [];
87
+ for (const file of walk(TEST_ROOT)) {
88
+ const source = readFileSync(file, 'utf8');
89
+ const migrated = migrateSource(source);
90
+ if (!migrated.changed || migrated.source === source) continue;
91
+ writeFileSync(file, migrated.source);
92
+ changedFiles.push(relative(CLI_ROOT, file));
93
+ }
94
+
95
+ console.log(`Migrated ${changedFiles.length} test files from node:test to vitest.`);
96
+ for (const file of changedFiles) {
97
+ console.log(file);
98
+ }
@@ -78,7 +78,7 @@ FAIL=0
78
78
  TARBALL_URL=""
79
79
  REGISTRY_CHECKSUM=""
80
80
  PACKAGE_NAME="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).name)")"
81
- PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const names = Object.keys(pkg.bin || {}); if (names.length !== 1) { console.error('package.json bin must declare exactly one entry'); process.exit(1); } console.log(names[0]);")"
81
+ PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const bins = pkg.bin || {}; if (bins[pkg.name]) { console.log(pkg.name); process.exit(0); } const names = Object.keys(bins); if (names.length === 1) { console.log(names[0]); process.exit(0); } console.error('package.json bin must declare the primary package bin'); process.exit(1);")"
82
82
  RUNNER_INTERFACE_VERSION_EXPECTED="$(node --input-type=module -e "import('./src/lib/runner-interface.js').then((mod) => { console.log(mod.RUNNER_INTERFACE_VERSION); }).catch((error) => { console.error(error.message); process.exit(1); });")"
83
83
  ADAPTER_INTERFACE_VERSION_EXPECTED="$(node --input-type=module -e "import('./src/lib/adapter-interface.js').then((mod) => { console.log(mod.ADAPTER_INTERFACE_VERSION); }).catch((error) => { console.error(error.message); process.exit(1); });")"
84
84
 
@@ -179,15 +179,15 @@ if [[ "$PUBLISH_GATE" -eq 1 ]]; then
179
179
  fail "No beta-tester scenario tests found for release-gate verification"
180
180
  TEST_OUTPUT=""
181
181
  TEST_STATUS=1
182
- elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 node --test "${GATE_TEST_ARGS[@]}"; then
182
+ elif run_and_capture TEST_OUTPUT env AGENTXCHAIN_RELEASE_TARGET_VERSION="${TARGET_VERSION}" AGENTXCHAIN_RELEASE_PREFLIGHT=1 npm test -- "${GATE_TEST_ARGS[@]}"; then
183
183
  TEST_STATUS=0
184
184
  else
185
185
  TEST_STATUS=$?
186
186
  fi
187
- NODE_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ tests / { print $3; exit }')"
188
- NODE_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^ℹ fail / { print $3; exit }')"
189
- if [ "$TEST_STATUS" -eq 0 ] && [ "${NODE_FAIL:-0}" = "0" ]; then
190
- pass "${NODE_PASS:-?} release-gate tests passed, 0 failures"
187
+ VITEST_PASS="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+passed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
188
+ VITEST_FAIL="$(printf '%s\n' "$TEST_OUTPUT" | awk '/^[[:space:]]*Tests[[:space:]]+[0-9]+[[:space:]]+failed/ { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+$/) { print $i; exit } }')"
189
+ if [ "$TEST_STATUS" -eq 0 ] && [ "${VITEST_FAIL:-0}" = "0" ]; then
190
+ pass "${VITEST_PASS:-?} release-gate tests passed, 0 failures"
191
191
  else
192
192
  fail "Release-gate tests failed"
193
193
  printf '%s\n' "$TEST_OUTPUT" | tail -20
@@ -21,7 +21,7 @@ TARGET_VERSION=""
21
21
 
22
22
  FORMULA_PATH="${CLI_DIR}/homebrew/agentxchain.rb"
23
23
  PACKAGE_NAME="$(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).name)")"
24
- PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const names = Object.keys(pkg.bin || {}); if (names.length !== 1) { console.error('package.json bin must declare exactly one entry'); process.exit(1); } console.log(names[0]);")"
24
+ PACKAGE_BIN_NAME="$(node -e "const pkg = JSON.parse(require('fs').readFileSync('package.json','utf8')); if (typeof pkg.bin === 'string') { console.log(pkg.name); process.exit(0); } const bins = pkg.bin || {}; if (bins[pkg.name]) { console.log(pkg.name); process.exit(0); } const names = Object.keys(bins); if (names.length === 1) { console.log(names[0]); process.exit(0); } console.error('package.json bin must declare the primary package bin'); process.exit(1);")"
25
25
 
26
26
  formula_url() {
27
27
  local formula_path="$1"
@@ -0,0 +1,80 @@
1
+ import { buildRunExport } from '../lib/export.js';
2
+ import { loadExportArtifact } from '../lib/export-verifier.js';
3
+ import { buildGovernanceReport } from '../lib/report.js';
4
+ import {
5
+ deriveCIExitCode,
6
+ detectCIEnvironment,
7
+ formatGitHubAnnotations,
8
+ formatJUnitXml,
9
+ writeGitHubOutputVars,
10
+ } from '../lib/ci-reporter.js';
11
+
12
+ export async function ciReportCommand(options) {
13
+ const cwd = process.cwd();
14
+ const format = options.format || 'auto';
15
+
16
+ // Step 1: Build or load the governance report
17
+ let report;
18
+ if (options.input && options.input !== '-') {
19
+ const loaded = loadExportArtifact(options.input, cwd);
20
+ if (!loaded.ok) {
21
+ console.error(loaded.error);
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ const result = buildGovernanceReport(loaded.artifact, { input: loaded.input });
26
+ report = result.report;
27
+ } else {
28
+ // Build from current project
29
+ const exportResult = buildRunExport(cwd);
30
+ if (!exportResult.ok) {
31
+ console.error(exportResult.error);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ const result = buildGovernanceReport(exportResult.export, { input: 'current-project' });
36
+ report = result.report;
37
+ }
38
+
39
+ // Step 2: Detect CI environment and resolve format
40
+ const ci = detectCIEnvironment();
41
+ let resolvedFormat = format;
42
+ if (resolvedFormat === 'auto') {
43
+ if (ci?.provider === 'github_actions') {
44
+ resolvedFormat = 'github-actions';
45
+ } else {
46
+ resolvedFormat = 'json';
47
+ }
48
+ }
49
+
50
+ // Step 3: Emit formatted output
51
+ switch (resolvedFormat) {
52
+ case 'github-actions': {
53
+ const annotations = formatGitHubAnnotations(report);
54
+ console.log(annotations);
55
+
56
+ // Write output variables if $GITHUB_OUTPUT is set
57
+ const ghOutput = process.env.GITHUB_OUTPUT;
58
+ if (ghOutput) {
59
+ writeGitHubOutputVars(report, ghOutput);
60
+ }
61
+ break;
62
+ }
63
+ case 'junit-xml': {
64
+ const xml = formatJUnitXml(report);
65
+ console.log(xml);
66
+ break;
67
+ }
68
+ case 'json': {
69
+ console.log(JSON.stringify(report, null, 2));
70
+ break;
71
+ }
72
+ default:
73
+ console.error(`Unsupported ci-report format "${resolvedFormat}". Use "github-actions", "junit-xml", "json", or "auto".`);
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+
78
+ // Step 4: Exit with CI-appropriate code
79
+ process.exitCode = deriveCIExitCode(report);
80
+ }
@@ -21,7 +21,7 @@ import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/go
21
21
  import { findPendingApprovedIntents } from '../lib/intake.js';
22
22
  import { checkCleanBaseline } from '../lib/repo-observer.js';
23
23
  import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
24
- import { getClaudeSubprocessAuthIssue } from '../lib/claude-local-auth.js';
24
+ import { getClaudeSubprocessAuthIssue, isCursorLocalCliRuntime, isWindsurfLocalCliRuntime, isOpenCodeLocalCliRuntime } from '../lib/claude-local-auth.js';
25
25
 
26
26
  export async function doctorCommand(opts = {}) {
27
27
  const root = findProjectRoot(process.cwd());
@@ -502,6 +502,27 @@ async function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
502
502
  case 'local_cli': {
503
503
  const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
504
504
  if (probe.ok) {
505
+ if (isCursorLocalCliRuntime(rt)) {
506
+ return attachRuntimeContract({
507
+ ...base,
508
+ level: 'pass',
509
+ detail: `${probe.detail} (Cursor IDE local_cli connector)`,
510
+ }, rtId, rt, boundRoleEntries);
511
+ }
512
+ if (isWindsurfLocalCliRuntime(rt)) {
513
+ return attachRuntimeContract({
514
+ ...base,
515
+ level: 'pass',
516
+ detail: `${probe.detail} (Windsurf IDE local_cli connector)`,
517
+ }, rtId, rt, boundRoleEntries);
518
+ }
519
+ if (isOpenCodeLocalCliRuntime(rt)) {
520
+ return attachRuntimeContract({
521
+ ...base,
522
+ level: 'pass',
523
+ detail: `${probe.detail} (OpenCode local_cli connector)`,
524
+ }, rtId, rt, boundRoleEntries);
525
+ }
505
526
  const claudeAuthIssue = await getClaudeSubprocessAuthIssue(rt);
506
527
  if (claudeAuthIssue) {
507
528
  return attachRuntimeContract({
@@ -18,6 +18,7 @@ export async function intakeApproveCommand(opts) {
18
18
  const result = approveIntent(root, opts.intent, {
19
19
  approver: opts.approver || undefined,
20
20
  reason: opts.reason || undefined,
21
+ forceScope: opts.forceScope || false,
21
22
  });
22
23
 
23
24
  if (opts.json) {
@@ -54,6 +54,7 @@ export async function replayTurnCommand(turnId, opts = {}) {
54
54
  root,
55
55
  verification: entry.verification,
56
56
  timeoutMs,
57
+ allowCommandExecution: Boolean(opts.execute),
57
58
  }),
58
59
  };
59
60
 
@@ -18,6 +18,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { loadProjectContext, loadProjectState } from '../lib/config.js';
20
20
  import { runLoop } from '../lib/run-loop.js';
21
+ import { isCredentialedExitGate } from '../lib/approval-policy.js';
21
22
  import { transitionActiveTurnLifecycle } from '../lib/runner-interface.js';
22
23
  import { buildRunExport } from '../lib/export.js';
23
24
  import { buildGovernanceReport, formatGovernanceReportMarkdown } from '../lib/report.js';
@@ -362,6 +363,15 @@ export async function executeGovernedRun(context, opts = {}) {
362
363
  turnId: turn.turn_id,
363
364
  onSpawnAttached: ({ pid, at }) => ensureStartingState(pid, at),
364
365
  onFirstOutput: ({ at, stream }) => ensureRunningState(stream, at),
366
+ onStartupHeartbeat: ({ elapsed_since_spawn_ms }) => {
367
+ tracker.heartbeat(`Adapter keepalive (${Math.round((elapsed_since_spawn_ms || 0) / 1000)}s since spawn)`);
368
+ },
369
+ onLivenessHeartbeat: ({ elapsed_since_spawn_ms }) => {
370
+ // RB-6: refresh last_activity_at while the subprocess is alive but
371
+ // output-silent, so the stale-turn watchdog does not misfire during
372
+ // long quiet operations (e.g. running a test suite inside the turn).
373
+ tracker.heartbeat(`Subprocess alive (${Math.round((elapsed_since_spawn_ms || 0) / 1000)}s since spawn)`);
374
+ },
365
375
  };
366
376
 
367
377
  const recordOutputActivity = (stream, text) => {
@@ -435,6 +445,37 @@ export async function executeGovernedRun(context, opts = {}) {
435
445
  const transport = runtime ? resolvePromptTransport(runtime) : 'dispatch_bundle_only';
436
446
  log(chalk.dim(` Dispatching to local CLI: ${runtime?.command || '(default)'} transport: ${transport}`));
437
447
  adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
448
+
449
+ // Rate-limit backoff: if adapter classified as rate_limited + retryable,
450
+ // apply exponential backoff and re-dispatch. Does NOT consume max_turn_retries.
451
+ const RATE_LIMIT_MAX_RETRIES = 5;
452
+ const RATE_LIMIT_BASE_DELAY_MS = 2000;
453
+ const RATE_LIMIT_MAX_DELAY_MS = 120_000;
454
+ const RATE_LIMIT_BACKOFF_MULTIPLIER = 2;
455
+
456
+ let rateLimitAttempt = 0;
457
+ while (
458
+ adapterResult?.classified?.error_class === 'rate_limited' &&
459
+ adapterResult?.classified?.retryable === true &&
460
+ rateLimitAttempt < RATE_LIMIT_MAX_RETRIES
461
+ ) {
462
+ rateLimitAttempt++;
463
+ const resetMs = adapterResult.classified.reset_ms;
464
+ const exponentialDelay = Math.min(
465
+ RATE_LIMIT_MAX_DELAY_MS,
466
+ RATE_LIMIT_BASE_DELAY_MS * RATE_LIMIT_BACKOFF_MULTIPLIER ** (rateLimitAttempt - 1),
467
+ );
468
+ const delayMs = resetMs != null ? Math.max(resetMs, RATE_LIMIT_BASE_DELAY_MS) : exponentialDelay;
469
+ // Add jitter: 0-25% of delay
470
+ const jitter = Math.floor(Math.random() * delayMs * 0.25);
471
+ const totalDelay = delayMs + jitter;
472
+
473
+ log(chalk.yellow(` Rate-limited by provider (attempt ${rateLimitAttempt}/${RATE_LIMIT_MAX_RETRIES}). Retrying in ${Math.ceil(totalDelay / 1000)}s...`));
474
+ await new Promise((resolve) => setTimeout(resolve, totalDelay));
475
+
476
+ log(chalk.dim(` Re-dispatching after rate-limit backoff...`));
477
+ adapterResult = await dispatchLocalCli(projectRoot, state, cfg, adapterOpts);
478
+ }
438
479
  } else if (runtimeType === 'remote_agent') {
439
480
  ensureStartingState(null);
440
481
  ensureRunningState('request');
@@ -575,6 +616,15 @@ export async function executeGovernedRun(context, opts = {}) {
575
616
 
576
617
  async approveGate(gateType, state) {
577
618
  if (autoApprove) {
619
+ // Lights-out without blind trust: a credentialed gate (protecting an
620
+ // irreversible, external, or operator-owned action) is NEVER auto-approved,
621
+ // even under --auto-approve / continuous mode. It falls through to gate_held,
622
+ // so a human must approve it (agentxchain approve-transition / approve-completion).
623
+ if (isCredentialedExitGate(config, state?.phase)) {
624
+ const gateId = config?.routing?.[state?.phase]?.exit_gate;
625
+ log(chalk.red(` Gate "${gateId}" is credentialed — auto-approval refused. A human must approve (lights-out without blind trust).`));
626
+ return false;
627
+ }
578
628
  log(chalk.yellow(` Auto-approved ${gateType} gate`));
579
629
  return true;
580
630
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * agentxchain serve — start the hosted runner HTTP server.
3
+ *
4
+ * Exposes control plane API routes for remote run management and dispatches
5
+ * governed turns to cloud agent APIs via the execution worker.
6
+ *
7
+ * Usage:
8
+ * agentxchain serve [--port 4100] [--host 127.0.0.1] [--project <path>]
9
+ */
10
+
11
+ import { resolve } from 'node:path';
12
+ import { findProjectRoot, loadProjectContext } from '../lib/config.js';
13
+ import { createHostedRunner } from '../lib/api/hosted-runner.js';
14
+
15
+ export async function serveCommand(opts) {
16
+ const rootHint = opts.project || process.cwd();
17
+ const root = findProjectRoot(resolve(rootHint));
18
+
19
+ if (!root) {
20
+ process.stderr.write(
21
+ `[agentxchain serve] Error: no agentxchain.json found at or above ${rootHint}\n`
22
+ );
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+
27
+ const ctx = loadProjectContext(root);
28
+ if (!ctx || !ctx.config) {
29
+ process.stderr.write(
30
+ `[agentxchain serve] Error: failed to load project configuration from ${root}\n`
31
+ );
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+
36
+ const port = parseInt(opts.port || '4100', 10);
37
+ const host = opts.host || '127.0.0.1';
38
+
39
+ const additionalProjects = (opts.projects || '')
40
+ .split(',')
41
+ .map(p => p.trim())
42
+ .filter(Boolean)
43
+ .map(p => resolve(p));
44
+
45
+ const runner = createHostedRunner({
46
+ root,
47
+ config: ctx.config,
48
+ port,
49
+ host,
50
+ projects: additionalProjects,
51
+ });
52
+
53
+ await runner.start();
54
+ process.stderr.write(`[agentxchain serve] Hosted runner listening on http://${host}:${port}\n`);
55
+
56
+ const shutdown = async () => {
57
+ process.stderr.write('[agentxchain serve] Shutting down...\n');
58
+ await runner.stop();
59
+ process.exit(0);
60
+ };
61
+
62
+ process.on('SIGINT', shutdown);
63
+ process.on('SIGTERM', shutdown);
64
+ }