@yemi33/minions 0.1.2142 → 0.1.2144

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.
@@ -21,8 +21,42 @@
21
21
  const http = require('http');
22
22
  const fs = require('fs');
23
23
  const path = require('path');
24
+ const os = require('os');
24
25
 
25
- const DEFAULT_BASE_URL = process.env.MINIONS_DASHBOARD_URL || 'http://localhost:7331';
26
+ /**
27
+ * Resolve the dashboard base URL on EVERY call so commands invoked while the
28
+ * dashboard auto-fallback bound to a non-default port (W-mq5nwl9l) still
29
+ * reach it. Precedence:
30
+ * 1. MINIONS_DASHBOARD_URL env var (full URL — wins absolutely)
31
+ * 2. engine/dashboard-port.json runtime file (the live dashboard's actual port)
32
+ * 3. http://localhost:7331 (legacy default)
33
+ *
34
+ * Inlined here (rather than going through engine/shared.js) so the CLI client
35
+ * stays runtime-loadable even if shared.js fails to load mid-upgrade.
36
+ */
37
+ function _readRuntimePort() {
38
+ try {
39
+ const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
40
+ const raw = fs.readFileSync(path.join(home, 'engine', 'dashboard-port.json'), 'utf8');
41
+ const data = JSON.parse(raw);
42
+ const port = Number(data && data.port);
43
+ if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
44
+ } catch {}
45
+ return null;
46
+ }
47
+
48
+ function getDefaultBaseUrl() {
49
+ if (process.env.MINIONS_DASHBOARD_URL) return process.env.MINIONS_DASHBOARD_URL;
50
+ const port = _readRuntimePort();
51
+ if (port) return `http://localhost:${port}`;
52
+ return 'http://localhost:7331';
53
+ }
54
+
55
+ // Backwards-compat: an evaluated-at-import snapshot for callers that just want
56
+ // "the value at the time the module loaded". Live consumers should call
57
+ // getDefaultBaseUrl() (default-parameter evaluation inside apiCall does that
58
+ // for them transparently).
59
+ const DEFAULT_BASE_URL = getDefaultBaseUrl();
26
60
 
27
61
  // ── HTTP core ───────────────────────────────────────────────────────────────
28
62
 
@@ -60,7 +94,8 @@ function _normalizeApiPath(input) {
60
94
  return s;
61
95
  }
62
96
 
63
- function apiCall(method, urlPath, bodyJson, { baseUrl = DEFAULT_BASE_URL, timeoutMs = 30000 } = {}) {
97
+ function apiCall(method, urlPath, bodyJson, { baseUrl, timeoutMs = 30000 } = {}) {
98
+ if (!baseUrl) baseUrl = getDefaultBaseUrl();
64
99
  return new Promise((resolve, reject) => {
65
100
  const normalized = _normalizeApiPath(urlPath);
66
101
  let u;
@@ -165,9 +200,10 @@ function _formatPlanOneLine(p) {
165
200
  // ── Generic passthrough: `minions api <METHOD> <PATH> [...]` ────────────────
166
201
 
167
202
  async function cliApi(argv) {
203
+ const baseUrlDefault = getDefaultBaseUrl();
168
204
  const help = `Usage: minions api <METHOD> <PATH> [--body JSON | --body-file FILE] [--base-url URL] [--quiet]
169
205
 
170
- Direct HTTP passthrough to the dashboard API at ${DEFAULT_BASE_URL}.
206
+ Direct HTTP passthrough to the dashboard API at ${baseUrlDefault}.
171
207
 
172
208
  Examples:
173
209
  minions api GET /api/status
@@ -187,7 +223,7 @@ Exit codes: 0 = 2xx, 1 = 4xx/5xx, 2 = connection failure / bad usage`;
187
223
  lineOut(help);
188
224
  return 2;
189
225
  }
190
- const baseUrl = takeFlag(argv, '--base-url') || DEFAULT_BASE_URL;
226
+ const baseUrl = takeFlag(argv, '--base-url') || baseUrlDefault;
191
227
  const bodyInline = takeFlag(argv, '--body');
192
228
  const bodyFile = takeFlag(argv, '--body-file');
193
229
  const quiet = hasFlag(argv, '--quiet');
@@ -711,6 +747,7 @@ module.exports = {
711
747
  isHandled,
712
748
  dispatch,
713
749
  DEFAULT_BASE_URL,
750
+ getDefaultBaseUrl,
714
751
  // Per-command handlers exposed for unit tests that want to drive them
715
752
  // directly without going through process.argv.
716
753
  _handlers: { cliApi, cliWi, cliPlans, cliSchedule, cliWatch, cliFeature, cliSettings },
package/bin/minions.js CHANGED
@@ -64,7 +64,43 @@ const PKG_ROOT = path.resolve(__dirname, '..');
64
64
  const shared = require(path.join(PKG_ROOT, 'engine', 'shared'));
65
65
  const { openUrlInBrowser } = shared;
66
66
  const { waitForRestartHealth, formatRestartHealthError } = require(path.join(PKG_ROOT, 'engine', 'restart-health'));
67
- const DASH_PORT = 7331;
67
+
68
+ /**
69
+ * Resolve the dashboard port for any CLI command. Precedence (W-mq5nwl9l):
70
+ * 1. Runtime file (engine/dashboard-port.json) — what the live dashboard
71
+ * ACTUALLY bound to. Wins over the chain so status/dash/restart-health
72
+ * probes the right port even after an EADDRINUSE auto-fallback.
73
+ * 2. Explicit `--port N` CLI flag if present in `argv`.
74
+ * 3. env MINIONS_DASHBOARD_PORT.
75
+ * 4. config.json#engine.dashboardPort.
76
+ * 5. shared.DEFAULT_DASHBOARD_PORT (7331).
77
+ *
78
+ * Returns `{port, source, fromRuntimeFile}` so log lines can stay accurate.
79
+ * `fromRuntimeFile=true` means a dashboard wrote it; falsy means we computed
80
+ * the desired port from the chain (used when SPAWNING a fresh dashboard).
81
+ */
82
+ function resolveDashboardPort(argv) {
83
+ // Read MINIONS_HOME lazily via env so this helper works at CLI-init time
84
+ // (before the const below is bound) — the const is resolved later.
85
+ const home = process.env.MINIONS_HOME || (typeof MINIONS_HOME !== 'undefined' ? MINIONS_HOME : null);
86
+ const rt = home ? shared.readDashboardPortFile(home) : shared.readDashboardPortFile();
87
+ if (rt && rt.port) return { port: rt.port, source: 'runtime-file', fromRuntimeFile: true };
88
+ let explicit = null;
89
+ if (Array.isArray(argv)) {
90
+ const i = argv.indexOf('--port');
91
+ if (i >= 0 && argv[i + 1]) explicit = argv[i + 1];
92
+ }
93
+ let cfg = null;
94
+ try {
95
+ if (home) cfg = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
96
+ } catch { /* config may not exist yet */ }
97
+ const chain = shared.resolveDashboardPort({
98
+ explicit,
99
+ env: process.env.MINIONS_DASHBOARD_PORT,
100
+ config: cfg && cfg.engine && cfg.engine.dashboardPort,
101
+ });
102
+ return { port: chain.port, source: chain.source, fromRuntimeFile: false };
103
+ }
68
104
 
69
105
  /** Returns PIDs (as strings) of processes LISTENING on `port`. Empty on no match
70
106
  * or when the platform tool (netstat/findstr/lsof) is unavailable. */
@@ -276,12 +312,17 @@ function _sqliteSpawnFlags() {
276
312
  /** Spawn a detached dashboard with self-open suppressed — the CLI decides
277
313
  * when to open a browser based on whether a real tab reconnects post-health.
278
314
  * stdout/stderr land in engine/dashboard-stdio.log so silent startup crashes
279
- * leave a postmortem instead of vanishing. */
280
- function spawnDashboard() {
315
+ * leave a postmortem instead of vanishing. The optional `requestedPort` is
316
+ * forwarded as argv[2] so the dashboard's own port resolver agrees with the
317
+ * CLI's intent (EADDRINUSE retries still apply inside the dashboard if a
318
+ * race opens between the CLI's port-free check and the actual bind). */
319
+ function spawnDashboard(requestedPort) {
281
320
  const env = { ...process.env, MINIONS_NO_AUTO_OPEN: '1' };
282
321
  const out = _openStdioLog('dashboard-stdio.log');
283
322
  const err = _openStdioLog('dashboard-stdio.log');
284
- const proc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')], {
323
+ const args = [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'dashboard.js')];
324
+ if (requestedPort) args.push(String(requestedPort));
325
+ const proc = spawn(process.execPath, args, {
285
326
  cwd: MINIONS_HOME, stdio: ['ignore', out, err], detached: true, windowsHide: true, env
286
327
  });
287
328
  proc.unref();
@@ -324,17 +365,30 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
324
365
  });
325
366
  engineProc.unref();
326
367
  console.log(`\n Engine started (PID: ${engineProc.pid})`);
327
- const dashProc = spawnDashboard();
368
+ // The CLI has already killed any stale dashboard + port-released; the
369
+ // resolved chain port is what the new dashboard will TRY first. Forward
370
+ // it via argv so the dashboard's own resolver agrees. EADDRINUSE auto-
371
+ // fallback inside dashboard.js will pick a different port if a race opens.
372
+ const requested = resolveDashboardPort(rest);
373
+ const dashProc = spawnDashboard(requested.port);
328
374
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
329
- console.log(` Dashboard: http://localhost:${DASH_PORT}`);
375
+ console.log(` Dashboard: http://localhost:${requested.port}`);
330
376
  const supProc = spawnSupervisor();
331
377
  console.log(` Supervisor started (PID: ${supProc.pid})`);
332
378
  console.log(' Verifying restart health...');
333
379
  void (async () => {
380
+ // Poll the runtime port file so we use the ACTUAL bound port (which may
381
+ // differ from `requested.port` after EADDRINUSE retry). If the dashboard
382
+ // never writes it, fall back to the requested port — restart-health then
383
+ // surfaces a clear failure rather than the CLI silently mis-probing.
384
+ const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000) || requested.port;
385
+ if (actualPort !== requested.port) {
386
+ console.log(` Dashboard bound to alternate port ${actualPort} (requested ${requested.port} was busy)`);
387
+ }
334
388
  const result = await waitForRestartHealth({
335
389
  minionsHome: MINIONS_HOME,
336
390
  dashboardPid: dashProc.pid,
337
- dashboardPort: DASH_PORT,
391
+ dashboardPort: actualPort,
338
392
  });
339
393
  if (!result.ok) {
340
394
  console.error(formatRestartHealthError(result));
@@ -346,7 +400,7 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
346
400
  !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
347
401
  if (shouldOpen) {
348
402
  console.log(` Opening dashboard in browser...`);
349
- _openInBrowser(`http://localhost:${DASH_PORT}`);
403
+ _openInBrowser(`http://localhost:${actualPort}`);
350
404
  }
351
405
  console.log('');
352
406
  })().catch(err => {
@@ -356,6 +410,36 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
356
410
  });
357
411
  }
358
412
 
413
+ /** Poll engine/dashboard-port.json until it appears (dashboard wrote it on
414
+ * listen) or the timeout elapses. Returns the bound port or null on
415
+ * timeout. Stale files left behind by a crashed dashboard are tolerated —
416
+ * if the file pre-dates the current spawn we still pick it up; the
417
+ * subsequent isPortListening probe in waitForRestartHealth will catch a
418
+ * truly dead dashboard. */
419
+ async function _waitForDashboardPortFile(home, timeoutMs = 12000, pollMs = 150) {
420
+ const start = Date.now();
421
+ const startedAt = start;
422
+ while (Date.now() - start < timeoutMs) {
423
+ const rt = shared.readDashboardPortFile(home);
424
+ if (rt && rt.port) {
425
+ // Prefer entries written AFTER we started polling. If the file is
426
+ // older (stale from a previous instance) it'll get rewritten by the
427
+ // new dashboard within the polling window — keep polling until then.
428
+ if (rt.boundAt) {
429
+ const ts = Date.parse(rt.boundAt);
430
+ if (Number.isFinite(ts) && ts >= startedAt - 1000) return rt.port;
431
+ } else {
432
+ return rt.port;
433
+ }
434
+ }
435
+ await new Promise(r => setTimeout(r, pollMs));
436
+ }
437
+ // Final read — even a stale entry is better than nothing if we timed out
438
+ // (the chain fallback in the caller covers a fully-missing file).
439
+ const rtFinal = shared.readDashboardPortFile(home);
440
+ return rtFinal && rtFinal.port ? rtFinal.port : null;
441
+ }
442
+
359
443
  /** Clear the stop-intent flag so the supervisor resumes guarding the engine
360
444
  * and dashboard. Called at the top of every start/restart path.
361
445
  * Delegates to engine/shared.js when available so engine.js, dashboard.js,
@@ -653,7 +737,8 @@ function init() {
653
737
  if (isUpgrade && skipStart) return;
654
738
 
655
739
  // Auto-start on fresh install; direct force-upgrade restarts automatically.
656
- const dashWasUp = isPortListening(DASH_PORT);
740
+ const upgradeRequested = resolveDashboardPort([]);
741
+ const dashWasUp = isPortListening(upgradeRequested.port);
657
742
  const restartStartMs = Date.now();
658
743
  if (isUpgrade) {
659
744
  // Pre-write stop-intent so the supervisor doesn't race-respawn the
@@ -663,7 +748,10 @@ function init() {
663
748
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000, windowsHide: true }); } catch {}
664
749
  // Free the dashboard port too — without this the new dashboard EADDRINUSE-dies
665
750
  // silently and the user keeps running stale code from the old dashboard process.
666
- killByPort(DASH_PORT);
751
+ killByPort(upgradeRequested.port);
752
+ // Wipe the runtime port file so the new dashboard's freshly-written
753
+ // beacon is the source of truth (avoids polling-back a stale entry).
754
+ shared.clearDashboardPortFile(MINIONS_HOME);
667
755
  // Clear AFTER kill so the old dashboard can't repopulate during shutdown.
668
756
  _clearDashboardBrowserState(MINIONS_HOME);
669
757
  }
@@ -679,19 +767,20 @@ function init() {
679
767
  engineProc.unref();
680
768
  console.log(` Engine started (PID: ${engineProc.pid})`);
681
769
 
682
- const dashProc = spawnDashboard();
770
+ const dashProc = spawnDashboard(upgradeRequested.port);
683
771
  console.log(` Dashboard started (PID: ${dashProc.pid})`);
684
- console.log(` Dashboard: http://localhost:${DASH_PORT}`);
772
+ console.log(` Dashboard: http://localhost:${upgradeRequested.port}`);
685
773
 
686
774
  const supProc = spawnSupervisor();
687
775
  console.log(` Supervisor started (PID: ${supProc.pid})`);
688
776
 
689
777
  void (async () => {
778
+ const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 8000) || upgradeRequested.port;
690
779
  const shouldOpen = forceOpen || !dashWasUp ||
691
780
  !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
692
781
  if (shouldOpen) {
693
782
  console.log(` Opening dashboard in browser...`);
694
- _openInBrowser(`http://localhost:${DASH_PORT}`);
783
+ _openInBrowser(`http://localhost:${actualPort}`);
695
784
  }
696
785
  })().catch(err => {
697
786
  console.log(` Could not open dashboard: ${err.message}`);
@@ -1010,12 +1099,16 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1010
1099
  if (enginePid) {
1011
1100
  try { process.kill(enginePid, 0); engineAlive = true; } catch { /* dead */ }
1012
1101
  }
1013
- const dashUp = isPortListening(DASH_PORT);
1102
+ // Probe the runtime port file first (already-bound dashboard) then fall
1103
+ // back to the chain. Source distinguishes 'runtime-file' vs default for
1104
+ // the partial-state error message.
1105
+ const startResolved = resolveDashboardPort(rest);
1106
+ const dashUp = isPortListening(startResolved.port);
1014
1107
  if (engineAlive && dashUp) {
1015
- console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${DASH_PORT}).`);
1108
+ console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${startResolved.port}).`);
1016
1109
  if (forceOpen) {
1017
1110
  console.log(` Opening dashboard in browser...`);
1018
- _openInBrowser(`http://localhost:${DASH_PORT}`);
1111
+ _openInBrowser(`http://localhost:${startResolved.port}`);
1019
1112
  } else {
1020
1113
  console.log(` Run \`minions dash\` to open the dashboard, or \`minions start --open\` to force a new browser tab.\n`);
1021
1114
  }
@@ -1033,7 +1126,11 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1033
1126
  // `--cli` / `--model` flags forward to `engine.js start` so the runtime
1034
1127
  // fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
1035
1128
  ensureInstalled();
1036
- const dashWasUp = isPortListening(DASH_PORT);
1129
+ // For restart we want the port the CURRENT dashboard is bound to (so we
1130
+ // kill it on its actual port) — runtime file wins for the kill step. The
1131
+ // chain falls in as fallback if no file exists yet (cold start).
1132
+ const restartResolved = resolveDashboardPort(rest);
1133
+ const dashWasUp = isPortListening(restartResolved.port);
1037
1134
  const restartStartMs = Date.now();
1038
1135
  // Pre-write stop-intent so the supervisor doesn't race-respawn the
1039
1136
  // engine/dashboard we're about to kill. Killed before the engine/dashboard
@@ -1048,7 +1145,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1048
1145
  // Force-kill the recorded engine PID (NOT the tree — agent children must
1049
1146
  // survive so the new engine can re-attach them via PID files).
1050
1147
  killPidOnly(oldEnginePid);
1051
- killByPort(DASH_PORT);
1148
+ killByPort(restartResolved.port);
1052
1149
  killMinionsProcesses(['engine.js', 'dashboard.js', 'supervisor.js']);
1053
1150
  // Confirm the OS finished the asynchronous termination before we spawn new
1054
1151
  // processes. Without this, `taskkill /F` returns immediately while the
@@ -1064,14 +1161,17 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1064
1161
  process.exit(1);
1065
1162
  }
1066
1163
  }
1067
- const portFree = waitForPortRelease(DASH_PORT, 10000);
1164
+ const portFree = waitForPortRelease(restartResolved.port, 10000);
1068
1165
  if (!portFree.ok) {
1069
- console.error(`\n ERROR: Port ${DASH_PORT} still in use after 10s — killing failed.`);
1166
+ console.error(`\n ERROR: Port ${restartResolved.port} still in use after 10s — killing failed.`);
1070
1167
  console.error(` Bound by PID(s): ${portFree.stillBound.join(', ')}`);
1071
1168
  console.error(` Manually free the port: taskkill /F /PID ${portFree.stillBound.join(' /PID ')}`);
1072
1169
  console.error(` Then retry: minions restart`);
1073
1170
  process.exit(1);
1074
1171
  }
1172
+ // Wipe the runtime port file so a stale entry from the old dashboard
1173
+ // can't fool the post-spawn poll into reading the old port.
1174
+ shared.clearDashboardPortFile(MINIONS_HOME);
1075
1175
  // Clear stale beacons AFTER the kill so the old dashboard's last writes
1076
1176
  // can't repopulate the file in the gap between clear and shutdown.
1077
1177
  _clearDashboardBrowserState(MINIONS_HOME);
@@ -1108,8 +1208,9 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1108
1208
 
1109
1209
  // 1. Kill all processes
1110
1210
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME }); } catch {}
1111
- killByPort(DASH_PORT);
1211
+ killByPort(resolveDashboardPort([]).port);
1112
1212
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js']);
1213
+ shared.clearDashboardPortFile(MINIONS_HOME);
1113
1214
  console.log(' Killed all processes');
1114
1215
 
1115
1216
  // 2. Delete runtime state
@@ -1199,7 +1300,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1199
1300
  writeStopIntent('minions uninstall');
1200
1301
  killSupervisor();
1201
1302
  try { execSync(`node "${path.join(MINIONS_HOME, 'engine.js')}" stop`, { stdio: 'ignore', cwd: MINIONS_HOME, timeout: 10000 }); } catch {}
1202
- killByPort(DASH_PORT);
1303
+ killByPort(resolveDashboardPort([]).port);
1203
1304
  killMinionsProcesses(['engine.js', 'dashboard.js', 'spawn-agent.js', 'supervisor.js']);
1204
1305
  console.log(' Killed all processes');
1205
1306
 
@@ -1239,7 +1340,11 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1239
1340
  doctor(MINIONS_HOME).then(ok => process.exit(ok ? 0 : 1));
1240
1341
  } else if (cmd === 'dash' || cmd === 'dashboard') {
1241
1342
  ensureInstalled();
1242
- // If dashboard is already running, just open the browser
1343
+ // If dashboard is already running, just open the browser. The runtime
1344
+ // port file wins over the resolution chain when the dashboard is up so
1345
+ // the probe targets the actually-bound port (which may differ from the
1346
+ // default after an EADDRINUSE auto-fallback).
1347
+ const dashResolved = resolveDashboardPort(rest);
1243
1348
  const net = require('net');
1244
1349
  const sock = new net.Socket();
1245
1350
  let handled = false;
@@ -1248,7 +1353,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1248
1353
  sock.destroy();
1249
1354
  if (handled) return;
1250
1355
  handled = true;
1251
- const url = `http://localhost:${DASH_PORT}`;
1356
+ const url = `http://localhost:${dashResolved.port}`;
1252
1357
  console.log(`\n Dashboard already running: ${url}\n`);
1253
1358
  openUrlInBrowser(url);
1254
1359
  });
@@ -1264,7 +1369,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1264
1369
  handled = true;
1265
1370
  delegate('dashboard.js', rest);
1266
1371
  });
1267
- sock.connect(DASH_PORT, '127.0.0.1');
1372
+ sock.connect(dashResolved.port, '127.0.0.1');
1268
1373
  } else if (engineCmds.has(cmd)) {
1269
1374
  delegate('engine.js', [cmd, ...rest]);
1270
1375
  } else if (require('./cli-api-client').isHandled(cmd)) {
@@ -137,7 +137,7 @@ const RENDER_VERSIONS = {
137
137
  projects: 1,
138
138
  notes: 1,
139
139
  prd: 1,
140
- prs: 2,
140
+ prs: 3,
141
141
  archivedPrds: 1,
142
142
  engine: 4,
143
143
  version: 1,
@@ -153,10 +153,11 @@ function prRow(pr) {
153
153
  ? '<span class="pr-agent" title="' + escapeHtml(pr.reviewedBy.join(', ')) + '">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>'
154
154
  : '<span style="color:var(--muted);font-size:var(--text-base)">—</span>';
155
155
  const createdLabel = (pr.created || '—').slice(0, 16).replace('T', ' ');
156
- // Per-row auto-observe toggle (W-mpmwxkzm0009ba0b). _contextOnly === true
157
- // means the engine polls status/comments but does NOT dispatch review/fix
158
- // agents against this PR. Default (undefined / false) = observed.
159
- var observe = pr._contextOnly !== true;
156
+ // Per-row auto-observe toggle (W-mpmwxkzm0009ba0b; canonicalized in
157
+ // W-mq5s5ttx000j7ab8-c). pr.contextOnly === true means the engine polls
158
+ // status/comments but does NOT dispatch review/fix agents against this
159
+ // PR. Default (undefined / false) = observed.
160
+ var observe = pr.contextOnly !== true;
160
161
  var observeClass = observe ? 'pr-observe-on' : 'pr-observe-off';
161
162
  var observeLabel = observe ? 'observed' : 'context';
162
163
  var observeTitle = observe
@@ -396,9 +397,10 @@ async function unlinkPr(id) {
396
397
  }
397
398
 
398
399
  // Per-row auto-observe toggle (W-mpmwxkzm0009ba0b; toggle-switch UX in
399
- // W-mpof8qam000h62fb). Optimistic UI: flip the switch immediately, only
400
- // revert on API error. The engine consumes `_contextOnly` to gate review/fix
401
- // dispatch (engine/shared.js isAutoManagedPrRecord + discoverFromPrs).
400
+ // W-mpof8qam000h62fb; canonicalized in W-mq5s5ttx000j7ab8-c). Optimistic
401
+ // UI: flip the switch immediately, only revert on API error. The engine
402
+ // consumes canonical `contextOnly` to gate review/fix dispatch (engine/
403
+ // shared.js isAutoManagedPrRecord + discoverFromPrs).
402
404
  //
403
405
  // `btn` is the <input type=checkbox> inside .pr-observe-switch. Data-attrs
404
406
  // live on the input so this handler keeps reading them directly; visual
package/dashboard.js CHANGED
@@ -77,9 +77,26 @@ const TITLE_SUFFIX = IS_DEV_MODE ? ' [DEV]' : '';
77
77
  }
78
78
  })();
79
79
 
80
- const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
81
80
  let CONFIG = queries.getConfig();
82
81
  let PROJECTS = _getProjects(CONFIG);
82
+ // PORT resolution chain (W-mq5nwl9l): explicit CLI arg (argv[2]) > env
83
+ // MINIONS_DASHBOARD_PORT / PORT > config.engine.dashboardPort > default 7331.
84
+ // REQUESTED_PORT is the user/operator intent; BOUND_PORT may differ after an
85
+ // EADDRINUSE retry. Test-mode badge logic stays anchored to REQUESTED_PORT so
86
+ // an accidental port collision (7331 already in use) does NOT cause the live
87
+ // dashboard to wear the "TEST" badge. `PORT` is kept as an alias for the
88
+ // bound port — every existing site that read PORT (banner, dashboardPort
89
+ // substitution for CC, etc.) now sees the actually-bound value.
90
+ const REQUESTED_PORT = (() => {
91
+ const argv = parseInt(process.argv[2], 10);
92
+ if (Number.isInteger(argv) && argv > 0 && argv <= 65535) return argv;
93
+ const envP = parseInt(process.env.MINIONS_DASHBOARD_PORT || process.env.PORT || '', 10);
94
+ if (Number.isInteger(envP) && envP > 0 && envP <= 65535) return envP;
95
+ const cfgP = parseInt(CONFIG && CONFIG.engine && CONFIG.engine.dashboardPort, 10);
96
+ if (Number.isInteger(cfgP) && cfgP > 0 && cfgP <= 65535) return cfgP;
97
+ return shared.DEFAULT_DASHBOARD_PORT;
98
+ })();
99
+ let PORT = REQUESTED_PORT;
83
100
  const CONFIG_PATH = path.join(MINIONS_DIR, 'config.json');
84
101
  const PINNED_PATH = path.join(MINIONS_DIR, 'pinned.md');
85
102
  const PINNED_DEFAULT_CONTENT = '# Pinned Context\n\nCritical notes visible to all agents.';
@@ -822,7 +839,7 @@ function collectArchivedWorkItems(minionsDir = MINIONS_DIR, projects = PROJECTS)
822
839
  }
823
840
  return archived;
824
841
  }
825
- function linkPullRequestForTracking({ url, title, project: projectName, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
842
+ function linkPullRequestForTracking({ url, title, project: projectName, contextOnly, autoObserve, context, workItemId }, config = CONFIG, options = {}) {
826
843
  if (!url) {
827
844
  const err = new Error('url required');
828
845
  err.statusCode = 400;
@@ -838,6 +855,12 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
838
855
  const linkedWorkItemId = getWorkItemIdFromPrLinkContext(context, workItemId);
839
856
  const contextText = typeof context === 'string' ? context : (context == null ? '' : JSON.stringify(context));
840
857
  const metadata = normalizePrMetadata(options.metadata);
858
+ // W-mq5s5ttx000j7ab8-c: canonical `contextOnly` field with `autoObserve`
859
+ // deprecated alias. Explicit canonical wins; otherwise fall back to the
860
+ // alias (autoObserve:true → contextOnly:false); otherwise default false.
861
+ const resolvedContextOnly = typeof contextOnly === 'boolean'
862
+ ? contextOnly
863
+ : (autoObserve === undefined ? false : !autoObserve);
841
864
  const result = shared.upsertPullRequestRecord(prPath, {
842
865
  id: prId,
843
866
  prNumber: parseInt(prNum, 10) || null,
@@ -850,9 +873,7 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
850
873
  created: new Date().toISOString(),
851
874
  url,
852
875
  prdItems: linkedWorkItemId ? [linkedWorkItemId] : [],
853
- _manual: true,
854
- _contextOnly: !autoObserve,
855
- _autoObserve: !!autoObserve,
876
+ contextOnly: resolvedContextOnly,
856
877
  _context: contextText,
857
878
  _projectResolution: projectResolution,
858
879
  }, {
@@ -863,9 +884,11 @@ function linkPullRequestForTracking({ url, title, project: projectName, autoObse
863
884
  }
864
885
 
865
886
  // W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
866
- // POST /api/pull-requests/observe. Flips `_contextOnly` / `_autoObserve` on
867
- // an existing tracked PR record under a lock (per CLAUDE.md mutate convention).
887
+ // POST /api/pull-requests/observe. Flips canonical `contextOnly` on an
888
+ // existing tracked PR record under a lock (per CLAUDE.md mutate convention).
868
889
  // Body shape: { host: 'github'|'ado', slug, number, observe: boolean }.
890
+ // The public `observe` body param is preserved for backward compatibility
891
+ // (W-mq5s5ttx000j7ab8-c); internally it maps to `contextOnly = !observe`.
869
892
  // Returns the updated record + the PR path that was touched. Throws an
870
893
  // Error with `statusCode` for the route handler to map to an HTTP status.
871
894
  function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
@@ -907,9 +930,8 @@ function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, conf
907
930
  shared.mutatePullRequests(prPath, (prs) => {
908
931
  const pr = prs.find(p => p && p.id === canonicalId);
909
932
  if (!pr) return prs;
910
- pr._contextOnly = !observe;
911
- pr._autoObserve = !!observe;
912
- updated = { id: pr.id, _contextOnly: pr._contextOnly, _autoObserve: pr._autoObserve };
933
+ pr.contextOnly = !observe;
934
+ updated = { id: pr.id, contextOnly: pr.contextOnly };
913
935
  updatedPath = prPath;
914
936
  return prs;
915
937
  });
@@ -1052,16 +1074,21 @@ function resolvePlanPath(file) {
1052
1074
  }
1053
1075
 
1054
1076
  // Test-mode banner: surfaced in <title> + <h1> + body class when the dashboard
1055
- // is started under MINIONS_TEST_DIR or on a non-default port. Makes it visually
1056
- // obvious that the user is looking at a sandboxed instance, not their live
1057
- // fleet — prevents acting on test fixtures by mistake.
1077
+ // is started under MINIONS_TEST_DIR or on a non-default REQUESTED port. Makes
1078
+ // it visually obvious that the user is looking at a sandboxed instance, not
1079
+ // their live fleet — prevents acting on test fixtures by mistake. Anchored to
1080
+ // REQUESTED_PORT (not the bound PORT) so an EADDRINUSE auto-fallback from
1081
+ // 7331 → 7332 does NOT promote a live dashboard to TEST-mode.
1058
1082
  function _isTestMode() {
1059
- return !!process.env.MINIONS_TEST_DIR || (typeof PORT === 'number' && PORT !== 7331);
1083
+ return !!process.env.MINIONS_TEST_DIR
1084
+ || (typeof REQUESTED_PORT === 'number' && REQUESTED_PORT !== shared.DEFAULT_DASHBOARD_PORT);
1060
1085
  }
1061
1086
 
1062
1087
  function _testBadgeLabel() {
1063
1088
  if (process.env.MINIONS_TEST_DIR) return 'TEST';
1064
- if (typeof PORT === 'number' && PORT !== 7331) return `TEST :${PORT}`;
1089
+ if (typeof REQUESTED_PORT === 'number' && REQUESTED_PORT !== shared.DEFAULT_DASHBOARD_PORT) {
1090
+ return `TEST :${REQUESTED_PORT}`;
1091
+ }
1065
1092
  return '';
1066
1093
  }
1067
1094
 
@@ -11351,7 +11378,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11351
11378
  // /api/prd/regenerate removed — use /api/plans/approve which does diff-aware update
11352
11379
 
11353
11380
  // Agents
11354
- { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, autoObserve?, context?, workItemId?', handler: async (req, res) => {
11381
+ { method: 'POST', path: '/api/pull-requests/link', desc: 'Manually link an external PR for tracking', params: 'url, title?, project?, contextOnly?, autoObserve? (deprecated alias for !contextOnly), context?, workItemId?', handler: async (req, res) => {
11355
11382
  const body = await readBody(req);
11356
11383
  const { url } = body;
11357
11384
  if (!url) return jsonReply(res, 400, { error: 'url required' });
@@ -11448,13 +11475,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11448
11475
  })();
11449
11476
  }},
11450
11477
 
11451
- { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle auto-observe (_contextOnly flag) on a tracked PR', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
11478
+ { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle canonical contextOnly flag on a tracked PR (public `observe` body param is preserved as the inverse for backward compat)', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
11452
11479
  const body = await readBody(req);
11453
11480
  reloadConfig();
11454
11481
  try {
11455
11482
  const result = updatePullRequestObserveFlag(body, CONFIG);
11456
11483
  invalidateStatusCache();
11457
- return jsonReply(res, 200, { ok: true, ...result, observe: !result._contextOnly });
11484
+ return jsonReply(res, 200, { ok: true, ...result, observe: !result.contextOnly });
11458
11485
  } catch (e) {
11459
11486
  return jsonReply(res, e.statusCode || 400, { error: e.message });
11460
11487
  }
@@ -12279,7 +12306,54 @@ if (require.main === module) {
12279
12306
  console.warn(`[boot] pre-warm git status failed: ${e && e.message}`);
12280
12307
  }
12281
12308
 
12282
- server.listen(PORT, '127.0.0.1', () => {
12309
+ // Listen with EADDRINUSE auto-fallback (W-mq5nwl9l). Tries REQUESTED_PORT
12310
+ // first; on EADDRINUSE scans upward through shared.DASHBOARD_PORT_SCAN_MAX
12311
+ // ports and binds to the first free one. The actually-bound port is
12312
+ // written to engine/dashboard-port.json as soon as listen resolves so
12313
+ // every downstream consumer (CLI status/dash/restart, cli-api-client,
12314
+ // supervisor) discovers it instead of guessing 7331. The original
12315
+ // server.on('error') one-shot exit is replaced by the retry loop — only
12316
+ // non-EADDRINUSE errors abort.
12317
+ const maxScan = shared.DASHBOARD_PORT_SCAN_MAX || 100;
12318
+ let listenErr = null;
12319
+ for (let attempt = 0; attempt < maxScan; attempt++) {
12320
+ const tryPort = REQUESTED_PORT + attempt;
12321
+ try {
12322
+ await new Promise((resolve, reject) => {
12323
+ const onError = (e) => { server.removeListener('listening', onListen); reject(e); };
12324
+ const onListen = () => { server.removeListener('error', onError); resolve(); };
12325
+ server.once('error', onError);
12326
+ server.once('listening', onListen);
12327
+ server.listen(tryPort, '127.0.0.1');
12328
+ });
12329
+ if (tryPort !== REQUESTED_PORT) {
12330
+ console.log(`[dashboard] requested port ${REQUESTED_PORT} in use, bound to ${tryPort}`);
12331
+ }
12332
+ PORT = tryPort;
12333
+ listenErr = null;
12334
+ break;
12335
+ } catch (e) {
12336
+ listenErr = e;
12337
+ if (e && e.code !== 'EADDRINUSE') break;
12338
+ // Loop to next candidate port.
12339
+ }
12340
+ }
12341
+ if (listenErr) {
12342
+ if (listenErr.code === 'EADDRINUSE') {
12343
+ console.error(`\n No free port in [${REQUESTED_PORT}..${REQUESTED_PORT + maxScan - 1}]. Free one and retry.\n`);
12344
+ } else {
12345
+ console.error(listenErr);
12346
+ }
12347
+ process.exit(1);
12348
+ }
12349
+
12350
+ // Persist the actually-bound port for every downstream consumer.
12351
+ try {
12352
+ shared.writeDashboardPortFile({ port: PORT, pid: process.pid, minionsHome: MINIONS_DIR });
12353
+ } catch (e) {
12354
+ console.warn(`[dashboard] could not write dashboard-port.json: ${e && e.message}`);
12355
+ }
12356
+
12283
12357
  console.log(`\n Minions Mission Control`);
12284
12358
  console.log(` -----------------------------------`);
12285
12359
  console.log(` http://localhost:${PORT}`);
@@ -12358,20 +12432,14 @@ if (require.main === module) {
12358
12432
  }
12359
12433
  }, 30000).unref();
12360
12434
  console.log(` Engine watchdog: active (checks every 30s)`);
12361
- });
12362
12435
  })();
12363
12436
 
12364
- server.on('error', e => {
12365
- if (e.code === 'EADDRINUSE') {
12366
- console.error(`\n Port ${PORT} already in use. Kill the existing process or change PORT.\n`);
12367
- } else {
12368
- console.error(e);
12369
- }
12370
- process.exit(1);
12371
- });
12372
-
12373
- // ── Graceful shutdown: flush debounced writes ──────────────────────────────
12374
- server.on('close', () => flushPendingDocSessions());
12375
- process.on('SIGTERM', () => { flushPendingDocSessions(); process.exit(0); });
12376
- process.on('SIGINT', () => { flushPendingDocSessions(); process.exit(0); });
12437
+ // ── Graceful shutdown: flush debounced writes + clear runtime port file ──
12438
+ function _gracefulShutdown() {
12439
+ try { flushPendingDocSessions(); } catch {}
12440
+ try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
12441
+ }
12442
+ server.on('close', () => _gracefulShutdown());
12443
+ process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });
12444
+ process.on('SIGINT', () => { _gracefulShutdown(); process.exit(0); });
12377
12445
  }