@yemi33/minions 0.1.2453 → 0.1.2455

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 (48) hide show
  1. package/bin/install-internal-minions.js +136 -6
  2. package/bin/minions.js +27 -13
  3. package/dashboard/js/agent-identity.js +121 -0
  4. package/dashboard/js/modal.js +4 -0
  5. package/dashboard/js/refresh.js +38 -3
  6. package/dashboard/js/render-agents.js +14 -2
  7. package/dashboard/js/render-dispatch.js +2 -2
  8. package/dashboard/js/render-other.js +147 -13
  9. package/dashboard/js/render-prd.js +6 -5
  10. package/dashboard/js/render-prs.js +44 -25
  11. package/dashboard/js/render-work-items.js +442 -22
  12. package/dashboard/js/settings.js +8 -0
  13. package/dashboard/js/utils.js +40 -0
  14. package/dashboard/pages/tools.html +1 -0
  15. package/dashboard/pages/work.html +13 -0
  16. package/dashboard/shared/pr-author.js +75 -0
  17. package/dashboard/shared/pr-filters.js +28 -5
  18. package/dashboard/styles.css +85 -0
  19. package/dashboard-build.js +3 -3
  20. package/dashboard.js +93 -7
  21. package/docs/README.md +1 -0
  22. package/docs/copilot-cli-schema.md +1 -0
  23. package/docs/engine-restart.md +4 -2
  24. package/docs/internal-install.md +44 -5
  25. package/docs/named-agents.md +48 -0
  26. package/docs/pr-author-identity.md +63 -10
  27. package/docs/runtime-adapters.md +39 -0
  28. package/docs/temporary-agents.md +172 -0
  29. package/engine/ado/comment.js +261 -4
  30. package/engine/agents/llm.js +26 -0
  31. package/engine/agents/playbook.js +2 -1
  32. package/engine/api/settings-validation.js +25 -0
  33. package/engine/core/operator-identity.js +23 -1
  34. package/engine/core/queries.js +53 -2
  35. package/engine/core/shared.js +286 -9
  36. package/engine/db/migrations/032-review-enrolled-pr-context-only.js +95 -0
  37. package/engine/operations/cli.js +102 -1
  38. package/engine/orchestration/lifecycle.js +7 -0
  39. package/engine/orchestration/routing.js +4 -1
  40. package/engine/providers/gh-comment.js +159 -0
  41. package/engine/recovery/stop-stack.js +16 -2
  42. package/engine/runtimes/claude.js +3 -0
  43. package/engine/runtimes/codex.js +4 -0
  44. package/engine/runtimes/copilot.js +23 -2
  45. package/engine.js +2 -2
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +23 -2
  48. package/playbooks/shared-rules.md +24 -2
@@ -1143,7 +1143,47 @@ const BACKUP_COPIED_FILES = ['config.json', 'routing.md', 'pinned.md'];
1143
1143
  /** Subdirectory of the backup holding the ORIGINAL, byte-for-byte state files. */
1144
1144
  const RETAINED_ORIGINAL_DIR = 'original';
1145
1145
 
1146
- const QUIESCE_TIMEOUT_MS = 60000;
1146
+ /**
1147
+ * The FLOOR of the wait budget, and the fixed window this gate used to have.
1148
+ *
1149
+ * The budget is now derived from the runtime being stopped (see
1150
+ * `resolveQuiesceTimeoutMs`), but it may only ever WIDEN from here: a runtime
1151
+ * configured with a very short drain must not make the migration gate stricter
1152
+ * than it has always been. It is also `stop-stack.js#DEFAULT_STOP_TIMEOUT_MS`,
1153
+ * so the wait after a stop is never shorter than the stop's own budget.
1154
+ */
1155
+ const QUIESCE_TIMEOUT_FLOOR_MS = 60000;
1156
+
1157
+ /**
1158
+ * The absolute CEILING on the wait budget.
1159
+ *
1160
+ * The derived budget is operator-supplied data read from a file this gate does
1161
+ * not own, so it is bounded: a corrupt or hostile `config.json` must not be able
1162
+ * to hang a migration indefinitely on an unattended machine. 15 minutes is ~3x
1163
+ * the default runtime's own budget, so it clamps nothing a real stack needs.
1164
+ */
1165
+ const QUIESCE_TIMEOUT_CEILING_MS = 900000;
1166
+
1167
+ /**
1168
+ * Grace added on top of the engine's own drain budget, mirroring
1169
+ * `engine/recovery/stop-stack.js#DRAIN_GRACE_MS` — the SAME number `minions
1170
+ * restart` applies when it derives a teardown deadline the same way. It covers
1171
+ * the fixed work either side of the drain: stop intent, the supervisor and
1172
+ * dashboard, the verified reap, and the WAL-index release.
1173
+ */
1174
+ const QUIESCE_DRAIN_GRACE_MS = 5000;
1175
+
1176
+ /**
1177
+ * Mirror of `engine/core/shared.js#ENGINE_DEFAULTS.shutdownTimeout` — the drain
1178
+ * budget a runtime uses when its config does not name one.
1179
+ *
1180
+ * Mirrored rather than imported because this script is deliberately standalone
1181
+ * (it must run before any Minions exists to import from). A drift test in
1182
+ * `test/unit/install-internal-minions.test.js` binds both this and the grace
1183
+ * above to their engine-side originals, so the copies cannot silently diverge.
1184
+ */
1185
+ const ENGINE_SHUTDOWN_TIMEOUT_FALLBACK_MS = 300000;
1186
+
1147
1187
  const QUIESCE_POLL_MS = 500;
1148
1188
  const CLI_STOP_TIMEOUT_MS = 120000;
1149
1189
  /** Budget for the read-only capability probe: `minions help` prints and exits. */
@@ -1251,7 +1291,9 @@ function probeStopCapability({ cliPath, run, env, timeoutMs = CLI_HELP_TIMEOUT_M
1251
1291
  * Reports which stop contract ran and the exact argv it issued — without that,
1252
1292
  * "the runtime did not release the database" is indistinguishable between a
1253
1293
  * whole-stack teardown that genuinely could not finish and a legacy fallback
1254
- * that only ever asked the engine to stand down.
1294
+ * that only ever asked the engine to stand down. It also reports the budget that
1295
+ * expired and where that budget came from, so "it gave up too early" is an
1296
+ * answerable question rather than a guess.
1255
1297
  */
1256
1298
  function formatQuiescenceRefusal(quiescence = {}) {
1257
1299
  const stopPath = quiescence.stopPath || 'none';
@@ -1262,6 +1304,9 @@ function formatQuiescenceRefusal(quiescence = {}) {
1262
1304
  ? ` (exit code ${quiescence.stopExitCode})`
1263
1305
  : '';
1264
1306
  const lines = [` Stop path: ${STOP_PATH_LABELS[stopPath] || STOP_PATH_LABELS.none}${issued}${exit}`];
1307
+ if (quiescence.budget) {
1308
+ lines.push(` Waited ${quiescence.waitedMs}ms of ${describeQuiesceBudget(quiescence.budget)}`);
1309
+ }
1265
1310
  const holders = Array.isArray(quiescence.holders) ? quiescence.holders : [];
1266
1311
  if (holders.length) {
1267
1312
  lines.push(' Still holding the database:');
@@ -1356,6 +1401,68 @@ function assessDatabaseQuiescence({ running = [], shm = { present: false, bytes:
1356
1401
  return { quiesced: true, holders: [], shmHeld: false, reason: 'no service holds engine/state.db' };
1357
1402
  }
1358
1403
 
1404
+ /**
1405
+ * The wait budget, DERIVED from the runtime this gate is stopping.
1406
+ *
1407
+ * A fixed window cannot be right: it is the *runtime* that decides how long a
1408
+ * teardown legitimately takes, through `engine.shutdownTimeout` — the budget the
1409
+ * engine gives itself to drain pooled leases before exiting. The old fixed 60s
1410
+ * was shorter than the DEFAULT drain budget of five minutes, so a busy stack was
1411
+ * declared stuck while it was still shutting down exactly as designed, and the
1412
+ * migration refused a runtime that was working.
1413
+ *
1414
+ * `minions restart` already derives its teardown deadline this way; this reads
1415
+ * the same key, from the PINNED root, and adds the same grace — one contract,
1416
+ * two callers, no second number.
1417
+ *
1418
+ * Bounded on BOTH sides, because the input is operator-supplied data read from a
1419
+ * file this gate does not own:
1420
+ * - FLOOR never stricter than the fixed window it replaces
1421
+ * - CEILING a corrupt or hostile value cannot hang a migration indefinitely
1422
+ *
1423
+ * Never throws: a missing, unreadable, malformed, or nonsensical config is a
1424
+ * fallback to the engine default, not a failure. A value that is neither a
1425
+ * number nor a numeric string is rejected outright — `Number(true)` is `1` and
1426
+ * `Number([30000])` is `30000`, and silently accepting either would produce a
1427
+ * budget nobody configured.
1428
+ *
1429
+ * @param {object} opts
1430
+ * @param {string} opts.runtimeRoot the PINNED root, not the installer's own
1431
+ * @param {object} [opts.deps] `{ readFileSync }` injection seam
1432
+ * @returns {{timeoutMs:number, shutdownTimeoutMs:number, source:'config'|'default', clamped:'floor'|'ceiling'|null}}
1433
+ */
1434
+ function resolveQuiesceTimeoutMs({ runtimeRoot, deps = {} } = {}) {
1435
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
1436
+ let shutdownTimeoutMs = ENGINE_SHUTDOWN_TIMEOUT_FALLBACK_MS;
1437
+ let source = 'default';
1438
+ try {
1439
+ const parsed = JSON.parse(String(readFile(path.join(runtimeRoot, 'config.json'))));
1440
+ const engine = parsed && typeof parsed === 'object' ? parsed.engine : null;
1441
+ const raw = engine && typeof engine === 'object' ? engine.shutdownTimeout : undefined;
1442
+ const configured = (typeof raw === 'number' || typeof raw === 'string') ? Number(raw) : NaN;
1443
+ if (Number.isFinite(configured) && configured > 0) {
1444
+ shutdownTimeoutMs = configured;
1445
+ source = 'config';
1446
+ }
1447
+ } catch { /* missing, unreadable, or malformed — the engine default is the safe answer */ }
1448
+ const derived = shutdownTimeoutMs + QUIESCE_DRAIN_GRACE_MS;
1449
+ const timeoutMs = Math.min(QUIESCE_TIMEOUT_CEILING_MS, Math.max(QUIESCE_TIMEOUT_FLOOR_MS, derived));
1450
+ let clamped = null;
1451
+ if (timeoutMs !== derived) clamped = timeoutMs === QUIESCE_TIMEOUT_CEILING_MS ? 'ceiling' : 'floor';
1452
+ return { timeoutMs, shutdownTimeoutMs, source, clamped };
1453
+ }
1454
+
1455
+ /** One operator-facing line for how long this gate will wait, and why. */
1456
+ function describeQuiesceBudget(budget = {}) {
1457
+ const ms = Number(budget.timeoutMs) || 0;
1458
+ if (budget.source === 'explicit') return `${ms}ms (caller-supplied)`;
1459
+ const from = budget.source === 'config'
1460
+ ? `engine.shutdownTimeout=${budget.shutdownTimeoutMs}`
1461
+ : `the engine default drain of ${budget.shutdownTimeoutMs}ms`;
1462
+ const bound = budget.clamped ? `, clamped to the ${budget.clamped}` : '';
1463
+ return `${ms}ms (${from} + ${QUIESCE_DRAIN_GRACE_MS}ms grace${bound})`;
1464
+ }
1465
+
1359
1466
  /** Block the calling thread. The installer is deliberately spawnSync-only. */
1360
1467
  function sleepSync(ms) {
1361
1468
  try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(ms) || 0); }
@@ -1376,6 +1483,11 @@ function sleepSync(ms) {
1376
1483
  * `stop`. Both paths then run the same wait loop, because the CLI's own verdict
1377
1484
  * is never trusted — the `-shm` evidence below is.
1378
1485
  *
1486
+ * The wait budget is DERIVED from the pinned runtime's own drain budget (see
1487
+ * `resolveQuiesceTimeoutMs`) so a stack that is legitimately slow to shut down
1488
+ * is waited out rather than declared stuck. An explicit `timeoutMs` is a
1489
+ * deliberate caller instruction and wins verbatim.
1490
+ *
1379
1491
  * FAILS CLOSED: when the handles are not released inside the timeout the caller
1380
1492
  * gets `ok: false`, the path that was taken, and the exact PIDs still holding
1381
1493
  * the database.
@@ -1386,18 +1498,21 @@ function quiesceServices({
1386
1498
  run,
1387
1499
  env,
1388
1500
  log = console.log,
1389
- timeoutMs = QUIESCE_TIMEOUT_MS,
1501
+ timeoutMs = null,
1390
1502
  pollMs = QUIESCE_POLL_MS,
1391
1503
  probeTimeoutMs = CLI_HELP_TIMEOUT_MS,
1392
1504
  deps = {},
1393
1505
  sleep = sleepSync,
1394
1506
  now = () => Date.now(),
1395
1507
  } = {}) {
1508
+ const explicit = Number(timeoutMs);
1509
+ const budget = Number.isFinite(explicit) && explicit > 0
1510
+ ? { timeoutMs: explicit, shutdownTimeoutMs: null, source: 'explicit', clamped: null }
1511
+ : resolveQuiesceTimeoutMs({ runtimeRoot, deps });
1396
1512
  const probeState = () => assessDatabaseQuiescence({
1397
1513
  running: detectRunningServices(runtimeRoot, deps),
1398
1514
  shm: readStateShm(runtimeRoot, deps),
1399
1515
  });
1400
- const startedAt = now();
1401
1516
  const initial = probeState();
1402
1517
  if (initial.quiesced) {
1403
1518
  return {
@@ -1409,10 +1524,12 @@ function quiesceServices({
1409
1524
  capabilityProbe: null,
1410
1525
  holders: [],
1411
1526
  waitedMs: 0,
1527
+ budget,
1412
1528
  reason: initial.reason,
1413
1529
  };
1414
1530
  }
1415
1531
  log(` ${initial.reason}`);
1532
+ log(` Waiting up to ${describeQuiesceBudget(budget)}.`);
1416
1533
  let stopped = false;
1417
1534
  let stopPath = 'none';
1418
1535
  let stopArgs = null;
@@ -1439,7 +1556,13 @@ function quiesceServices({
1439
1556
  } else {
1440
1557
  log(' WARNING: no installed CLI was found to run `minions stop`; waiting for the handles to be released.');
1441
1558
  }
1442
- const deadline = now() + timeoutMs;
1559
+ // The budget bounds THIS loop only — the capability probe and the stop
1560
+ // subprocess above run before it and are reported separately. Measuring the
1561
+ // wait from any earlier point would let a slow stop print a wait larger than
1562
+ // the budget that allowed it, i.e. a refusal that accuses the gate of
1563
+ // overrunning a deadline it actually honoured.
1564
+ const waitStartedAt = now();
1565
+ const deadline = waitStartedAt + budget.timeoutMs;
1443
1566
  let verdict = probeState();
1444
1567
  while (!verdict.quiesced && now() < deadline) {
1445
1568
  sleep(pollMs);
@@ -1453,7 +1576,8 @@ function quiesceServices({
1453
1576
  stopExitCode,
1454
1577
  capabilityProbe,
1455
1578
  holders: verdict.holders,
1456
- waitedMs: now() - startedAt,
1579
+ waitedMs: now() - waitStartedAt,
1580
+ budget,
1457
1581
  reason: verdict.reason,
1458
1582
  };
1459
1583
  }
@@ -2960,6 +3084,12 @@ module.exports = {
2960
3084
  detectRunningServices,
2961
3085
  readStateShm,
2962
3086
  assessDatabaseQuiescence,
3087
+ QUIESCE_TIMEOUT_FLOOR_MS,
3088
+ QUIESCE_TIMEOUT_CEILING_MS,
3089
+ QUIESCE_DRAIN_GRACE_MS,
3090
+ ENGINE_SHUTDOWN_TIMEOUT_FALLBACK_MS,
3091
+ resolveQuiesceTimeoutMs,
3092
+ describeQuiesceBudget,
2963
3093
  WHOLE_STACK_STOP_ARGS,
2964
3094
  LEGACY_STOP_ARGS,
2965
3095
  CLI_CAPABILITY_PROBE_ARGS,
package/bin/minions.js CHANGED
@@ -85,7 +85,7 @@ const {
85
85
  stopStackExitCode,
86
86
  holderIsLiveDaemon,
87
87
  STOP_STACK_EXIT,
88
- DEFAULT_STOP_TIMEOUT_MS: STOP_STACK_DEFAULT_TIMEOUT_MS,
88
+ DRAIN_GRACE_MS: STOP_STACK_DRAIN_GRACE_MS,
89
89
  } = require(path.join(PKG_ROOT, 'engine', 'recovery', 'stop-stack'));
90
90
  // Dev-mode dashboard port. `--dev` mode (see argv parser below) retargets
91
91
  // MINIONS_HOME at PKG_ROOT and routes the dashboard onto this port so a dev
@@ -681,13 +681,30 @@ function stopWholeRuntimeStack({ source, timeoutMs, waitForRelease = false }) {
681
681
  return verdict;
682
682
  }
683
683
 
684
- /** `--timeout <ms>` / `--timeout=<ms>` for `minions stop --all`. Defaults to
685
- * the shared module's budget, which matches the internal installer's
686
- * quiescence window so a stop can never be shorter than the wait after it. */
684
+ /** The pinned runtime's configured graceful-drain budget, falling back to the
685
+ * engine default when config.json is missing, unreadable, or does not name a
686
+ * positive one. Shared by `restart` and `stop --all` so both teardown paths
687
+ * hand the engine the SAME drain window its own graceful handler expects. */
688
+ function _resolveConfiguredShutdownTimeoutMs() {
689
+ try {
690
+ const cfg = JSON.parse(fs.readFileSync(path.join(MINIONS_HOME, 'config.json'), 'utf8'));
691
+ return Number(cfg?.engine?.shutdownTimeout) || shared.ENGINE_DEFAULTS.shutdownTimeout;
692
+ } catch { return shared.ENGINE_DEFAULTS.shutdownTimeout; }
693
+ }
694
+
695
+ /** `--timeout <ms>` / `--timeout=<ms>` for `minions stop --all`. With no flag it
696
+ * DERIVES the budget from the pinned runtime's `engine.shutdownTimeout` plus the
697
+ * drain grace — exactly as `minions restart` does — so a whole-stack stop is
698
+ * given the engine's own drain window rather than a flat default that would
699
+ * force-reap a slow drain mid-flight (the internal installer's quiescence gate
700
+ * shells `stop --all --wait` with no `--timeout` and depends on that budget
701
+ * reaching the teardown). Never shorter than the installer's quiescence floor:
702
+ * the shared module's default is that floor, and the engine drain default is
703
+ * larger still. */
687
704
  function _resolveStopAllTimeoutMs(argv) {
688
705
  const flagIdx = argv.indexOf('--timeout');
689
706
  const inline = argv.find(a => typeof a === 'string' && a.startsWith('--timeout='));
690
- if (flagIdx < 0 && !inline) return STOP_STACK_DEFAULT_TIMEOUT_MS;
707
+ if (flagIdx < 0 && !inline) return _resolveConfiguredShutdownTimeoutMs() + STOP_STACK_DRAIN_GRACE_MS;
691
708
  const raw = flagIdx >= 0 ? argv[flagIdx + 1] : inline.slice('--timeout='.length);
692
709
  const n = Number(raw);
693
710
  if (!Number.isInteger(n) || n <= 0) {
@@ -1748,13 +1765,10 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1748
1765
  const restartStartMs = Date.now();
1749
1766
  // Give the engine's graceful handler time to drain non-reattachable pooled
1750
1767
  // leases. Detached cold agents are not part of that drain and remain alive
1751
- // for normal PID-file reattachment by the replacement engine.
1752
- const shutdownTimeout = (() => {
1753
- try {
1754
- const cfg = JSON.parse(fs.readFileSync(path.join(MINIONS_HOME, 'config.json'), 'utf8'));
1755
- return Number(cfg?.engine?.shutdownTimeout) || shared.ENGINE_DEFAULTS.shutdownTimeout;
1756
- } catch { return shared.ENGINE_DEFAULTS.shutdownTimeout; }
1757
- })();
1768
+ // for normal PID-file reattachment by the replacement engine. The same budget
1769
+ // backs `stop --all` (see _resolveStopAllTimeoutMs) so both teardown paths
1770
+ // honour the runtime's own drain window.
1771
+ const shutdownTimeout = _resolveConfiguredShutdownTimeoutMs();
1758
1772
  // Ordered teardown — stop-intent first so the supervisor stands down and
1759
1773
  // cannot race-respawn what we are about to stop, then supervisor, dashboard,
1760
1774
  // graceful engine drain, verified reap, and the late-respawn sweep that
@@ -1764,7 +1778,7 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
1764
1778
  // stack, and the port gate below is its real readiness condition.
1765
1779
  const teardown = stopWholeRuntimeStack({
1766
1780
  source: 'minions restart',
1767
- timeoutMs: shutdownTimeout + 5000,
1781
+ timeoutMs: shutdownTimeout + STOP_STACK_DRAIN_GRACE_MS,
1768
1782
  waitForRelease: false,
1769
1783
  });
1770
1784
  // A holder that is provably NOT one of our daemons (a stale PID file, a
@@ -0,0 +1,121 @@
1
+ // dashboard/js/agent-identity.js — shared agent display-identity formatter.
2
+ //
3
+ // Single client-side seam every user-facing renderer uses to render an agent's
4
+ // friendly display name plus, for dynamically-created temp agents, a visible
5
+ // "Temp" badge. The canonical agent id (`temp-<uid>`) is NEVER shown as the
6
+ // primary name in normal UI, but stays discoverable via title/tooltip.
7
+ //
8
+ // The server already stamps a deterministic friendly `agentName` / `name` on
9
+ // temp dispatch, work-item, and roster payloads (engine/core/shared.js
10
+ // #tempAgentCallSign). The `callSign()` fallback below mirrors that algorithm
11
+ // verbatim so a legacy record missing the friendly name still renders a stable
12
+ // call sign rather than a raw `temp-msd…` hash. Keep this vocabulary + hash in
13
+ // sync with engine/core/shared.js (asserted by
14
+ // test/unit/temp-agent-identity.test.js).
15
+ (function () {
16
+ 'use strict';
17
+
18
+ // Mirror of engine/core/shared.js TEMP_AGENT_CALL_SIGN_ADJECTIVES / _NAMES.
19
+ var ADJECTIVES = [
20
+ 'Cosmic', 'Starry', 'Nebular', 'Twinkly', 'Lunar', 'Solar', 'Astral', 'Galactic',
21
+ 'Stellar', 'Orbital', 'Meteoric', 'Cloudy', 'Sunny', 'Radiant', 'Sparkly', 'Dreamy',
22
+ 'Breezy', 'Drifting', 'Glowing', 'Floaty', 'Skybound', 'Wandering', 'Whirling', 'Shimmery',
23
+ ];
24
+ var NAMES = [
25
+ 'Comet', 'Nova', 'Luna', 'Sol', 'Astro', 'Orion', 'Nebula', 'Pluto',
26
+ 'Halley', 'Vega', 'Rigel', 'Sirius', 'Polaris', 'Stardust', 'Cosmo', 'Nimbus',
27
+ 'Zephyr', 'Aurora', 'Meteor', 'Galaxy', 'Twinkle', 'Cirrus', 'Cygnus', 'Juno',
28
+ ];
29
+
30
+ function isTemp(idOrObj) {
31
+ var id = _idOf(idOrObj);
32
+ return typeof id === 'string' && /^temp-/i.test(id.trim());
33
+ }
34
+
35
+ // FNV-1a 32-bit — mirror of engine/core/shared.js#_fnv1a32.
36
+ function _fnv1a32(str) {
37
+ var h = 0x811c9dc5;
38
+ for (var i = 0; i < str.length; i++) {
39
+ h ^= str.charCodeAt(i);
40
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
41
+ }
42
+ return h >>> 0;
43
+ }
44
+
45
+ // Deterministic friendly call sign from a canonical temp id, e.g. "Cosmic Comet".
46
+ // Returns null for non-temp / malformed ids.
47
+ function callSign(idOrObj) {
48
+ var id = _idOf(idOrObj);
49
+ if (!isTemp(id)) return null;
50
+ var key = id.trim().toLowerCase();
51
+ // Single hash of the canonical id, then slice independent index streams from
52
+ // its low and high bits (mirror of engine/core/shared.js#tempAgentCallSign).
53
+ // Hashing the bare id once keeps its full entropy in BOTH indices; a trailing
54
+ // ":adj"/":name" discriminator would let the identical suffix dominate the
55
+ // FNV-1a low bits and collapse the joint distribution for the long-shared-
56
+ // prefix ids that concurrent temp allocation produces.
57
+ var h = _fnv1a32(key);
58
+ var adj = ADJECTIVES[h % ADJECTIVES.length];
59
+ var name = NAMES[Math.floor(h / ADJECTIVES.length) % NAMES.length];
60
+ return adj + ' ' + name;
61
+ }
62
+
63
+ function _idOf(idOrObj) {
64
+ if (idOrObj && typeof idOrObj === 'object') {
65
+ return idOrObj.id || idOrObj.agent || idOrObj.dispatched_to || '';
66
+ }
67
+ return idOrObj;
68
+ }
69
+
70
+ // Friendly display name string (UNESCAPED — callers escape). Prefers a
71
+ // server-supplied friendly name, then the deterministic call sign, then the
72
+ // canonical id. Never leads with `temp-…` when a call sign is derivable.
73
+ function displayName(idOrObj) {
74
+ var provided = null;
75
+ var id = _idOf(idOrObj);
76
+ if (idOrObj && typeof idOrObj === 'object') {
77
+ provided = idOrObj.name || idOrObj.agentName || idOrObj.displayName || null;
78
+ }
79
+ if (isTemp(id)) {
80
+ // A stored friendly name wins (stable across the record's life); otherwise
81
+ // derive deterministically. Guard against a legacy raw `temp-…` slipping
82
+ // through as `provided`.
83
+ if (provided && !/^temp-/i.test(String(provided).trim())) return provided;
84
+ return callSign(id) || id || '';
85
+ }
86
+ return provided || id || '';
87
+ }
88
+
89
+ var _esc = (typeof escapeHtml === 'function')
90
+ ? escapeHtml
91
+ : function (s) {
92
+ return String(s == null ? '' : s)
93
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
94
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
95
+ };
96
+
97
+ // Static, XSS-safe "Temp" badge markup. `title` carries the canonical id for
98
+ // debugging; the visible + aria-labelled "Temp" text keeps the temporary
99
+ // status unmistakable to sighted and assistive-tech users alike.
100
+ function badgeHtml(idOrObj) {
101
+ var id = _idOf(idOrObj);
102
+ var title = id ? ' title="Temporary agent — ' + _esc(id) + '"' : ' title="Temporary agent"';
103
+ return '<span class="agent-temp-badge" role="img" aria-label="Temporary agent"' + title + '>Temp</span>';
104
+ }
105
+
106
+ // Escaped friendly name + trailing Temp badge (for temp agents). Safe to
107
+ // inject as innerHTML. This is the single formatter renderers should use.
108
+ function nameWithBadgeHtml(idOrObj) {
109
+ var name = _esc(displayName(idOrObj));
110
+ if (isTemp(idOrObj)) return name + ' ' + badgeHtml(idOrObj);
111
+ return name;
112
+ }
113
+
114
+ window.MinionsAgentIdentity = {
115
+ isTemp: isTemp,
116
+ callSign: callSign,
117
+ displayName: displayName,
118
+ badgeHtml: badgeHtml,
119
+ nameWithBadgeHtml: nameWithBadgeHtml,
120
+ };
121
+ })();
@@ -14,6 +14,10 @@ function closeModal() {
14
14
  }
15
15
  const modalEl = document.querySelector('#modal .modal');
16
16
  if (modalEl) modalEl.classList.remove('modal-wide');
17
+ // The modal subtree is about to be hidden — dismiss any transient popup
18
+ // anchored inside it while contains() can still identify its anchor. Scoped,
19
+ // so a popover anchored elsewhere on the page is untouched.
20
+ if (typeof _dismissModalTransientPopups === 'function') _dismissModalTransientPopups();
17
21
  document.getElementById('modal').classList.remove('open');
18
22
  if (typeof popModalFrame === 'function') popModalFrame();
19
23
  else if (typeof clearModalBackStack === 'function') clearModalBackStack();
@@ -202,7 +202,11 @@ const RENDER_VERSIONS = {
202
202
  // Bumped 22→23: 'paused' joins the lifecycle order between failed and the
203
203
  // terminal tail (it was unranked, so it sorted behind every done row in both
204
204
  // directions) — row ORDER changes again for byte-identical input.
205
- workItems: 23,
205
+ // Bumped 23→24 (W-mscjvm33007r79ef): per-item actions (except Delete) moved
206
+ // into an overflow (⋯) menu and an opt-in bulk-selection mode adds an optional
207
+ // leading checkbox column + row-selected styling — row MARKUP changes for
208
+ // byte-identical input.
209
+ workItems: 24,
206
210
  skills: 1,
207
211
  commands: 1,
208
212
  mcpServers: 1,
@@ -519,6 +523,21 @@ function _refreshSidebarCounterSummaries() {
519
523
  // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
520
524
  }
521
525
 
526
+ function _setToolsRefreshStatus(state) {
527
+ var el = document.getElementById('tools-refresh-status');
528
+ if (!el) return;
529
+ var text = '';
530
+ if (state === 'loading') {
531
+ text = 'Discovering skills, commands & MCP servers…';
532
+ } else if (state === 'error-stale') {
533
+ text = 'Showing last known inventory — refresh failed. Retrying…';
534
+ } else if (state === 'error-cold') {
535
+ text = 'Could not load skills inventory. Retrying…';
536
+ }
537
+ el.textContent = text;
538
+ el.style.display = text ? 'block' : 'none';
539
+ }
540
+
522
541
  function _renderToolsInventory(tools) {
523
542
  if (!tools || !Array.isArray(tools.skills)
524
543
  || !Array.isArray(tools.commands)
@@ -988,20 +1007,36 @@ function _processStatusUpdate(data, opts) {
988
1007
  // Skills/commands/MCP discovery runs in a child process behind /api/tools.
989
1008
  // It is only consumed by the Tools page, so every other page skips the
990
1009
  // request and /api/status stays a small, non-blocking health envelope.
1010
+ //
1011
+ // Perceived latency: /api/tools serves a cached inventory immediately and
1012
+ // refreshes in the background (stale-while-revalidate on the server), so the
1013
+ // page renders from last-known data instead of blocking on the multi-second
1014
+ // child scan. The status line below represents the only user-visible states:
1015
+ // the true cold-start wait (no data yet) and a refresh failure while stale
1016
+ // data is shown.
991
1017
  _safeRender('toolsInventory', function() {
992
1018
  if (typeof currentPage !== 'undefined' && currentPage !== 'tools') return;
993
1019
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
994
1020
  window._lastRequestedSeq = window._lastRequestedSeq || {};
995
1021
  window._lastRequestedSeq.toolsInventory = seq;
1022
+ if (!window._lastToolsInventory) _setToolsRefreshStatus('loading');
996
1023
  _condFetchJson('/api/tools')
997
1024
  .then(function(resp) {
998
- if (seq < (window._lastRequestedSeq.toolsInventory || 0) || resp.notModified) return;
1025
+ if (seq < (window._lastRequestedSeq.toolsInventory || 0)) return;
1026
+ if (resp.notModified) { _setToolsRefreshStatus('idle'); return; }
999
1027
  const tools = resp.data;
1000
1028
  if (_renderToolsInventory(tools)) window._lastToolsInventory = tools;
1029
+ _setToolsRefreshStatus('idle');
1001
1030
  })
1002
1031
  .catch(function() {
1032
+ if (seq < (window._lastRequestedSeq.toolsInventory || 0)) return;
1003
1033
  const tools = window._lastToolsInventory;
1004
- if (tools) _renderToolsInventory(tools);
1034
+ if (tools) {
1035
+ _renderToolsInventory(tools);
1036
+ _setToolsRefreshStatus('error-stale');
1037
+ } else {
1038
+ _setToolsRefreshStatus('error-cold');
1039
+ }
1005
1040
  });
1006
1041
  });
1007
1042
  // Harness propagation diagnostic comes from /api/harness/diagnostics.
@@ -80,7 +80,7 @@ function renderAgents(agents) {
80
80
  grid.innerHTML = agents.map(a => `
81
81
  <div class="agent-card ${statusColor(a.status)}" data-agent-id="${escapeHtml(a.id)}" onclick="if(shouldIgnoreSelectionClick(event))return;openAgentDetail(this.dataset.agentId)">
82
82
  <div class="agent-card-header">
83
- <span class="agent-name"><span class="agent-emoji">${escapeHtml(a.emoji)}</span>${escapeHtml(a.name)}${_runtimeTagHtml(a.runtime)}${_modelChipHtml(a.displayModel || a.model)}</span>
83
+ <span class="agent-name"><span class="agent-emoji">${escapeHtml(a.emoji)}</span>${MinionsAgentIdentity.nameWithBadgeHtml(a)}${_runtimeTagHtml(a.runtime)}${_modelChipHtml(a.displayModel || a.model)}</span>
84
84
  <span class="status-badge ${escapeHtml(a.status)}">${escapeHtml(a.status)}</span>
85
85
  </div>
86
86
  <div class="agent-role">${escapeHtml(a.role)}</div>
@@ -158,9 +158,21 @@ async function openAgentDetail(id) {
158
158
  modelSpan.textContent = modelLabel;
159
159
  const children = [
160
160
  emojiSpan,
161
- document.createTextNode(' ' + (agent.name || '') + ' \u2014 ' + (agent.role || '')),
161
+ document.createTextNode(' ' + (MinionsAgentIdentity.displayName(agent) || agent.name || '') + ' \u2014 ' + (agent.role || '')),
162
162
  runtimeSpan,
163
163
  ];
164
+ // Temp agents get a visible + aria-labelled "Temp" badge; the canonical id
165
+ // stays in the title for debugging. Built via DOM so no user data is ever
166
+ // interpreted as HTML (SEC-03 detail-header contract).
167
+ if (MinionsAgentIdentity.isTemp(agent)) {
168
+ const tempBadge = document.createElement('span');
169
+ tempBadge.className = 'agent-temp-badge';
170
+ tempBadge.setAttribute('role', 'img');
171
+ tempBadge.setAttribute('aria-label', 'Temporary agent');
172
+ tempBadge.title = 'Temporary agent — ' + (agent.id || '');
173
+ tempBadge.textContent = 'Temp';
174
+ children.push(tempBadge);
175
+ }
164
176
  children.push(modelSpan);
165
177
  nameEl.replaceChildren(...children);
166
178
 
@@ -303,7 +303,7 @@ function renderDispatch(dispatch, opts) {
303
303
  const dispatchItemHtml = (d, trailing) =>
304
304
  '<div class="dispatch-item">' +
305
305
  '<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
306
- '<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
306
+ '<span class="dispatch-agent">' + MinionsAgentIdentity.nameWithBadgeHtml({ id: d.agent, name: d.agentName }) + '</span>' +
307
307
  '<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
308
308
  renderStuckChip(d) +
309
309
  trailing +
@@ -359,7 +359,7 @@ function renderDispatch(dispatch, opts) {
359
359
  : '';
360
360
  return '<tr>' +
361
361
  '<td><span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span></td>' +
362
- '<td>' + escHtml(d.agentName || d.agent || '') + '</td>' +
362
+ '<td>' + MinionsAgentIdentity.nameWithBadgeHtml({ id: d.agent, name: d.agentName }) + '</td>' +
363
363
  '<td style="width:100%;max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml((d.task || '').slice(0, 60)) + '</td>' +
364
364
  '<td style="color:' + (d.result === 'success' ? 'var(--green)' : 'var(--red)') + '">' + escHtml(d.result || '') + errorBtn + '</td>' +
365
365
  '<td class="pr-date">' + shortTime(d.completed_at) + '</td>' +