agentxchain 2.90.0 → 2.91.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.
@@ -199,7 +199,7 @@ program
199
199
 
200
200
  program
201
201
  .command('stop')
202
- .description('Stop watch daemon and Claude Code sessions; close Cursor/VS Code chats manually')
202
+ .description('Stop dashboard/watch daemons and Claude Code sessions; close Cursor/VS Code chats manually')
203
203
  .action(stopCommand);
204
204
 
205
205
  program
@@ -477,6 +477,7 @@ program
477
477
  .command('dashboard')
478
478
  .description('Open the read-only governance dashboard in your browser')
479
479
  .option('--port <port>', 'Server port', '3847')
480
+ .option('--daemon', 'Run the dashboard in background mode')
480
481
  .option('--no-open', 'Do not auto-open the browser')
481
482
  .action(dashboardCommand);
482
483
 
@@ -207,6 +207,23 @@ export function render({
207
207
  if (Array.isArray(barrier.satisfied_repos) && barrier.satisfied_repos.length > 0) {
208
208
  html += `<div class="turn-detail"><span class="detail-label">Satisfied Repos:</span> ${esc(barrier.satisfied_repos.join(', '))}</div>`;
209
209
  }
210
+ const decisionIds = barrier.required_decision_ids_by_repo || barrier.alignment_decision_ids || null;
211
+ if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
212
+ const satisfiedSet = new Set(Array.isArray(barrier.satisfied_repos) ? barrier.satisfied_repos : []);
213
+ html += `<div class="turn-detail"><span class="detail-label">Decision Requirements:</span></div>`;
214
+ html += `<div class="decision-req-list" style="margin-left:1.2em;margin-top:0.3em">`;
215
+ for (const [repo, ids] of Object.entries(decisionIds)) {
216
+ if (!Array.isArray(ids) || ids.length === 0) continue;
217
+ const repoSatisfied = satisfiedSet.has(repo);
218
+ const idBadges = ids.map((id) =>
219
+ repoSatisfied
220
+ ? badge(`${id} ✓`, 'var(--green)')
221
+ : badge(id, 'var(--text-dim)')
222
+ ).join(' ');
223
+ html += `<div style="margin-bottom:0.2em"><span class="mono" style="margin-right:0.5em">${esc(repo)}:</span>${idBadges}</div>`;
224
+ }
225
+ html += `</div>`;
226
+ }
210
227
  html += `</div>`;
211
228
  }
212
229
  html += `</div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.90.0",
3
+ "version": "2.91.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,13 +5,17 @@
5
5
  * The dashboard remains mostly observational, but can approve pending gates.
6
6
  */
7
7
 
8
- import { existsSync } from 'fs';
8
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
9
+ import { spawn } from 'child_process';
9
10
  import { join, dirname } from 'path';
10
11
  import { fileURLToPath } from 'url';
11
12
  import { createBridgeServer } from '../lib/dashboard/bridge-server.js';
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const DEFAULT_PORT = 3847;
16
+ const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
17
+ const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
18
+ const DASHBOARD_DAEMON_CHILD_ENV = 'AGENTXCHAIN_DASHBOARD_DAEMON_CHILD';
15
19
 
16
20
  export async function dashboardCommand(options) {
17
21
  const cwd = process.cwd();
@@ -29,12 +33,25 @@ export async function dashboardCommand(options) {
29
33
  process.exit(1);
30
34
  }
31
35
 
32
- const port = parseInt(options.port, 10) || DEFAULT_PORT;
36
+ if (options.daemon && process.env[DASHBOARD_DAEMON_CHILD_ENV] !== '1') {
37
+ await startDashboardDaemon({ cwd, port: parseDashboardPort(options.port) });
38
+ return;
39
+ }
40
+
41
+ cleanupDashboardFiles(cwd);
42
+
43
+ const port = parseDashboardPort(options.port);
33
44
  const bridge = createBridgeServer({ agentxchainDir, dashboardDir, port });
34
45
 
35
46
  try {
36
47
  const { port: actualPort } = await bridge.start();
37
48
  const url = `http://localhost:${actualPort}`;
49
+ writeDashboardSession(cwd, {
50
+ pid: process.pid,
51
+ port: actualPort,
52
+ url,
53
+ started_at: new Date().toISOString(),
54
+ });
38
55
 
39
56
  console.log(`Dashboard running at ${url}`);
40
57
  console.log('Press Ctrl+C to stop.\n');
@@ -51,16 +68,19 @@ export async function dashboardCommand(options) {
51
68
  }
52
69
  }
53
70
 
54
- // Keep running until interrupted
71
+ let shuttingDown = false;
55
72
  const shutdown = async () => {
73
+ if (shuttingDown) return;
74
+ shuttingDown = true;
56
75
  console.log('\nShutting down dashboard...');
76
+ cleanupDashboardFiles(cwd);
57
77
  await bridge.stop();
58
78
  process.exit(0);
59
79
  };
60
80
  process.on('SIGINT', shutdown);
61
81
  process.on('SIGTERM', shutdown);
62
-
63
82
  } catch (err) {
83
+ cleanupDashboardFiles(cwd);
64
84
  if (err.code === 'EADDRINUSE') {
65
85
  console.error(`Error: Port ${port} is already in use. Try --port <number>.`);
66
86
  process.exit(1);
@@ -68,3 +88,111 @@ export async function dashboardCommand(options) {
68
88
  throw err;
69
89
  }
70
90
  }
91
+
92
+ export function getDashboardPid(root) {
93
+ const pidPath = join(root, DASHBOARD_PID_FILE);
94
+ if (!existsSync(pidPath)) return null;
95
+ try {
96
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
97
+ if (!Number.isFinite(pid)) return null;
98
+ process.kill(pid, 0);
99
+ return pid;
100
+ } catch (err) {
101
+ if (err?.code === 'ESRCH') {
102
+ cleanupDashboardFiles(root);
103
+ return null;
104
+ }
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export function getDashboardSession(root) {
110
+ const sessionPath = join(root, DASHBOARD_SESSION_FILE);
111
+ if (!existsSync(sessionPath)) return null;
112
+ try {
113
+ return JSON.parse(readFileSync(sessionPath, 'utf8'));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ export function cleanupDashboardFiles(root) {
120
+ const paths = [
121
+ join(root, DASHBOARD_PID_FILE),
122
+ join(root, DASHBOARD_SESSION_FILE),
123
+ ];
124
+ for (const path of paths) {
125
+ if (!existsSync(path)) continue;
126
+ try { unlinkSync(path); } catch {}
127
+ }
128
+ }
129
+
130
+ function parseDashboardPort(value) {
131
+ const parsed = parseInt(value, 10);
132
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT;
133
+ }
134
+
135
+ function writeDashboardSession(root, session) {
136
+ writeFileSync(join(root, DASHBOARD_PID_FILE), `${session.pid}\n`);
137
+ writeFileSync(join(root, DASHBOARD_SESSION_FILE), `${JSON.stringify(session, null, 2)}\n`);
138
+ }
139
+
140
+ async function startDashboardDaemon({ cwd, port }) {
141
+ const existingPid = getDashboardPid(cwd);
142
+ if (existingPid) {
143
+ const existingSession = getDashboardSession(cwd);
144
+ const existingUrl = existingSession?.url || `http://localhost:${existingSession?.port || port}`;
145
+ console.error(`Error: Dashboard already running at ${existingUrl} (PID: ${existingPid}). Stop it first with "agentxchain stop".`);
146
+ process.exit(1);
147
+ }
148
+
149
+ cleanupDashboardFiles(cwd);
150
+
151
+ const cliBin = join(__dirname, '..', '..', 'bin', 'agentxchain.js');
152
+ const child = spawn(process.execPath, [cliBin, 'dashboard', '--port', String(port), '--no-open'], {
153
+ cwd,
154
+ env: {
155
+ ...process.env,
156
+ [DASHBOARD_DAEMON_CHILD_ENV]: '1',
157
+ },
158
+ detached: true,
159
+ stdio: 'ignore',
160
+ });
161
+ child.unref();
162
+
163
+ try {
164
+ const session = await waitForDashboardSession(cwd, child.pid);
165
+ console.log(`Dashboard started in daemon mode at ${session.url}`);
166
+ console.log(`PID: ${session.pid}`);
167
+ } catch (err) {
168
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
169
+ cleanupDashboardFiles(cwd);
170
+ console.error(`Error: ${err.message}`);
171
+ process.exit(1);
172
+ }
173
+ }
174
+
175
+ function waitForDashboardSession(root, expectedPid, timeoutMs = 8000) {
176
+ return new Promise((resolve, reject) => {
177
+ const startedAt = Date.now();
178
+
179
+ const poll = () => {
180
+ const pid = getDashboardPid(root);
181
+ const session = getDashboardSession(root);
182
+
183
+ if (pid === expectedPid && session?.pid === expectedPid && typeof session?.url === 'string') {
184
+ resolve(session);
185
+ return;
186
+ }
187
+
188
+ if (Date.now() - startedAt >= timeoutMs) {
189
+ reject(new Error('Dashboard daemon did not report a live session within 8s.'));
190
+ return;
191
+ }
192
+
193
+ setTimeout(poll, 100);
194
+ };
195
+
196
+ poll();
197
+ });
198
+ }
@@ -3,9 +3,12 @@ import { join } from 'path';
3
3
  import chalk from 'chalk';
4
4
  import { loadConfig } from '../lib/config.js';
5
5
  import { getWatchPid } from './watch.js';
6
+ import { cleanupDashboardFiles, getDashboardPid, getDashboardSession } from './dashboard.js';
6
7
 
7
8
  const SESSION_FILE = '.agentxchain-session.json';
8
9
  const WATCH_PID_FILE = '.agentxchain-watch.pid';
10
+ const DASHBOARD_PID_FILE = '.agentxchain-dashboard.pid';
11
+ const DASHBOARD_SESSION_FILE = '.agentxchain-dashboard.json';
9
12
 
10
13
  export async function stopCommand() {
11
14
  const result = loadConfig();
@@ -14,7 +17,12 @@ export async function stopCommand() {
14
17
  const { root } = result;
15
18
  const sessionPath = join(root, SESSION_FILE);
16
19
  const watchPidPath = join(root, WATCH_PID_FILE);
20
+ const dashboardPidPath = join(root, DASHBOARD_PID_FILE);
21
+ const dashboardSessionPath = join(root, DASHBOARD_SESSION_FILE);
17
22
  const watchPid = getWatchPid(root);
23
+ const hadDashboardArtifacts = existsSync(dashboardPidPath) || existsSync(dashboardSessionPath);
24
+ const dashboardPid = getDashboardPid(root);
25
+ const dashboardSession = getDashboardSession(root);
18
26
  let didStopAnything = false;
19
27
 
20
28
  if (watchPid) {
@@ -41,6 +49,26 @@ export async function stopCommand() {
41
49
  } catch {}
42
50
  }
43
51
 
52
+ if (dashboardPid) {
53
+ try {
54
+ process.kill(dashboardPid, 'SIGTERM');
55
+ cleanupDashboardFiles(root);
56
+ didStopAnything = true;
57
+ console.log('');
58
+ console.log(chalk.green(` ✓ Stopped dashboard process (PID: ${dashboardPid})${dashboardSession?.url ? ` at ${dashboardSession.url}` : ''}`));
59
+ console.log('');
60
+ } catch (err) {
61
+ if (err.code === 'ESRCH') {
62
+ cleanupDashboardFiles(root);
63
+ } else {
64
+ console.log(chalk.red(` ✗ Could not stop dashboard process (PID: ${dashboardPid}): ${err.message}`));
65
+ }
66
+ }
67
+ } else if (hadDashboardArtifacts) {
68
+ cleanupDashboardFiles(root);
69
+ console.log(chalk.dim(' Removed stale dashboard session files.'));
70
+ }
71
+
44
72
  if (existsSync(sessionPath)) {
45
73
  let session;
46
74
  try {
package/src/lib/report.js CHANGED
@@ -767,14 +767,28 @@ function extractBarrierSummary(artifact) {
767
767
  return Object.entries(data)
768
768
  .filter(([, b]) => b && typeof b === 'object' && !Array.isArray(b))
769
769
  .sort(([a], [b]) => a.localeCompare(b, 'en'))
770
- .map(([barrierId, b]) => ({
771
- barrier_id: barrierId,
772
- workstream_id: b.workstream_id || null,
773
- type: b.type || 'unknown',
774
- status: b.status || 'unknown',
775
- required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
776
- satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
777
- }));
770
+ .map(([barrierId, b]) => {
771
+ const entry = {
772
+ barrier_id: barrierId,
773
+ workstream_id: b.workstream_id || null,
774
+ type: b.type || 'unknown',
775
+ status: b.status || 'unknown',
776
+ required_repos: Array.isArray(b.required_repos) ? b.required_repos : [],
777
+ satisfied_repos: Array.isArray(b.satisfied_repos) ? b.satisfied_repos : [],
778
+ };
779
+ const decisionIds =
780
+ b.required_decision_ids_by_repo || b.alignment_decision_ids || null;
781
+ if (decisionIds && typeof decisionIds === 'object' && !Array.isArray(decisionIds)) {
782
+ entry.required_decision_ids_by_repo = decisionIds;
783
+ const satisfiedSet = new Set(entry.satisfied_repos);
784
+ const satisfiedByRepo = {};
785
+ for (const [repo, ids] of Object.entries(decisionIds)) {
786
+ satisfiedByRepo[repo] = satisfiedSet.has(repo) ? [...ids] : [];
787
+ }
788
+ entry.satisfied_decision_ids_by_repo = satisfiedByRepo;
789
+ }
790
+ return entry;
791
+ });
778
792
  }
779
793
 
780
794
  function summarizeBarrierTransition(entry) {
@@ -1442,6 +1456,17 @@ export function formatGovernanceReportText(report) {
1442
1456
  const satisfied = b.satisfied_repos.length;
1443
1457
  const required = b.required_repos.length;
1444
1458
  lines.push(` - ${b.barrier_id}: ${b.status} (${b.type}, ${satisfied}/${required} repos satisfied, workstream ${b.workstream_id || 'unknown'})`);
1459
+ if (b.required_decision_ids_by_repo) {
1460
+ lines.push(' Decision requirements:');
1461
+ for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
1462
+ if (!Array.isArray(ids) || ids.length === 0) continue;
1463
+ const satisfiedIds = new Set(
1464
+ Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : []
1465
+ );
1466
+ const labels = ids.map((id) => `${id} (${satisfiedIds.has(id) ? 'satisfied' : 'pending'})`);
1467
+ lines.push(` ${repo}: ${labels.join(', ')}`);
1468
+ }
1469
+ }
1445
1470
  }
1446
1471
  }
1447
1472
 
@@ -1917,6 +1942,20 @@ export function formatGovernanceReportMarkdown(report) {
1917
1942
  for (const b of barrier_summary) {
1918
1943
  mdLines.push(`| \`${b.barrier_id}\` | \`${b.workstream_id || 'unknown'}\` | \`${b.type}\` | \`${b.status}\` | ${b.satisfied_repos.length}/${b.required_repos.length} repos |`);
1919
1944
  }
1945
+ const barriersWithDecisions = barrier_summary.filter((b) => b.required_decision_ids_by_repo);
1946
+ if (barriersWithDecisions.length > 0) {
1947
+ mdLines.push('', '### Decision Requirements');
1948
+ for (const b of barriersWithDecisions) {
1949
+ mdLines.push('', `**\`${b.barrier_id}\`** decision requirements:`, '', '| Repo | Required | Satisfied |', '|------|----------|-----------|');
1950
+ for (const [repo, ids] of Object.entries(b.required_decision_ids_by_repo)) {
1951
+ if (!Array.isArray(ids) || ids.length === 0) continue;
1952
+ const satisfiedIds = Array.isArray(b.satisfied_decision_ids_by_repo?.[repo]) ? b.satisfied_decision_ids_by_repo[repo] : [];
1953
+ const reqStr = ids.map((id) => `\`${id}\``).join(', ');
1954
+ const satStr = satisfiedIds.length > 0 ? satisfiedIds.map((id) => `\`${id}\``).join(', ') : '—';
1955
+ mdLines.push(`| \`${repo}\` | ${reqStr} | ${satStr} |`);
1956
+ }
1957
+ }
1958
+ }
1920
1959
  }
1921
1960
 
1922
1961
  if (barrier_ledger_timeline && barrier_ledger_timeline.length > 0) {