@yemi33/minions 0.1.2362 → 0.1.2364

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.
@@ -1286,6 +1286,9 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1286
1286
  updateStreamDiv();
1287
1287
  } else if (evt.type === 'heartbeat') {
1288
1288
  return;
1289
+ } else if (evt.type === 'status') {
1290
+ streamStatusNote = evt.message || '';
1291
+ updateStreamDiv();
1289
1292
  } else if (evt.type === 'tool') {
1290
1293
  ccSegmentsApplyTool(segments, { name: evt.name, input: evt.input || {}, id: evt.id || null });
1291
1294
  if (activeTab) activeTab._segments = segments;
package/dashboard.js CHANGED
@@ -2644,15 +2644,14 @@ function _getMtimes() {
2644
2644
  return result;
2645
2645
  }
2646
2646
 
2647
- // Reset the per-source caches whose TTL would otherwise serve stale data
2648
- // across rebuilds. Skill files (`queries._skillsCache` 30s) and MCP servers
2649
- // (`_mcpServersCache` 5min) are the two with TTLs longer than our poll
2650
- // cadence, and both back slices the user sees on screen. Called on every
2651
- // mtime-detected rebuild so the rebuild always reads fresh disk state.
2647
+ // Reset per-source caches whose TTL would otherwise survive an mtime-triggered
2648
+ // status rebuild and serve stale data.
2652
2649
  function _invalidateInnerCachesForRebuild() {
2653
2650
  try { queries.invalidateSkillsCache(); } catch { /* optional */ }
2654
2651
  _mcpServersCache = null;
2655
2652
  _mcpServersCacheTs = 0;
2653
+ _diskVersionCache = null;
2654
+ _diskVersionCacheTs = 0;
2656
2655
  }
2657
2656
 
2658
2657
  function _mtimesChanged(prev, curr) {
@@ -3706,6 +3705,7 @@ function _ensureCcLiveStream(tabId) {
3706
3705
  tabId,
3707
3706
  text: '',
3708
3707
  tools: [],
3708
+ statusMessage: '',
3709
3709
  donePayload: null,
3710
3710
  writer: null,
3711
3711
  endResponse: null,
@@ -6341,6 +6341,11 @@ const server = http.createServer(async (req, res) => {
6341
6341
  if (!wiPath) {
6342
6342
  const prdFile = body.prdFile;
6343
6343
  if (!prdFile) return jsonReply(res, 404, { error: 'work item not found in any source' });
6344
+ // W-mrdykt9m0006e459 — prdFile is unauthenticated client input; reject
6345
+ // traversal/absolute paths and anything that resolves outside PRD_DIR
6346
+ // before it ever reaches a filesystem read.
6347
+ try { shared.sanitizePath(prdFile, PRD_DIR); }
6348
+ catch { return jsonReply(res, 400, { error: 'invalid prdFile path' }); }
6344
6349
 
6345
6350
  // Look up PRD item to create a new work item on-demand.
6346
6351
  // safeJsonNoRestore — PRDs are terminal artifacts; a stale .backup
@@ -10048,6 +10053,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10048
10053
  if (!hasImages && shared.resolveCcUseWorkerPool(engineConfig)) {
10049
10054
  return _invokeCcStreamViaPool({ prompt, liveState, toolUses, model, effort, engineConfig, systemPrompt, tabId });
10050
10055
  }
10056
+ const setStatus = (message) => {
10057
+ liveState.statusMessage = message;
10058
+ if (liveState.writer) liveState.writer({ type: 'status', message });
10059
+ };
10060
+ if (hasImages) {
10061
+ _touchCcLiveStream(liveState);
10062
+ setStatus('Looking at the image...');
10063
+ }
10051
10064
  const { callLLMStreaming } = require('./engine/llm');
10052
10065
  return callLLMStreaming(prompt, systemPrompt, {
10053
10066
  timeout: CC_CALL_TIMEOUT_MS, label: 'command-center', model, maxTurns,
@@ -10056,11 +10069,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10056
10069
  engineConfig, images,
10057
10070
  onChunk: (text, segmentId) => {
10058
10071
  _touchCcLiveStream(liveState);
10072
+ if (liveState.statusMessage) {
10073
+ setStatus('');
10074
+ }
10059
10075
  liveState.text = text;
10060
10076
  if (liveState.writer) liveState.writer({ type: 'chunk', text, segmentId });
10061
10077
  },
10062
10078
  onToolUse: (name, input, id) => {
10063
10079
  _touchCcLiveStream(liveState);
10080
+ if (liveState.statusMessage) {
10081
+ setStatus('');
10082
+ }
10064
10083
  // Claude (direct) now threads a tool_use id; with an id the chip starts
10065
10084
  // 'pending' and can be flipped to completed/failed by onToolUpdate.
10066
10085
  const entry = { name, input: input || {}, id: id || null, status: id ? 'pending' : null };
@@ -10515,6 +10534,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10515
10534
  writeCcEvent({ type: 'tool-update', id: tool.id, status: tool.status });
10516
10535
  }
10517
10536
  }
10537
+ if (live.statusMessage) writeCcEvent({ type: 'status', message: live.statusMessage });
10518
10538
  if (live.text) writeCcEvent({ type: 'chunk', text: live.text });
10519
10539
  if (live.donePayload) {
10520
10540
  writeCcEvent(live.donePayload);
@@ -11276,6 +11296,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11276
11296
  } catch { /* unknown/free-text runtime */ }
11277
11297
  return modelStr;
11278
11298
  }
11299
+ const _isClear = (v) => v === '' || v === null;
11300
+ let _registeredCliNames = null;
11301
+ const _validCli = (name) => {
11302
+ if (_registeredCliNames == null) {
11303
+ try { _registeredCliNames = _engineRuntimes.listRuntimes(); }
11304
+ catch { _registeredCliNames = []; }
11305
+ }
11306
+ return _registeredCliNames.length === 0 || _registeredCliNames.includes(String(name));
11307
+ };
11279
11308
  if (body.engine) {
11280
11309
  const e = body.engine;
11281
11310
  const D = shared.ENGINE_DEFAULTS;
@@ -11385,15 +11414,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11385
11414
  // chooses)" option submits '' and we must persist that as "unset".
11386
11415
  // Validate `defaultCli` and `ccCli` against the runtime registry so a
11387
11416
  // typo in the dashboard can't pin the fleet to a non-existent runtime.
11388
- const _isClear = (v) => v === '' || v === null;
11389
- let _registeredCliNames = null;
11390
- const _validCli = (name) => {
11391
- if (_registeredCliNames == null) {
11392
- try { _registeredCliNames = require('./engine/runtimes').listRuntimes(); }
11393
- catch { _registeredCliNames = []; }
11394
- }
11395
- return _registeredCliNames.length === 0 || _registeredCliNames.includes(String(name));
11396
- };
11397
11417
  if (e.defaultCli !== undefined) {
11398
11418
  if (_isClear(e.defaultCli)) _deleteEngineConfig('defaultCli');
11399
11419
  else if (_validCli(e.defaultCli)) _setEngineConfig('defaultCli', String(e.defaultCli));
@@ -11589,8 +11609,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11589
11609
  // CLI values pin the agent to a specific runtime. `0` is a valid
11590
11610
  // maxBudgetUsd (read-only / dry-run agents).
11591
11611
  if (updates.cli !== undefined) {
11592
- if (updates.cli === '' || updates.cli === null) delete config.agents[id].cli;
11593
- else config.agents[id].cli = String(updates.cli);
11612
+ if (_isClear(updates.cli)) delete config.agents[id].cli;
11613
+ else if (_validCli(updates.cli)) config.agents[id].cli = String(updates.cli);
11614
+ else _clamped.push(`agents.${id}.cli: "${updates.cli}" not registered (kept previous value)`);
11594
11615
  }
11595
11616
  if (updates.model !== undefined) {
11596
11617
  if (updates.model === '' || updates.model === null) delete config.agents[id].model;
@@ -14113,8 +14134,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14113
14134
  path.join(MINIONS_DIR, 'pull-requests.json'),
14114
14135
  ];
14115
14136
  let found = false;
14137
+ // Issue #803: tombstone EVERY scope that has a copy of this PR id, not
14138
+ // just the first one found. When enrollPrFromCanonicalId (issue #802)
14139
+ // has left a stray cross-scope duplicate (e.g. a legit per-project row
14140
+ // plus a stray central-scope copy), stopping at the first match leaves
14141
+ // the other scope's copy fully untouched — queries.js#getPullRequests
14142
+ // then de-dupes by picking that untouched duplicate as the "winner"
14143
+ // and silently resurrects the "deleted" PR on the next read.
14116
14144
  for (const prPath of prPaths) {
14117
- if (found) break;
14118
14145
  // Issue #384: use a tombstone (userDeleted:true) instead of splice so
14119
14146
  // the next poller cycle cannot re-add the PR. The entry stays in the
14120
14147
  // file but is filtered from API responses and skipped by pollers.
@@ -14895,7 +14922,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14895
14922
  // dashboard route serves dashboard/slim.html instead of the full SPA.
14896
14923
  // Checked at request time so /api/features/toggle flips behavior with
14897
14924
  // no restart; flag-off means zero behavior change for the catch-all.
14898
- if (pathname === '/' && features.isFeatureOn('slim-ux', CONFIG)) {
14925
+ const fullDashboardRequested = new URL(req.url, 'http://localhost').searchParams.get('fullDashboard') === '1';
14926
+ if (pathname === '/' && !fullDashboardRequested && features.isFeatureOn('slim-ux', CONFIG)) {
14899
14927
  return serveSlimUx(req, res);
14900
14928
  }
14901
14929
 
@@ -0,0 +1,94 @@
1
+ # Architecture Review — 2026-07-09
2
+
3
+ Scope: engine orchestration, SQL/JSON state boundaries, runtime/process recovery,
4
+ worktree lifecycle, pipelines/schedules, dashboard/API settings, and prompt
5
+ assembly. Findings were verified against `origin/main` at `b1b83dd2`.
6
+
7
+ ## Fixed in this change
8
+
9
+ ### High: Codex processes were rejected by orphan recovery
10
+
11
+ `isProcessCommandLineMatchingAgent` recognized only Claude and Copilot. After an
12
+ engine restart, a live Codex dispatch could therefore be classified as a
13
+ recycled PID and retried while the original process was still running.
14
+
15
+ The process gate now recognizes all bundled runtime CLIs, including native and
16
+ npm-installed Codex and Claude layouts, without accepting unrelated substring
17
+ matches.
18
+
19
+ ### High: worktree GC lost the project name from real dispatch rows
20
+
21
+ Worktree GC assumed `dispatch.meta.project` was a string, while production
22
+ dispatches store a project descriptor object. In shared worktree-root layouts,
23
+ the resulting `default` hash could fail to protect another project's active
24
+ worktree from orphan cleanup.
25
+
26
+ GC now resolves both the current object shape and the legacy string shape.
27
+
28
+ ### Medium: invalid per-agent runtimes bypassed settings validation
29
+
30
+ Fleet runtime fields were validated against the adapter registry, but
31
+ `agents.<id>.cli` was persisted verbatim. A typo could poison future dispatches
32
+ with a runtime-resolution failure. Per-agent overrides now share the fleet
33
+ validation path and preserve the previous value on invalid input.
34
+
35
+ ### Medium: the no-TTL status cache omitted its own source files
36
+
37
+ `/api/status` includes config-, install-, and package-derived fields but its
38
+ mtime registry omitted `config.json`, `.install-id`, and `package.json`.
39
+ Out-of-process changes could remain stale indefinitely. These inputs now
40
+ participate in cache invalidation.
41
+
42
+ ### Medium: Slim UX trapped users away from advanced settings
43
+
44
+ Slim linked to `/?fullDashboard=1`, but root takeover ignored the query string
45
+ and served Slim again. The root dispatcher now honors that explicit classic-UX
46
+ escape hatch.
47
+
48
+ ### Low: prompt validation generated warnings for valid templates
49
+
50
+ The validator required the literal `## Your Task`, while established playbooks
51
+ also use `## Task Description` and `## Mission`. Those valid aliases now satisfy
52
+ the task-section contract, eliminating recurring false-positive warnings.
53
+
54
+ ### Low: cancelled cron work blocked future schedule fires
55
+
56
+ The dashboard run-now path already treated cancelled work as terminal, but the
57
+ engine's cron enqueue dedup did not. Both paths now allow a new run after
58
+ cancellation.
59
+
60
+ ## Verified follow-up findings
61
+
62
+ These require separate focused changes rather than expanding this PR:
63
+
64
+ 1. **Dispatch SQL/JSON empty-state resurrection (high).** A process that has not
65
+ written SQL can still adopt stale `dispatch.json` when the SQL table is empty.
66
+ The durable fix is a mirror-generation/hash marker or removal of the legacy
67
+ fallback, not moving filesystem writes inside a SQLite transaction.
68
+ 2. **Pipeline task terminal-state model (high).** Task stages use a boolean
69
+ completion check and promote failed work items to a completed pipeline stage;
70
+ cancelled/review-blocked work can wedge indefinitely. Replace the boolean
71
+ helper with an explicit terminal outcome (`completed`, `failed`,
72
+ `waiting-human`, non-terminal) and propagate it through parallel stages.
73
+ 3. **Settings save spans two independent writes (medium).** Config and routing
74
+ are saved through separate endpoints, so routing failure can leave a partial
75
+ configuration update. A combined transactional settings contract should
76
+ replace the client-side two-step sequence.
77
+ 4. **ADO identity lookup duplication (medium).** PR polling resolves the same
78
+ authenticated user more than once per PR. Cache it once per organization and
79
+ poll pass to reduce throttling pressure.
80
+ 5. **Late descendant process leak window (medium).** Spawn cleanup snapshots
81
+ descendants at 3 seconds and then every 30 seconds. Children created and
82
+ reparented inside that window can evade final cleanup. Track descendants more
83
+ frequently while the runtime is alive.
84
+ 6. **Supervisor stale PID reuse (medium).** Startup trusts any live PID in
85
+ `supervisor.pid`. Validate the command line against this checkout's
86
+ `engine/supervisor.js` before suppressing startup.
87
+
88
+ ## Healthy areas
89
+
90
+ - Config/routing references and schedule schemas were valid.
91
+ - Runtime work-item and pull-request mirrors had no stuck dispatched rows,
92
+ duplicate PR IDs, or unknown PR states.
93
+ - No active dispatch was older than the five-hour wall-clock limit.
94
+ - Cooldown state was small and not exhibiting pending-context bloat.
@@ -70,6 +70,8 @@ Any violation rejects the **whole turn** with a typed error envelope (`code: 'in
70
70
 
71
71
  **Worker-pool interaction (W-mray463s001zcee2).** The opt-in Copilot ACP worker pool (`engine.ccUseWorkerPool: true`, default ON for Copilot CC) is a **separate protocol path** from the table above — `engine/cc-worker-pool.js`'s `session/prompt` call only ever sends a single text content block (`[{ type: 'text', text: prompt }]`) and has no image/attachment content-block support today. This used to mean image turns were silently dropped whenever the pool was active, with a comment incorrectly blaming a Copilot capability gap (`imageInput:false`) that doesn't exist — Copilot's `imageInput` is `true` and fully supports images on the direct spawn path. The fix: `_invokeCcStream` (dashboard.js) now checks for a non-empty `images` array **before** routing through `resolveCcUseWorkerPool` — image-bearing turns bypass the warm pool for that turn only and cold-spawn a one-off CLI process via the working `--attachment` path (same as the non-pooled path always used), while image-less turns keep using the fast warm pool. If `engine/cc-worker-pool.js`'s ACP session protocol is later extended to carry attachments (Option A), this per-turn bypass in `_invokeCcStream` should be revisited/removed.
72
72
 
73
+ Because that bypass can spend tens of seconds starting a one-off process before the first model token, the SSE stream shows a user-focused “Looking at the image…” status instead of exposing the cold-start implementation detail or leaving an apparently stalled spinner.
74
+
73
75
  `_resolveImageOpts` (in `engine/llm.js`) is pure and unit-tested: it forwards the `images` list only when `runtime.capabilities.imageInput` is truthy, otherwise returns the typed error so CC/doc-chat surface the envelope instead of a silently-text-only reply. `_spawnProcess` re-applies the same gate (`if (!caps.imageInput) adapterOpts.images = undefined`) as defense in depth. Image **filenames** are run through the untrusted-input fence (`buildSource('cc-image-filename', …)`) before they reach prompt text, since they are user-supplied.
74
76
 
75
77
  **No new `engine.*` flag.** The limits above are module-level constants in `dashboard.js` (`CC_IMAGE_MAX_COUNT`, `CC_IMAGE_MAX_DECODED_BYTES`, `CC_IMAGE_MIME_ALLOWLIST`), not config keys; `engine/shared.js` `ENGINE_DEFAULTS` was not touched. Per CLAUDE.md Best Practice #9 (Settings parity), no Settings toggle is added because no new `engine.*` flag was introduced. Runtime selection that determines whether images are accepted is the existing `engine.ccCli` / `ccModel` override, which already has a Settings control.
@@ -1378,7 +1378,20 @@ async function enrollPrFromCanonicalId(canonicalPrId, project, opts = {}) {
1378
1378
  // pr-links stays consistent (#779 pattern).
1379
1379
  if (opts.itemId && Array.isArray(existing.prdItems) && !existing.prdItems.includes(opts.itemId)) {
1380
1380
  try {
1381
- const targetPath = project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json');
1381
+ // W-mrduef3q000m2172 derive targetPath from the scope the store
1382
+ // actually returned `existing` under (existing._scope), NOT from the
1383
+ // current call's `project` argument. When the two differ (e.g. the
1384
+ // record lives under 'central' but this enrollment call was made
1385
+ // with a specific project), re-deriving from `project` sends the
1386
+ // upsert to the WRONG scope's file/array. upsertPullRequestRecord's
1387
+ // tombstone guard then finds no matching record in that (wrong)
1388
+ // scope and falls through to insert-as-new, silently clearing any
1389
+ // userDeleted tombstone on the real record. See issue writeup for
1390
+ // the confirmed repro.
1391
+ const store = require('./pull-requests-store');
1392
+ const targetPath = existing._scope
1393
+ ? store._filePathForScope(existing._scope)
1394
+ : (project ? shared.projectPrPath(project) : path.join(MINIONS_DIR, 'pull-requests.json'));
1382
1395
  shared.upsertPullRequestRecord(targetPath, {
1383
1396
  id: normalizedCanonicalId,
1384
1397
  prNumber,
@@ -6861,6 +6874,15 @@ function collapseAllDuplicatePrRecords(config) {
6861
6874
  // a coding WI (not one of the validation types itself). Skips if the coding WI
6862
6875
  // has no PR.
6863
6876
  //
6877
+ // W-mrdq22j700056c6c — gated to real code-authoring completions only. Only
6878
+ // item.type in {implement, implement:large, fix} ever spawns a follow-up
6879
+ // validation WI. Every other work-item type (review, test, verify, setup,
6880
+ // explore, ask, docs, decompose, ...) is excluded even when it resolves a PR
6881
+ // ref and autoDispatch is on, because those completions don't author a code
6882
+ // diff worth re-validating — e.g. a `review` dispatch that merely comments on
6883
+ // someone else's PR previously spawned a spurious "Validate: Review opg PR
6884
+ // #792 ..." follow-up. See the ELIGIBLE_TYPES check below.
6885
+ //
6864
6886
  // Files exactly ONE validation WI per coding-WI completion (W-mrhybval0001).
6865
6887
  // `liveValidation.type` still governs CHECKOUT ROUTING — the set of work-item
6866
6888
  // types that run in-place on the live checkout (see resolveCheckoutMode's
@@ -6880,6 +6902,10 @@ function collapseAllDuplicatePrRecords(config) {
6880
6902
  // own type is excluded so a validation-type WI can't recursively validate
6881
6903
  // itself (anti-loop).
6882
6904
  //
6905
+ // Only these item.type values ever trigger this hook (see ELIGIBLE_TYPES
6906
+ // below) — real code-authoring completions, not review/test/verify/setup/
6907
+ // explore/ask/docs/decompose completions.
6908
+ //
6883
6909
  // Deduplicates per codingWiId: any non-terminal WI with
6884
6910
  // meta.liveValidationFor === codingWiId blocks a second dispatch, so only one
6885
6911
  // validation task is ever in flight per coding completion.
@@ -6894,6 +6920,14 @@ function collapseAllDuplicatePrRecords(config) {
6894
6920
  // Validate: ..."), repeating indefinitely. Any WI that was itself created
6895
6921
  // as a liveValidation follow-up (tagged via meta.liveValidationFor at
6896
6922
  // creation, below) must never re-trigger this hook, regardless of its type.
6923
+ //
6924
+ // W-mrdq22j700056c6c — only real code-authoring work-item types ever spawn a
6925
+ // live-validation follow-up. Every other completed type (review, test,
6926
+ // verify, setup, explore, ask, docs, decompose, ...) is excluded even when it
6927
+ // resolves a PR ref, because those completions don't author a code diff
6928
+ // worth re-validating.
6929
+ const ELIGIBLE_LIVE_VALIDATION_TYPES = new Set([WORK_TYPE.IMPLEMENT, WORK_TYPE.IMPLEMENT_LARGE, WORK_TYPE.FIX]);
6930
+
6897
6931
  function autoDispatchLiveValidationWi(meta, config) {
6898
6932
  const item = meta?.item;
6899
6933
  if (!item?.id || !item?.type) return;
@@ -6903,6 +6937,12 @@ function autoDispatchLiveValidationWi(meta, config) {
6903
6937
  // "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
6904
6938
  if (item.meta && item.meta.liveValidationFor) return;
6905
6939
 
6940
+ // W-mrdq22j700056c6c — only implement/implement:large/fix completions ever
6941
+ // author a real code diff worth validating. Review, test, verify, setup,
6942
+ // explore, ask, docs, decompose, etc. completions must never spawn a
6943
+ // "Validate: ..." follow-up, even when they resolve a PR reference.
6944
+ if (!ELIGIBLE_LIVE_VALIDATION_TYPES.has(item.type)) return;
6945
+
6906
6946
  // Issue #716 — QA Session infrastructure WIs (SETUP/DRAFT/EXECUTE, tagged
6907
6947
  // via meta.sessionId and always dispatched with skipPr:true) never own a
6908
6948
  // real code diff, so there is nothing for a live-validation WI to validate.
@@ -1060,12 +1060,12 @@ function renderPlaybook(type, vars) {
1060
1060
  // ─── Playbook Section Validator ──────────────────────────────────────────────
1061
1061
 
1062
1062
  // Required structural section patterns — warn (do not throw) when absent.
1063
- // Only '## Your Task' is checked: it's the one section every playbook template
1064
- // actually implements. '## Tools' / '## Constraints' were removed (#775)
1065
- // no template in playbooks/ defines those headers, so the checks always
1066
- // failed and produced 100%-failure-rate warn-level noise in engine/log.json.
1063
+ // Playbooks use a few established names for the task-bearing section; accept
1064
+ // all of them rather than generating warn-level noise for valid templates.
1065
+ // '## Tools' / '## Constraints' were removed (#775) because no template
1066
+ // consistently implements those headers.
1067
1067
  const _REQUIRED_PROMPT_SECTIONS = [
1068
- { pattern: /^## Your Task\b/m, label: '## Your Task' },
1068
+ { pattern: /^## (?:Your Task|Task Description|Mission)\b/m, label: 'task section' },
1069
1069
  ];
1070
1070
 
1071
1071
  /**
@@ -566,9 +566,9 @@ function findProcessCwdHolders(worktreePath, opts = {}) {
566
566
  return _parseCwdHolderLines(raw, resolved);
567
567
  }
568
568
 
569
- // Cross-check a single PID's command line for a Minions agent invocation
570
- // (`claude` or `copilot`, including the `node spawn-agent.js --runtime <name>`
571
- // wrapper and `gh copilot` fallback). Used by orphan/recycled-PID safety:
569
+ // Cross-check a single PID's command line for a Minions agent invocation,
570
+ // including the shared spawn wrapper and every bundled runtime CLI. Used by
571
+ // orphan/recycled-PID safety:
572
572
  // - engine/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
573
573
  // - engine/timeout.js: gate before parking a dispatch as still-alive when
574
574
  // the OS PID is alive but may belong to an unrelated recycled-PID process
@@ -578,9 +578,20 @@ function findProcessCwdHolders(worktreePath, opts = {}) {
578
578
  // macOS / when /proc isn't available: fallback `ps -p <pid> -o command=`.
579
579
  //
580
580
  // Returns false when the PID is invalid, the process doesn't exist, the
581
- // command line can't be read, or the cmdline contains neither `claude` nor
582
- // `copilot`. False is the safe default for both call sites: cleanup falls
581
+ // command line can't be read, or the cmdline contains no recognized agent
582
+ // marker. False is the safe default for both call sites: cleanup falls
583
583
  // through to "skip kill" and timeout falls through to "treat PID as dead".
584
+ const AGENT_COMMAND_PATTERNS = [
585
+ /(?:^|[\s"'\\/])(?:claude|copilot|codex)(?:\.exe|\.cmd|\.bat|\.js)?(?=$|[\s"])/i,
586
+ /[\\/]claude-code[\\/]cli\.js(?=$|[\s"])/i,
587
+ /[\\/]@github[\\/](?:copilot|copilot-cli)[\\/](?:(?:dist|bin)[\\/])?index\.js(?=$|[\s"])/i,
588
+ ];
589
+
590
+ function _commandLineMatchesAgent(cmdline) {
591
+ const value = String(cmdline || '');
592
+ return AGENT_COMMAND_PATTERNS.some(pattern => pattern.test(value));
593
+ }
594
+
584
595
  function isProcessCommandLineMatchingAgent(pid) {
585
596
  const n = Number(pid);
586
597
  if (!Number.isInteger(n) || n <= 0) return false;
@@ -605,8 +616,7 @@ function isProcessCommandLineMatchingAgent(pid) {
605
616
  }
606
617
  } catch { return false; }
607
618
  if (!cmdline) return false;
608
- const lower = cmdline.toLowerCase();
609
- return lower.includes('claude') || lower.includes('copilot');
619
+ return _commandLineMatchesAgent(cmdline);
610
620
  }
611
621
 
612
622
  function _buildChildMap(processes) {
@@ -693,6 +703,7 @@ module.exports = {
693
703
  _parseCwdHolderLines,
694
704
  findProcessCwdHolders,
695
705
  isProcessCommandLineMatchingAgent,
706
+ _commandLineMatchesAgent, // exported for testing
696
707
  _buildChildMap,
697
708
  listProcessDescendants,
698
709
  listProcessReachable,
package/engine/queries.js CHANGED
@@ -995,12 +995,25 @@ function getPullRequests(config) {
995
995
  // Then merge non-conflicting fields from the loser into the winner so we
996
996
  // never lose prdItems, sourcePlan, itemType, minionsReview, or
997
997
  // _automationFixCauses if only the loser carried them.
998
+ // Issue #803 — defense in depth: if a canonical PR id has a tombstoned
999
+ // (userDeleted:true) copy in ANY scope, treat the whole id as deleted, not
1000
+ // just that one record. Without this, a delete that (for whatever reason)
1001
+ // only tombstones one scope's copy would still have its untouched
1002
+ // cross-scope duplicate survive grouping below and get picked as the
1003
+ // dedupe "winner", silently resurrecting a PR the user just deleted.
1004
+ const tombstonedIds = new Set();
1005
+ for (const pr of sqlPrs) {
1006
+ if (pr && pr.id && pr.userDeleted === true) tombstonedIds.add(pr.id);
1007
+ }
1008
+
998
1009
  const groups = [];
999
1010
  const groupsById = new Map();
1000
1011
  for (const pr of sqlPrs) {
1001
1012
  if (!pr || !pr.id) continue;
1002
- // Issue #384: skip tombstoned records (user explicitly deleted)
1013
+ // Issue #384/#803: skip tombstoned records (user explicitly deleted), and
1014
+ // skip ANY record sharing an id that has a tombstoned copy elsewhere.
1003
1015
  if (pr.userDeleted === true) continue;
1016
+ if (tombstonedIds.has(pr.id)) continue;
1004
1017
  if (!groupsById.has(pr.id)) {
1005
1018
  const g = [];
1006
1019
  groupsById.set(pr.id, g);
@@ -3270,10 +3283,6 @@ function getStatusFastStateMtimePaths(config) {
3270
3283
  * Claude CLI mutations (mcp add/remove, settings edits), not on every
3271
3284
  * prompt, so whole-file tracking is acceptable.
3272
3285
  *
3273
- * Files intentionally NOT tracked here:
3274
- * - version, autoMode, installId — change only on human/CLI edits, which
3275
- * already pop the slow-state via reloadConfig + the 60 s TTL.
3276
- *
3277
3286
  * Per-project `.git/logs/HEAD` + `.git/FETCH_HEAD` ARE tracked here in
3278
3287
  * addition to the fast-state tracker. The project payload (`projects:`
3279
3288
  * with ahead/behind counts) lives in the slow-state slice, and
@@ -3314,6 +3323,14 @@ function getStatusSlowStateMtimePaths(config) {
3314
3323
  // in the payload anymore.
3315
3324
  const files = [];
3316
3325
 
3326
+ // Config-derived status fields (projects, autoMode, version settings) can be
3327
+ // changed by the CLI or another process without calling dashboard-local
3328
+ // invalidateStatusCache(). The unified cache has no TTL fallback, so these
3329
+ // source files must participate in the mtime registry.
3330
+ files.push(CONFIG_PATH);
3331
+ files.push(path.join(MINIONS_DIR, '.install-id'));
3332
+ files.push(path.join(MINIONS_DIR, 'package.json'));
3333
+
3317
3334
  // Skill discovery roots (surfaced by _buildStatusSlowState → getSkills).
3318
3335
  // Mirrors collectSkillFiles' source enumeration so adding a new runtime
3319
3336
  // adapter automatically extends the tracker. ENOENT is tolerated by
package/engine/shared.js CHANGED
@@ -7882,7 +7882,17 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7882
7882
  ...(Array.isArray(itemIds) ? itemIds : [itemId]),
7883
7883
  ]);
7884
7884
  const prNumber = getPrNumber(entry.prNumber ?? entry.id ?? entry.url);
7885
- const canonicalId = getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
7885
+ // Issue #802: when the caller already resolved a scope-qualified canonical
7886
+ // id (e.g. `ado:office/office/office#5370216`, as enrollPrFromCanonicalId
7887
+ // does before calling here), it must win over recomputing from a bare
7888
+ // entry.prNumber + project. Recomputing silently downgrades to a legacy
7889
+ // `PR-<n>` stub whenever project is null/unresolvable (getCanonicalPrId
7890
+ // can't derive a scope), discarding the good id even though it was passed
7891
+ // in. Prefer entry.id whenever it already parses as canonical.
7892
+ const hasCanonicalEntryId = typeof entry.id === 'string' && parseCanonicalPrId(entry.id) != null;
7893
+ const canonicalId = hasCanonicalEntryId
7894
+ ? getCanonicalPrId(project, entry.id, entry.url || '')
7895
+ : getCanonicalPrId(project, entry.prNumber ?? entry.id ?? entry.url ?? prNumber, entry.url || '');
7886
7896
  if (!canonicalId) throw new Error('PR id required');
7887
7897
  const normalizedEntry = {
7888
7898
  ...entry,
@@ -7930,6 +7940,23 @@ function upsertPullRequestRecord(prPath, entry, { project = null, itemId = null,
7930
7940
  delete target.userDeletedAt;
7931
7941
  resurrected = true;
7932
7942
  }
7943
+ // W-mrduef3q000m2172 — defense-in-depth: `prs` here is scoped to a single
7944
+ // scope's array (mutatePullRequests routes by prPath). If a caller
7945
+ // resolved the wrong scope for an already-tombstoned canonical id (the
7946
+ // record actually lives in a DIFFERENT scope's file), the above check
7947
+ // never sees it and would otherwise fall through to insert-as-new,
7948
+ // silently clearing the tombstone. Check globally before inserting.
7949
+ if (!target) {
7950
+ try {
7951
+ const store = require('./pull-requests-store');
7952
+ const globalMatch = (store.readAllPullRequests() || [])
7953
+ .find(pr => pr && pr.id === canonicalId && pr.userDeleted === true);
7954
+ if (globalMatch) {
7955
+ skipped = true;
7956
+ return prs;
7957
+ }
7958
+ } catch { /* best-effort global tombstone check — fall through on failure */ }
7959
+ }
7933
7960
  // P-e9f0a2b4 — When findPrRecord returns null but multiple records share the
7934
7961
  // same prNumber (e.g. cross-scope contamination or a project-config change
7935
7962
  // that shifted canonical IDs), collapse the duplicates before inserting.
@@ -769,9 +769,9 @@ function pruneOrphanWorktrees(opts) {
769
769
  const globalLiveDirNames = new Set();
770
770
  for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
771
771
  if (!d || !d.id || !d.meta || !d.meta.branch) continue;
772
- const dispatchProject = (d.meta.project && typeof d.meta.project === 'string')
772
+ const dispatchProject = typeof d.meta.project === 'string'
773
773
  ? d.meta.project
774
- : 'default';
774
+ : (d.meta.project?.name || 'default');
775
775
  try {
776
776
  globalLiveDirNames.add(_buildWorktreeDirName({
777
777
  dispatchId: d.id,
package/engine.js CHANGED
@@ -28,13 +28,14 @@ require('./engine/stdio-timestamps').installIfNotInstalled();
28
28
 
29
29
  const fs = require('fs');
30
30
  const path = require('path');
31
+ const os = require('os');
31
32
  const crypto = require('crypto');
32
33
  const shared = require('./engine/shared');
33
34
  const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
34
35
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
35
36
  FAILURE_CLASS, resolvePollFlag } = shared;
36
37
  const { resolveRuntime } = require('./engine/runtimes');
37
- const { assertStaleHeadOk } = require('./engine/spawn-agent');
38
+ const { assertStaleHeadOk, preApproveWorkspaceMcps, computeAddDirs } = require('./engine/spawn-agent');
38
39
  const adoGitAuth = require('./engine/ado-git-auth');
39
40
  const queries = require('./engine/queries');
40
41
  const dispatchEvents = require('./engine/dispatch-events');
@@ -10140,7 +10141,9 @@ async function discoverWork(config) {
10140
10141
  const activeMissionIds = new Set();
10141
10142
  const activeScheduleIds = new Set();
10142
10143
  for (const existing of items) {
10143
- if (existing.status === WI_STATUS.DONE || existing.status === WI_STATUS.FAILED) continue;
10144
+ if (DONE_STATUSES.has(existing.status) ||
10145
+ existing.status === WI_STATUS.FAILED ||
10146
+ existing.status === WI_STATUS.CANCELLED) continue;
10144
10147
  if (existing._missionId) activeMissionIds.add(existing._missionId);
10145
10148
  if (existing._scheduleId) activeScheduleIds.add(existing._scheduleId);
10146
10149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2362",
3
+ "version": "0.1.2364",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"