@yemi33/minions 0.1.2373 → 0.1.2375

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.
@@ -446,6 +446,7 @@ async function openSettings() {
446
446
  '<div class="settings-pane-sub">Persistent CC worker pool and git worktree provisioning. Tune these only if you see worktree-create timeouts or slow CC cold-spawns.</div>' +
447
447
  '<div class="settings-stack" style="margin-bottom:12px">' +
448
448
  settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
449
+ settingsToggle('Agent Worker Pool', 'set-agentUseWorkerPool', e.agentUseWorkerPool !== false, 'Route fleet Copilot dispatches through persistent copilot --acp workers. Default ON; runtimes without ACP support ignore this setting.') +
449
450
  // W-mq6f2fe0000557fa — opt-in: auto-kill orphan spawn-agent processes
450
451
  // holding a stuck worktree's cwd. Gated by safe-reap criteria (cmdline
451
452
  // matches spawn-agent.js + basename + age > agentTimeout * 2). Default
@@ -484,12 +485,6 @@ async function openSettings() {
484
485
  '</select>' +
485
486
  '</div>' +
486
487
  settingsField('Copilot fallback model', 'set-copilotFallbackModel', e.copilotFallbackModel || '', 'e.g. gpt-5.4', 'Copilot has no --fallback-model flag. On a MODEL_UNAVAILABLE (overloaded/503) retry, the engine OVERRIDES --model with this value (Copilot only).') +
487
- '</div>' +
488
- '<div class="settings-stack" style="margin-top:12px">' +
489
- settingsField('Copilot: disable agent MCP servers', 'set-copilotAgentDisabledMcpServers',
490
- Array.isArray(e.copilotAgentDisabledMcpServers) ? e.copilotAgentDisabledMcpServers.join(', ') : (e.copilotAgentDisabledMcpServers || ''),
491
- 'e.g. playwright, maestro, loop',
492
- 'Comma-separated MCP server names (from ~/.copilot/mcp-config.json) the engine disables for autonomous Copilot agent dispatches by filtering them out of the agent-only COPILOT_HOME. Copilot loads your user MCP config on every spawn regardless of --add-dir; list the noisy/auth-popping servers (e.g. playwright, maestro) to keep them out of agents. Empty = inherit all.') +
493
488
  '</div>';
494
489
 
495
490
  const paneClaude =
@@ -588,7 +583,7 @@ async function openSettings() {
588
583
  '</div>' +
589
584
  '<h4>Agent Memory</h4>' +
590
585
  '<div class="settings-stack">' +
591
- settingsToggle('Shadow mode', 'set-memoryRetrievalShadowMode', e.memoryRetrievalShadowMode !== false, 'Measure retrieval without changing prompts') +
586
+ settingsToggle('Shadow mode', 'set-memoryRetrievalShadowMode', !!e.memoryRetrievalShadowMode, 'Measure retrieval without changing prompts') +
592
587
  settingsToggle('Capture task episodes', 'set-memoryEpisodicCapture', !!e.memoryEpisodicCapture, 'Store compact outcomes, never transcripts') +
593
588
  '</div>' +
594
589
  '<div class="settings-grid-2">' +
@@ -1128,6 +1123,7 @@ async function saveSettings() {
1128
1123
  autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
1129
1124
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
1130
1125
  ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
1126
+ agentUseWorkerPool: !!document.getElementById('set-agentUseWorkerPool')?.checked,
1131
1127
  autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
1132
1128
  liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
1133
1129
  liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
@@ -1188,7 +1184,6 @@ async function saveSettings() {
1188
1184
  claudeBareMode: !!document.getElementById('set-claudeBareMode')?.checked,
1189
1185
  claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
1190
1186
  copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
1191
- copilotAgentDisabledMcpServers: (document.getElementById('set-copilotAgentDisabledMcpServers')?.value ?? '').trim(),
1192
1187
  copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
1193
1188
  copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
1194
1189
  copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
package/dashboard.js CHANGED
@@ -11374,17 +11374,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11374
11374
  else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
11375
11375
  else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
11376
11376
  }
11377
- // P-mcp-storm — MCP server names to disable for autonomous Copilot agents
11378
- // (--disable-mcp-server). Accept an array OR a comma/whitespace string;
11379
- // normalize to a deduped array of trimmed names. Empty clears (inherit all).
11380
- if (e.copilotAgentDisabledMcpServers !== undefined) {
11381
- const src = Array.isArray(e.copilotAgentDisabledMcpServers)
11382
- ? e.copilotAgentDisabledMcpServers
11383
- : String(e.copilotAgentDisabledMcpServers || '').split(/[\s,]+/);
11384
- const names = [...new Set(src.map(s => String(s == null ? '' : s).trim()).filter(Boolean))];
11385
- if (names.length) _setEngineConfig('copilotAgentDisabledMcpServers', names);
11386
- else _deleteEngineConfig('copilotAgentDisabledMcpServers');
11387
- }
11388
11377
  // W-mpmwxkrw000872ec — fontSize allowlist. Clamps invalid values
11389
11378
  // (rather than silently failing) so the dashboard bootstrap never
11390
11379
  // ends up with an unknown data-font-size attribute.
@@ -15099,18 +15088,6 @@ if (require.main === module) {
15099
15088
  // engine reads, port binding) is captured rather than dying silently.
15100
15089
  _installCrashHandlers();
15101
15090
 
15102
- // Deliberately do NOT set process.env.COPILOT_HOME here. Command Center,
15103
- // doc-chat, and the CC worker pool (`copilot --acp`) all spawn `copilot`
15104
- // as this process's children, so they should inherit the operator's real
15105
- // `~/.copilot` home (including their model preference in settings.json)
15106
- // exactly like an interactive `copilot` CLI session would — that's the
15107
- // documented CLI contract. An earlier revision forced these onto an
15108
- // isolated, MCP-filtered home to dodge MCP-server auth popups, but that
15109
- // broke CC's settings inheritance and reintroduced a staleness/reseed bug;
15110
- // reverted (W-mre75l6x00024f49). Per-dispatch agent isolation is unaffected
15111
- // — the engine process still seeds its own COPILOT_HOME at boot and
15112
- // re-stamps it per spawn via `_applyAgentCopilotHome` (engine.js).
15113
-
15114
15091
  // Kick off first npm check on startup, then re-check every 4 hours.
15115
15092
  // Guarded here so that requiring dashboard as a module (unit tests, _withDashServer)
15116
15093
  // does not spawn npm child processes that keep the event loop alive past test teardown
@@ -0,0 +1,106 @@
1
+ # inbox_entries Table Schema
2
+
3
+ ## Overview
4
+ The `inbox_entries` table is the persistent store for operator and agent inbox messages within the Minions orchestration engine. It provides a time-ordered, unread-tracking message queue decoupled from the runtime work-item/dispatch lifecycle.
5
+
6
+ ## Schema Specification
7
+
8
+ ### Column Definitions
9
+
10
+ | Column | Type | Constraints | Purpose |
11
+ |--------|------|-----------|---------|
12
+ | `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>` format |
13
+ | `recipient_session_id` | TEXT | NOT NULL | Session ID of the inbox owner (operator or agent); routes messages to specific actor |
14
+ | `sender_id` | TEXT | NOT NULL | Identifier of the message originator; structured as `<agent-id>` or `'system'` or `'operator'` |
15
+ | `sender_name` | TEXT | NOT NULL | Human-readable sender name for UI display; persisted at write-time for offline/archived reference |
16
+ | `sender_type` | TEXT | NOT NULL | Enum: `'agent'`, `'system'`, `'operator'`; constrains allowed sender_id values |
17
+ | `interaction_id` | TEXT | NOT NULL | Group ID linking related messages (e.g., work-item dispatch feedback, PR review batch); enables threading and deduplication |
18
+ | `sequence` | INTEGER | NOT NULL, DEFAULT 0 | Per-interaction message counter; disambiguates bursts and enables ordering within same millisecond |
19
+ | `summary` | TEXT | NOT NULL | Title/subject line; used for list views and user scanning; always present even if content is truncated |
20
+ | `content` | TEXT | NOT NULL | Full message body; may contain Markdown, code blocks, or structured JSON sidecars (not yet parsed at table level) |
21
+ | `unread` | INTEGER | NOT NULL, DEFAULT 1 | Boolean flag (0/1); toggled by reader, cleared on archive/delete; enables fast unread-count queries |
22
+ | `sent_at` | INTEGER | NOT NULL | Unix timestamp (milliseconds) of message creation; primary sort key for time-ordered display |
23
+ | `read_at` | INTEGER | Nullable | Unix timestamp (milliseconds) when recipient first read the message; null until marked read |
24
+ | `notified_at` | INTEGER | Nullable | Unix timestamp (milliseconds) of last notification event sent to operator; null if no notification dispatched |
25
+
26
+ ### Constraints & Relationships
27
+
28
+ - **PK constraint:** `id` is globally unique across all inboxes; collision risk is negligible given the composite structure (recipient + timestamp + sender + seq).
29
+ - **FK (soft):** `recipient_session_id` does NOT enforce a referential constraint to `sessions` table (sessions may be archived). Queries SHOULD handle missing recipients gracefully.
30
+ - **FK (soft):** `sender_id` is informational only; no validation that the sender exists in the `config.agents` or active sessions. Invalid senders are logged but do not block writes.
31
+ - **No composite index on (recipient_session_id, sent_at):** See Performance section.
32
+
33
+ ## Architectural Rationale
34
+
35
+ ### Design Decisions
36
+
37
+ 1. **Separate inbox table vs. inline dispatch/work-item attachments.**
38
+ - Inboxes can outlive their source work items (e.g., a feedback message remains useful after the WI is done).
39
+ - Decoupling allows independent inbox lifecycle (archive, export, cleanup) without cascading work-item deletes.
40
+ - Supports rich async consolidation (team notes, KB articles) that don't belong to any single WI.
41
+
42
+ 2. **`unread` flag instead of soft-delete or status enum.**
43
+ - Fast count queries for badge (`SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1`).
44
+ - Unread state is mutable; allows mark-as-read UX without data loss.
45
+ - Simplifies archive/export (no tombstone filtering needed).
46
+
47
+ 3. **`sender_type` as an explicit column.**
48
+ - Allows schema validation (e.g., "agent" senders must have valid IDs; "system" is always valid).
49
+ - Enables filtered views ("show only agent messages" or "system alerts only") without string parsing.
50
+
51
+ 4. **`interaction_id` for grouping.**
52
+ - Enables undo/redo on related messages (e.g., canceling all build-failure notifications from one dispatch).
53
+ - Supports consolidation: multiple small notifications can be collapsed into one summary message.
54
+
55
+ 5. **Persistent `sender_name` at write-time.**
56
+ - Operator may rename agents or disable them; storing the name ensures historical accuracy.
57
+ - UI doesn't need to perform runtime lookups on archived inboxes.
58
+
59
+ 6. **Unix timestamps (milliseconds) instead of ISO strings.**
60
+ - Enables range queries (`sent_at BETWEEN ? AND ?`) without parsing.
61
+ - Smaller column footprint; easier to sort.
62
+ - Downside: not human-readable; UI must convert to locale strings.
63
+
64
+ ### Entity Relationships
65
+
66
+ - **inbox_entries ← dispatch WI feedback:** A failed dispatch may create multiple inbox entries (error summary, retry recommendation, logs snippet). Threaded via `interaction_id`.
67
+ - **inbox_entries ← PR reviews:** Humans and auto-review agents post comments; summarized as inbox alerts to project owners via `interaction_id` per-PR.
68
+ - **inbox_entries ← consolidation:** `engine/consolidation.js` reads inbox entries to extract `agent:` frontmatter and route findings into per-agent knowledge files.
69
+
70
+ ## Performance Considerations
71
+
72
+ ### Indexing Strategy
73
+
74
+ **Current indexes (recommended):**
75
+ - `idx_recipient_sent` on `(recipient_session_id, sent_at DESC)` — enables efficient "unread count" and "latest N messages" queries without table scans.
76
+ - `idx_interaction` on `(interaction_id)` — enables grouping/dedup queries (e.g., "was this interaction already notified?").
77
+ - PK `id` is implicit index.
78
+
79
+ **Query patterns:**
80
+ - **Unread count per session:** `SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1` → must scan `idx_recipient_sent` to avoid full table.
81
+ - **Latest messages:** `SELECT * WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT 20` → seeks on `idx_recipient_sent` DESC.
82
+ - **Thread view:** `SELECT * WHERE interaction_id = ? ORDER BY sent_at` → seeks on `idx_interaction`, then sorts in application or via secondary index.
83
+ - **Cleanup:** `DELETE WHERE sent_at < ? AND unread = 0` → full table scan (acceptable; cleanup is infrequent, low-priority background task).
84
+
85
+ **Missing indexes (future optimization):**
86
+ - `idx_sender_id` — if queries like "messages from this agent" are common, consider adding.
87
+ - `idx_unread` — if badge ("N unread") is a hot query, consider combining with recipient (`idx_recipient_unread` on `(recipient_session_id, unread)`).
88
+
89
+ ### Size Estimates & Cleanup
90
+
91
+ - **Row size:** ~500–2000 bytes (summary + content; content can be verbose with logs/code snippets).
92
+ - **Growth:** ~5–50 entries/day (depends on agent activity and verbose flagging).
93
+ - **Retention:** No automatic cleanup; operators may archive/export and manually delete aged entries. Engine does NOT auto-prune.
94
+ - **Recommended**: Implement a dashboard setting to soft-delete entries older than N days (default 30) with an undo/export option.
95
+
96
+ ## Migration & Backward Compatibility
97
+
98
+ - **Migration 018 or later** creates `inbox_entries` as part of SQL state cutover. Legacy `inbox/` Markdown files are NOT imported; operators must re-send or manually recreate critical messages.
99
+ - **No schema versioning needed** for now; table is immutable-append-only (reads/writes only).
100
+
101
+ ## Related Code
102
+
103
+ - **Writes:** `engine/inbox-store.js` (primary mutator; uses `getDb()` and `emitStateEvent`).
104
+ - **Reads:** `engine/queries.js#getInboxForSession()`, dashboard `/api/inbox`, `engine/consolidation.js`.
105
+ - **UI:** `dashboard/pages/inbox.html`, `dashboard/js/inbox.js`.
106
+
@@ -11,6 +11,7 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
11
11
 
12
12
  - `discoverFromPrs()` (engine.js) runs each tick (~10s)
13
13
  - Gates: `status === 'active'` + `reviewStatus === 'pending'` + not reviewed since last push + not dispatched + not on cooldown
14
+ - "Not reviewed since last push" is `!isPrReviewCurrent(pr)` — compares `pr.reviewedHeadSha` (the SHA the completed review verdict actually evaluated) against the live head, falling back to the legacy `lastPushedAt <= lastReviewedAt` timestamp comparison for PR records that predate `reviewedHeadSha`. The SHA-based check replaces a pure timestamp comparison, which could false-positive as "already reviewed" when a commit landed mid-review (`lastReviewedAt` is stamped at completion, i.e. *after* the mid-review push) — root cause of github:opg-microsoft/minions#821. Re-review (`reviewStatus === 'waiting'`) uses the mirrored `isPrFixedAfterReview(pr)` gate for the same reason. Both dispatch sites capture `reviewHeadSha: getPrCauseHead(pr)` into dispatch `meta` so the eventual verdict can stamp the exact SHA it evaluated.
14
15
  - Pre-dispatch: `checkLiveReviewStatus()` hits GitHub/ADO API to catch stale cached status
15
16
  - Routes to reviewer via `resolveAgent('review')`, dispatches with `review.md` playbook
16
17
 
@@ -144,8 +145,9 @@ A "branch unchanged" no-op report for a `BUILD_FAILURE`-cause fix used to be con
144
145
  | `buildErrorLog` | Poller | Actual CI error output for fix agents |
145
146
  | `_buildFixPushedAt` | Discovery (on dispatch) | Grace period timestamp |
146
147
  | `_buildFailNotified` | Discovery | Dedup for inbox alert |
147
- | `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic |
148
- | `lastReviewedAt` | `updatePrAfterReview()` | Prevents re-dispatch if reviewed |
148
+ | `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic (legacy fallback signal) |
149
+ | `lastReviewedAt` | `updatePrAfterReview()` | Legacy fallback for freshness gating on PR records that predate `reviewedHeadSha` |
150
+ | `reviewedHeadSha` | `updatePrAfterReview()` | SHA the completed review verdict actually evaluated (captured at dispatch time via `meta.reviewHeadSha`, or falls back to the live head for legacy in-flight dispatches); primary review-freshness signal (`isPrReviewCurrent`/`isPrFixedAfterReview`) |
149
151
  | `minionsReview` | Post-completion hooks | `{ reviewer, reviewedAt, note, fixedAt }` |
150
152
  | `humanFeedback` | `pollPrHumanComments()` | `{ pendingFix, feedbackContent, lastProcessedCommentDate }` |
151
153
  | `_noOpFixes[cause]` | `recordPrNoOpFixAttempt()` | Per-cause record `{ count, paused, fingerprint, beforeHead, afterHead }` driving the issue #2969 pause loop |
@@ -321,8 +321,6 @@ means zero such files were found), **Proposed target module**.
321
321
  | `resolveCcModel` | 3935-3950 | Resolve the model for the Command Center / doc-chat. Priority: 1. `engine.ccModel` — CC-only override 2. `engine.defaultModel` — fleet default (CC inherits this when ccModel unset) 3. `undefined` — let the runtime adapte | yes | `engine/llm.js`, `engine/pre-dispatch-eval.js`, `test/unit/auto-recovery.test.js`, `test/unit.test.js`, `dashboard.js` | engine/shared.js (keep) |
322
322
  | `resolveAgentMaxBudget` | 3951-3973 | Resolve the per-spawn USD budget cap. Priority: 1. `agent.maxBudgetUsd` — per-agent override 2. `engine.maxBudgetUsd` — fleet default 3. `undefined` — no cap Uses nullish coalescing so a literal `0` is honored as a valid | yes | `test/unit.test.js`, `engine.js` | engine/shared.js (keep) |
323
323
  | `resolveAgentBareMode` | 3974-3991 | Resolve whether this agent should run in Claude `--bare` mode. Priority: 1. `agent.bareMode` — per-agent override (boolean) 2. `engine.claudeBareMode` — fleet default 3. `false` — hardcoded fallback Strict undefined/null | yes | `test/unit.test.js`, `engine.js` | engine/shared.js (keep) |
324
- | `_normalizeMcpServerList` | 3992-4005 | P-mcp-storm — Resolve the list of MCP server names to disable for spawned // Copilot agents. The engine FILTERS these out of the agent's seeded // COPILOT_HOME mcp-config (engine.js `_applyAgentCopilotHome`) — a server a | no | _internal-only (no callers found outside shared.js)_ | engine/shared.js (keep) |
325
- | `resolveCopilotAgentDisabledMcpServers` | 4006-4030 | Resolve copilot agent disabled mcp servers. | yes | `test/unit/agent-copilot-home.test.js` | engine/shared.js (keep) |
326
324
  | `applyLegacyCcModelMigration` | 4063-4082 | If `config.engine.ccModel` is set but `config.engine.defaultModel` is unset, mutate the in-memory `config.engine` so `defaultModel` mirrors `ccModel` and log a one-time deprecation notice. Does NOT write to disk. Returns | yes | `engine/cli.js`, `test/unit.test.js` | engine/shared.js (keep) |
327
325
  | `_resetLegacyCcModelMigrationFlag` | 4083-4113 | Test helper: reset the dedup flag so repeated tests can re-trigger the log. | yes | _internal-only (no callers found outside shared.js)_ | engine/shared.js (keep) |
328
326
  | `runtimeConfigWarnings` | 4114-4194 | Inspect runtime fleet config and return warning entries for misconfiguration. Warnings emitted: - Unknown CLI: any `cli` value (per-agent, ccCli, defaultCli) not in `registeredRuntimes`. Each unknown value produces one e | yes | `engine/preflight.js` | engine/process-utils.js (new) — process spawn/pid/tmp-dir lifecycle helpers |
@@ -12,7 +12,7 @@ Multiple agents work the same repository in parallel and across days. Without sh
12
12
  - Agents re-litigate decisions the team already made (drift, regressions).
13
13
  - Human-flagged warnings (e.g., "do not self-approve PRs") get ignored because they live outside the prompt.
14
14
 
15
- Team memory closes the loop. The engine always injects pinned context. By default it also injects recent team and personal notes; when structured retrieval is enabled outside shadow mode, it replaces those broad appendices with a bounded task-relevant memory pack. Playbooks still instruct agents to consult `knowledge/` and `notes/inbox/` on disk before going wider.
15
+ Team memory closes the loop. The engine always injects pinned context. By default, structured retrieval replaces broad team and personal notes with a bounded task-relevant memory pack. Playbooks still instruct agents to consult `knowledge/` and `notes/inbox/` on disk before going wider.
16
16
 
17
17
  ## Structured memory and retrieval
18
18
 
@@ -20,11 +20,11 @@ Migration `017-agent-memory.js` backfills the existing Markdown sources into `me
20
20
 
21
21
  `engine/memory-retrieval.js` searches active records in global, current-project, and assigned-agent scopes. It reranks lexical matches by scope, trust, path match, recency, importance, and confidence, then packs the best records into a strict byte budget. Agent- or external-authored procedural records are never recalled as instructions. Every injected pack remains inside an `<UNTRUSTED-INPUT>` fence.
22
22
 
23
- Rollout is controlled by the default-off `memoryRetrieval` feature flag:
23
+ Structured retrieval is controlled by the default-on `memoryRetrieval` feature flag:
24
24
 
25
25
  - **Flag off:** legacy prompt behavior.
26
- - **Flag on + `memoryRetrievalShadowMode: true` (default):** compute retrieval and telemetry, but keep legacy prompts.
27
- - **Flag on + shadow mode off:** inject `Relevant Memory` when retrieval finds evidence; otherwise fall back to legacy notes and personal memory.
26
+ - **Flag on + shadow mode off (default):** inject `Relevant Memory` when retrieval finds evidence; otherwise fall back to legacy notes and personal memory.
27
+ - **Flag on + `memoryRetrievalShadowMode: true`:** compute retrieval and telemetry, but keep legacy prompts.
28
28
 
29
29
  Settings expose top-k, candidate count, byte budget, shadow mode, and episodic capture. The Inbox page's **Memory search** control shows provenance and status and supports explicit pin, retract, and restore actions.
30
30
 
@@ -0,0 +1,37 @@
1
+ // engine/db/migrations/019-inbox-entries.js
2
+ //
3
+ // Creates the `inbox_entries` table: persistent store for operator and agent
4
+ // inbox messages, decoupled from the runtime work-item/dispatch lifecycle.
5
+ //
6
+ // Schema: columns, types, constraints, and indexing strategy documented in
7
+ // docs/design-inbox-entries-schema.md
8
+
9
+ module.exports = {
10
+ version: 19,
11
+ description: 'inbox_entries: operator/agent message queue with full schema + indexes',
12
+ up(db) {
13
+ db.exec(`
14
+ CREATE TABLE inbox_entries (
15
+ id TEXT PRIMARY KEY,
16
+ recipient_session_id TEXT NOT NULL,
17
+ sender_id TEXT NOT NULL,
18
+ sender_name TEXT NOT NULL,
19
+ sender_type TEXT NOT NULL,
20
+ interaction_id TEXT NOT NULL,
21
+ sequence INTEGER NOT NULL DEFAULT 0,
22
+ summary TEXT NOT NULL,
23
+ content TEXT NOT NULL,
24
+ unread INTEGER NOT NULL DEFAULT 1,
25
+ sent_at INTEGER NOT NULL,
26
+ read_at INTEGER,
27
+ notified_at INTEGER
28
+ );
29
+
30
+ CREATE INDEX idx_inbox_recipient_sent
31
+ ON inbox_entries(recipient_session_id, sent_at DESC);
32
+
33
+ CREATE INDEX idx_inbox_interaction
34
+ ON inbox_entries(interaction_id);
35
+ `);
36
+ },
37
+ };
@@ -96,8 +96,8 @@ const FEATURES = {
96
96
  expires: '2026-12-01',
97
97
  },
98
98
  'memoryRetrieval': {
99
- description: 'Select bounded task-relevant long-term memory with SQLite FTS5 instead of injecting only recent team and personal notes. Starts in shadow mode by default.',
100
- default: false,
99
+ description: 'Select bounded task-relevant long-term memory with SQLite FTS5 instead of injecting broad team and personal notes.',
100
+ default: true,
101
101
  addedIn: '0.1.2233',
102
102
  expires: '2027-01-31',
103
103
  },
@@ -0,0 +1,295 @@
1
+ // engine/inbox-store.js — SQL-backed inbox_entries table handlers.
2
+ //
3
+ // Provides read/write helpers for the operator and agent inbox message queue.
4
+ // Inboxes are decoupled from the runtime work-item/dispatch lifecycle and
5
+ // support threading via interaction_id, unread tracking, and time-ordered display.
6
+ //
7
+ // Schema: docs/design-inbox-entries-schema.md
8
+
9
+ const crypto = require('node:crypto');
10
+ const { getDb, withTransaction } = require('./db');
11
+
12
+ // Per CLAUDE.md's "State storage" mutator contract, every mutator wrapper
13
+ // must call emitStateEvent() after a successful write so the dashboard's
14
+ // MAX(events.id) cache-version advances — otherwise the dashboard keeps
15
+ // serving stale cached inbox state until an unrelated event happens to
16
+ // bump the counter. Best-effort: never let event emission fail a write.
17
+ function _emitInboxEvent(payload) {
18
+ try {
19
+ require('./db-events').emitStateEvent('inbox', payload);
20
+ } catch { /* best-effort */ }
21
+ }
22
+
23
+ // In-process monotonic counter. Millisecond timestamps are too coarse to
24
+ // disambiguate bursts of createInboxEntry() calls for the same
25
+ // recipient+sender pair (e.g. rapid consolidation/notification writes), and
26
+ // the caller-supplied `sequence` field is not guaranteed to be unique either
27
+ // (it defaults to 0 and callers are not required to increment it). The
28
+ // counter guarantees every id generated within this process is distinct even
29
+ // when Date.now() does not advance between calls.
30
+ let _inboxIdCounter = 0;
31
+
32
+ // Generate a likely-unique inbox entry ID:
33
+ // <recipient>-<timestamp>-<sender>-<seq>-<counter>-<random>
34
+ // `sequence` is preserved for callers that pass a meaningful value; the
35
+ // monotonic counter + random suffix are what actually make the id
36
+ // collision-proof, independent of clock resolution or caller behavior.
37
+ function _generateInboxId(recipientSessionId, senderId, sequence) {
38
+ const now = Date.now();
39
+ _inboxIdCounter = (_inboxIdCounter + 1) % 0x1000000;
40
+ const rand = crypto.randomBytes(4).toString('hex');
41
+ return `${recipientSessionId}-${now}-${senderId}-${sequence}-${_inboxIdCounter.toString(36)}-${rand}`;
42
+ }
43
+
44
+ // Read all inbox entries for a given recipient session, ordered by time descending.
45
+ function _readInboxEntriesFromSql(db, { recipientSessionId } = {}) {
46
+ let query = 'SELECT * FROM inbox_entries';
47
+ const params = [];
48
+ if (recipientSessionId) {
49
+ query += ' WHERE recipient_session_id = ?';
50
+ params.push(recipientSessionId);
51
+ }
52
+ query += ' ORDER BY sent_at DESC';
53
+ return db.prepare(query).all(...params);
54
+ }
55
+
56
+ // Read unread count for a recipient.
57
+ function getInboxUnreadCount(recipientSessionId) {
58
+ let db;
59
+ try { db = getDb(); }
60
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
61
+ const row = db.prepare('SELECT COUNT(*) AS count FROM inbox_entries WHERE recipient_session_id = ? AND unread = 1')
62
+ .get(recipientSessionId);
63
+ return row ? row.count : 0;
64
+ }
65
+
66
+ // Read all inbox entries for a recipient session.
67
+ function getInboxForSession(recipientSessionId, { limit = null, offset = 0 } = {}) {
68
+ let db;
69
+ try { db = getDb(); }
70
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
71
+ let query = 'SELECT * FROM inbox_entries WHERE recipient_session_id = ? ORDER BY sent_at DESC';
72
+ const params = [recipientSessionId];
73
+ if (limit) {
74
+ query += ' LIMIT ? OFFSET ?';
75
+ params.push(limit, offset);
76
+ }
77
+ return db.prepare(query).all(...params);
78
+ }
79
+
80
+ // Read a single inbox entry by ID.
81
+ function getInboxEntryById(id) {
82
+ let db;
83
+ try { db = getDb(); }
84
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
85
+ return db.prepare('SELECT * FROM inbox_entries WHERE id = ?').get(id);
86
+ }
87
+
88
+ // Create a new inbox entry. Returns the entry object.
89
+ function createInboxEntry({
90
+ recipientSessionId,
91
+ senderId,
92
+ senderName,
93
+ senderType,
94
+ interactionId,
95
+ summary,
96
+ content,
97
+ sequence = 0,
98
+ }) {
99
+ if (!recipientSessionId || !senderId || !senderName || !senderType || !interactionId || !summary || !content) {
100
+ throw new Error('inbox-store: missing required fields for createInboxEntry');
101
+ }
102
+ if (!['agent', 'system', 'operator'].includes(senderType)) {
103
+ throw new Error(`inbox-store: invalid senderType "${senderType}"`);
104
+ }
105
+
106
+ let db;
107
+ try { db = getDb(); }
108
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
109
+
110
+ const MAX_ID_COLLISION_RETRIES = 5;
111
+
112
+ return withTransaction(db, () => {
113
+ const now = Date.now();
114
+ const insert = db.prepare(`
115
+ INSERT INTO inbox_entries
116
+ (id, recipient_session_id, sender_id, sender_name, sender_type, interaction_id,
117
+ sequence, summary, content, unread, sent_at, read_at, notified_at)
118
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL, NULL)
119
+ `);
120
+
121
+ // _generateInboxId() is collision-proof in practice (monotonic
122
+ // per-process counter + random suffix), but retry-on-conflict is kept as
123
+ // defense-in-depth against an id collision from any other source (e.g. a
124
+ // pre-existing row imported/replayed from another process) rather than
125
+ // letting a rare UNIQUE-constraint failure crash the caller.
126
+ let id;
127
+ for (let attempt = 0; attempt < MAX_ID_COLLISION_RETRIES; attempt++) {
128
+ id = _generateInboxId(recipientSessionId, senderId, sequence);
129
+ try {
130
+ insert.run(id, recipientSessionId, senderId, senderName, senderType, interactionId, sequence, summary, content, now);
131
+ break;
132
+ } catch (e) {
133
+ const isUniqueViolation = e && (e.code === 'ERR_SQLITE_ERROR' || e.errcode === 1555 || /UNIQUE constraint failed/.test(e.message || ''));
134
+ if (!isUniqueViolation || attempt === MAX_ID_COLLISION_RETRIES - 1) throw e;
135
+ // Regenerate and retry — the monotonic counter guarantees the next
136
+ // attempt produces a different id.
137
+ }
138
+ }
139
+
140
+ _emitInboxEvent({ id, recipientSessionId, action: 'create' });
141
+ return getInboxEntryById(id);
142
+ });
143
+ }
144
+
145
+ // Mark one or more inbox entries as read.
146
+ function markInboxEntriesAsRead(entryIds) {
147
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
148
+
149
+ let db;
150
+ try { db = getDb(); }
151
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
152
+
153
+ return withTransaction(db, () => {
154
+ const now = Date.now();
155
+ const placeholders = entryIds.map(() => '?').join(',');
156
+ const query = `UPDATE inbox_entries SET unread = 0, read_at = ? WHERE id IN (${placeholders})`;
157
+ const stmt = db.prepare(query);
158
+ const result = stmt.run(now, ...entryIds);
159
+ if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'mark_read' });
160
+ return { wrote: result.changes > 0 };
161
+ });
162
+ }
163
+
164
+ // Mark one or more inbox entries as unread.
165
+ function markInboxEntriesAsUnread(entryIds) {
166
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
167
+
168
+ let db;
169
+ try { db = getDb(); }
170
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
171
+
172
+ return withTransaction(db, () => {
173
+ const placeholders = entryIds.map(() => '?').join(',');
174
+ const query = `UPDATE inbox_entries SET unread = 1, read_at = NULL WHERE id IN (${placeholders})`;
175
+ const stmt = db.prepare(query);
176
+ const result = stmt.run(...entryIds);
177
+ if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'mark_unread' });
178
+ return { wrote: result.changes > 0 };
179
+ });
180
+ }
181
+
182
+ // Mark all inbox entries for a recipient as read.
183
+ function markAllInboxAsRead(recipientSessionId) {
184
+ let db;
185
+ try { db = getDb(); }
186
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
187
+
188
+ return withTransaction(db, () => {
189
+ const now = Date.now();
190
+ const result = db.prepare(
191
+ 'UPDATE inbox_entries SET unread = 0, read_at = ? WHERE recipient_session_id = ? AND unread = 1'
192
+ ).run(now, recipientSessionId);
193
+ if (result.changes > 0) _emitInboxEvent({ recipientSessionId, action: 'mark_all_read' });
194
+ return { wrote: result.changes > 0 };
195
+ });
196
+ }
197
+
198
+ // Delete an inbox entry by ID.
199
+ function deleteInboxEntry(id) {
200
+ let db;
201
+ try { db = getDb(); }
202
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
203
+
204
+ return withTransaction(db, () => {
205
+ const result = db.prepare('DELETE FROM inbox_entries WHERE id = ?').run(id);
206
+ if (result.changes > 0) _emitInboxEvent({ id, action: 'delete' });
207
+ return { wrote: result.changes > 0 };
208
+ });
209
+ }
210
+
211
+ // Delete multiple inbox entries by ID.
212
+ function deleteInboxEntries(entryIds) {
213
+ if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
214
+
215
+ let db;
216
+ try { db = getDb(); }
217
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
218
+
219
+ return withTransaction(db, () => {
220
+ const placeholders = entryIds.map(() => '?').join(',');
221
+ const query = `DELETE FROM inbox_entries WHERE id IN (${placeholders})`;
222
+ const result = db.prepare(query).run(...entryIds);
223
+ if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'delete_batch' });
224
+ return { wrote: result.changes > 0 };
225
+ });
226
+ }
227
+
228
+ // Delete all unread entries older than a given timestamp (in milliseconds).
229
+ // Used for cleanup/archival.
230
+ function deleteInboxEntriesOlderThan(timestampMs, { unreadOnly = false } = {}) {
231
+ let db;
232
+ try { db = getDb(); }
233
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
234
+
235
+ return withTransaction(db, () => {
236
+ let query = 'DELETE FROM inbox_entries WHERE sent_at < ?';
237
+ const params = [timestampMs];
238
+ if (unreadOnly) {
239
+ query += ' AND unread = 1';
240
+ }
241
+ const result = db.prepare(query).run(...params);
242
+ if (result.changes > 0) _emitInboxEvent({ timestampMs, unreadOnly, action: 'delete_older_than' });
243
+ return { wrote: result.changes > 0, deletedCount: result.changes };
244
+ });
245
+ }
246
+
247
+ // Retrieve inbox entries grouped by interaction_id.
248
+ function getInboxByInteraction(interactionId) {
249
+ let db;
250
+ try { db = getDb(); }
251
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
252
+ return db.prepare('SELECT * FROM inbox_entries WHERE interaction_id = ? ORDER BY sent_at ASC')
253
+ .all(interactionId);
254
+ }
255
+
256
+ // Retrieve all unread entries for all recipients.
257
+ function getAllUnreadInboxEntries() {
258
+ let db;
259
+ try { db = getDb(); }
260
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
261
+ return db.prepare('SELECT * FROM inbox_entries WHERE unread = 1 ORDER BY sent_at DESC').all();
262
+ }
263
+
264
+ // Update the notified_at timestamp for an entry (marks when a notification was sent).
265
+ function markInboxEntryNotified(id) {
266
+ let db;
267
+ try { db = getDb(); }
268
+ catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
269
+
270
+ return withTransaction(db, () => {
271
+ const now = Date.now();
272
+ const result = db.prepare('UPDATE inbox_entries SET notified_at = ? WHERE id = ?').run(now, id);
273
+ if (result.changes > 0) _emitInboxEvent({ id, action: 'mark_notified' });
274
+ return { wrote: result.changes > 0 };
275
+ });
276
+ }
277
+
278
+ module.exports = {
279
+ // Read operations
280
+ getInboxUnreadCount,
281
+ getInboxForSession,
282
+ getInboxEntryById,
283
+ getInboxByInteraction,
284
+ getAllUnreadInboxEntries,
285
+
286
+ // Write operations
287
+ createInboxEntry,
288
+ markInboxEntriesAsRead,
289
+ markInboxEntriesAsUnread,
290
+ markAllInboxAsRead,
291
+ markInboxEntryNotified,
292
+ deleteInboxEntry,
293
+ deleteInboxEntries,
294
+ deleteInboxEntriesOlderThan,
295
+ };
package/engine/shared.js CHANGED
@@ -3485,25 +3485,8 @@ const ENGINE_DEFAULTS = {
3485
3485
  claudePreApproveWorkspaceMcps: true,
3486
3486
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
3487
3487
  copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
3488
- ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
3488
+ ccUseWorkerPool: true, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. On by default (PR #2492 cold-spawn measurements; matches engine/features.js's `ccUseWorkerPool` registry default) — opt out via `engine.ccUseWorkerPool: false`. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
3489
3489
  agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
3490
- // PR #321 — "kill auth-popup storm". Copilot spawns and authenticates EVERY
3491
- // MCP server in `$COPILOT_HOME/mcp-config.json` on every process start, even
3492
- // when the corresponding tool is hidden from the model via
3493
- // `--disable-mcp-server`. If left unfiltered, every auth-requiring user MCP
3494
- // (azure-kusto/azmcp, DevBox, workiq, loop, teams, …) boots and pops a
3495
- // Microsoft sign-in window on EVERY agent spawn — at minions' continuous
3496
- // spawn cadence that is an infinite popup storm.
3497
- //
3498
- // The engine therefore points agents at a private COPILOT_HOME
3499
- // (`shared.resolveAgentCopilotHome`) whose mcp-config is a copy of the operator's
3500
- // `~/.copilot/mcp-config.json` MINUS the names in this list (see engine.js
3501
- // `_applyAgentCopilotHome`). Default [] = inherit ALL of the operator's MCP
3502
- // servers for agents (non-breaking). Add a name to disable it for agents only;
3503
- // the operator's interactive `~/.copilot` is never touched. Per-agent override
3504
- // `agent.copilotAgentDisabledMcpServers`. Resolved through
3505
- // `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
3506
- copilotAgentDisabledMcpServers: [],
3507
3490
  maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
3508
3491
  disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
3509
3492
  // Phase 8 (qa-runs.json + qa-sessions.json → SQL). When true, every QA
@@ -3605,7 +3588,7 @@ const ENGINE_DEFAULTS = {
3605
3588
  agentMemorySummaryEnabled: false, // opt-in: when true, eviction batches go through an LLM-compressed summary before being dropped. Default off to mirror the conservative gating on the existing reconcile pass (LLM cost + test stability). Operators flip via engine.agentMemorySummaryEnabled.
3606
3589
  agentMemorySummaryThreshold: 30, // batch window: when summary is enabled and a prune evicts entries, fold at least this many oldest sections into one summary. Means "summary every ~30 entries" in steady state (the original PRD intent).
3607
3590
  agentMemorySummaryDays: 30, // age trigger: when the oldest section is older than this and >= agentMemorySummaryThreshold entries exist, summarize the oldest window even if the file is under the entry cap.
3608
- memoryRetrievalShadowMode: true, // compute + measure FTS5 retrieval but preserve legacy prompt injection until explicitly disabled
3591
+ memoryRetrievalShadowMode: false, // inject bounded FTS5-selected memory by default; true computes telemetry while preserving legacy prompt injection
3609
3592
  memoryRetrievalTopK: 8, // maximum structured memory records injected into one dispatch
3610
3593
  memoryRetrievalMaxBytes: 8 * 1024, // hard byte budget for the Relevant Memory prompt appendix
3611
3594
  memoryRetrievalCandidateLimit: 50, // bounded FTS5 candidate pool before deterministic re-ranking
@@ -3863,7 +3846,7 @@ function resolveCcUseWorkerPool(engine) {
3863
3846
  if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
3864
3847
  return engine.ccUseWorkerPool;
3865
3848
  }
3866
- return true; // CC runtime is copilot — default ON (cold-start savings)
3849
+ return ENGINE_DEFAULTS.ccUseWorkerPool;
3867
3850
  }
3868
3851
 
3869
3852
  /**
@@ -3988,39 +3971,6 @@ function resolveAgentBareMode(agent, engine) {
3988
3971
  return false;
3989
3972
  }
3990
3973
 
3991
- // P-mcp-storm — Resolve the list of MCP server names to disable for spawned
3992
- // Copilot agents. The engine FILTERS these out of the agent's seeded
3993
- // COPILOT_HOME mcp-config (engine.js `_applyAgentCopilotHome`) — a server absent
3994
- // from the config can't be spawned, which is the only effective disable
3995
- // (`--disable-mcp-server` does not stop the process). Chain: per-agent
3996
- // `agent.copilotAgentDisabledMcpServers` → `engine.copilotAgentDisabledMcpServers`
3997
- // → `[]` (inherit all). Tolerant of either an array OR a comma/whitespace-
3998
- // separated string (Settings UI / `MINIONS_*` env deliver strings). Always
3999
- // returns a deduped array of trimmed, non-empty strings. See ENGINE_DEFAULTS
4000
- // for the full rationale.
4001
- function _normalizeMcpServerList(v) {
4002
- if (v == null) return null;
4003
- const raw = Array.isArray(v) ? v : String(v).split(/[\s,]+/);
4004
- const out = [];
4005
- const seen = new Set();
4006
- for (const item of raw) {
4007
- const name = String(item == null ? '' : item).trim();
4008
- if (!name || seen.has(name)) continue;
4009
- seen.add(name);
4010
- out.push(name);
4011
- }
4012
- return out;
4013
- }
4014
-
4015
- function resolveCopilotAgentDisabledMcpServers(agent, engine) {
4016
- const a = agent ? _normalizeMcpServerList(agent.copilotAgentDisabledMcpServers) : null;
4017
- if (a) return a;
4018
- const e = engine ? _normalizeMcpServerList(engine.copilotAgentDisabledMcpServers) : null;
4019
- if (e) return e;
4020
- return [];
4021
- }
4022
-
4023
-
4024
3974
  // ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
4025
3975
  //
4026
3976
  // Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
@@ -6051,91 +6001,6 @@ function resolveAgentTempBaseDir(project, engine, minionsDir) {
6051
6001
  return path.join(anchor, '.agent-temp');
6052
6002
  }
6053
6003
 
6054
- /**
6055
- * Resolve the stable, repo-external COPILOT_HOME used to isolate spawned
6056
- * Copilot agents from the operator's interactive `~/.copilot` MCP servers
6057
- * (W-copilot-auth-popup-storm / PR #321 — "give copilot agents an MCP-free
6058
- * COPILOT_HOME (kill auth-popup storm)"). Copilot spawns and authenticates
6059
- * EVERY server in `$COPILOT_HOME/mcp-config.json` on every process start;
6060
- * `--disable-mcp-server <name>` only hides that server's tools from the model,
6061
- * it does NOT stop the server process from launching (verified live: agents
6062
- * spawned azmcp.exe/workiq.exe despite those servers being in
6063
- * `copilotAgentDisabledMcpServers`). The only effective lever is to point
6064
- * agents at a COPILOT_HOME whose mcp-config has NO disabled-list servers — see
6065
- * `ensureAgentCopilotHome` below, which the engine seeds at boot and re-stamps
6066
- * per dispatch (`_applyAgentCopilotHome` in engine.js).
6067
- *
6068
- * `COPILOT_HOME` overrides `$HOME/.copilot`; copilot auth is unaffected (it
6069
- * uses GH_TOKEN / the OS credential store, not files under the home), and the
6070
- * operator's real `~/.copilot` — i.e. their interactive copilot — is
6071
- * completely untouched.
6072
- *
6073
- * Placed OUTSIDE the repo so concurrent git branch switches in the working
6074
- * tree can never delete or dirty it, on the MINIONS_DIR volume (copilot's
6075
- * session-store can grow to GBs, so keep it off a possibly-full system
6076
- * drive). Stable path so copilot session resume (`--resume`) stays
6077
- * consistent across spawns. Falls back to the user home when no minionsDir
6078
- * is given.
6079
- *
6080
- * Base selection is cross-platform (W-copilot-home-posix-root): on Windows
6081
- * the MINIONS_DIR drive root (`C:\`, `D:\`, or a UNC share root) is
6082
- * user-writable, so we anchor there. On POSIX the filesystem root (`/`) is
6083
- * NOT user-writable — a home placed there can never be created (`mkdir`
6084
- * EACCES), `ensureAgentCopilotHome` fail-opens and returns the uncreatable
6085
- * path anyway, and every spawned `copilot --acp` / copilot agent then
6086
- * inherits a `COPILOT_HOME` it can't use and exits code 1 silently. So when
6087
- * the drive root is the bare POSIX root we fall back to the user home —
6088
- * repo-external, writable, and on the user's own volume.
6089
- */
6090
- function resolveAgentCopilotHome(minionsDir) {
6091
- let base;
6092
- if (minionsDir) {
6093
- const root = path.parse(path.resolve(String(minionsDir))).root;
6094
- // The POSIX filesystem root ('/') is not user-writable; Windows drive/UNC
6095
- // roots are. Anchor at the user home in the unwritable-root case.
6096
- base = (root === '/' || root === path.sep) ? os.homedir() : root;
6097
- } else {
6098
- base = os.homedir();
6099
- }
6100
- return path.join(base, '.minions-agent-copilot-home');
6101
- }
6102
-
6103
- // Seed the agent COPILOT_HOME's mcp-config (copy of `~/.copilot/mcp-config.json`
6104
- // MINUS `engine.copilotAgentDisabledMcpServers`) and return the home path. This
6105
- // is the source of truth for "what MCP servers may an agent-dispatch copilot
6106
- // load." It is applied to the engine-process copilot spawn paths ONLY — agent
6107
- // dispatches (`_applyAgentCopilotHome`) and the engine process's own internal
6108
- // `llm.callLLM` calls — by setting `process.env.COPILOT_HOME` at engine boot
6109
- // and re-stamping it per dispatch, so those copilot CHILDREN inherit the
6110
- // filtered config. copilot SPAWNS every server in its config and
6111
- // authenticates it (`--disable-mcp-server` only hides tools), so any
6112
- // auth-requiring server pops a Microsoft window per spawn — filtering the
6113
- // config is the only effective control. Idempotent: writes only when the
6114
- // content changes (safe under concurrent callers). The operator's real
6115
- // `~/.copilot` (interactive copilot, and now also Command Center / doc-chat /
6116
- // the CC worker pool, per W-mre75l6x00024f49) is never touched. Fail-open.
6117
- function ensureAgentCopilotHome(minionsDir, engine) {
6118
- const home = resolveAgentCopilotHome(minionsDir);
6119
- try {
6120
- fs.mkdirSync(home, { recursive: true });
6121
- const disabled = new Set(resolveCopilotAgentDisabledMcpServers(null, engine));
6122
- let servers = {};
6123
- try {
6124
- const userCfgPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
6125
- const userCfg = JSON.parse(fs.readFileSync(userCfgPath, 'utf8').replace(/^/, ''));
6126
- for (const [name, def] of Object.entries(userCfg.mcpServers || {})) {
6127
- if (!disabled.has(name)) servers[name] = def;
6128
- }
6129
- } catch { servers = {}; /* no/unreadable user config → no MCPs */ }
6130
- const desired = JSON.stringify({ mcpServers: servers }, null, 2);
6131
- const cfgFile = path.join(home, 'mcp-config.json');
6132
- let current = null;
6133
- try { current = fs.readFileSync(cfgFile, 'utf8'); } catch {}
6134
- if (current !== desired) fs.writeFileSync(cfgFile, desired);
6135
- } catch { /* fail-open: leave whatever home/config exists */ }
6136
- return home;
6137
- }
6138
-
6139
6004
  // ── Spawn cwd vs worktree placement (W-mp73x32w000l143d) ──────────────────────
6140
6005
  // Work types that don't need a git worktree — they read repo state but don't
6141
6006
  // produce code changes. Centralized here so engine.js spawnAgent and any
@@ -9056,7 +8921,6 @@ module.exports = {
9056
8921
  resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
9057
8922
  resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
9058
8923
  resolveAgentMaxBudget, resolveAgentBareMode,
9059
- resolveCopilotAgentDisabledMcpServers,
9060
8924
  applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
9061
8925
  runtimeConfigWarnings,
9062
8926
  projectWorkSourceWarnings,
@@ -9162,9 +9026,7 @@ module.exports = {
9162
9026
  parseWorktreePorcelain,
9163
9027
  assertWorktreeOutsideProject,
9164
9028
  resolveProjectRootDir,
9165
- resolveAgentCopilotHome,
9166
9029
  resolveAgentTempBaseDir,
9167
- ensureAgentCopilotHome,
9168
9030
  resolveSpawnPaths,
9169
9031
  validateWorkItemWorkdir,
9170
9032
  applyWorkdir,
package/engine.js CHANGED
@@ -2100,32 +2100,6 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
2100
2100
  }
2101
2101
  }
2102
2102
 
2103
- // Seed a spawned agent's COPILOT_HOME mcp-config as a copy of the operator's
2104
- // `~/.copilot/mcp-config.json` MINUS the servers in
2105
- // `engine.copilotAgentDisabledMcpServers`, then point COPILOT_HOME at it.
2106
- //
2107
- // Why config-filtering and not `--disable-mcp-server`: copilot SPAWNS every
2108
- // server in its config on startup and authenticates it; `--disable-mcp-server
2109
- // <name>` only hides that server's TOOLS from the model, it does NOT stop the
2110
- // server process (PR #321 — verified live: agents spawned azmcp.exe/workiq.exe
2111
- // despite those servers being in `copilotAgentDisabledMcpServers`).
2112
- //
2113
- // Default disabled list `[]` => agents inherit ALL of the operator's MCP servers
2114
- // (non-breaking; matches interactive copilot). Add a name to disable it for
2115
- // agents. COPILOT_HOME is copilot-specific; claude/codex ignore it, so this is
2116
- // set unconditionally (no runtime.name branching, per the adapter contract).
2117
- // Copilot auth is unaffected (GH_TOKEN / OS credential store); the operator's
2118
- // real ~/.copilot is untouched. Best-effort/fail-open: any failure leaves the
2119
- // inherited COPILOT_HOME so a hiccup never blocks a dispatch.
2120
- function _applyAgentCopilotHome(childEnv) {
2121
- // Re-seed (picks up Settings changes to the disabled list) + stamp the env.
2122
- // process.env.COPILOT_HOME is also set at engine boot (see below) so the
2123
- // child would inherit it anyway; this is belt-and-suspenders + keeps the
2124
- // seed fresh per dispatch. See shared.ensureAgentCopilotHome.
2125
- try { childEnv.COPILOT_HOME = shared.ensureAgentCopilotHome(MINIONS_DIR, getConfig()?.engine); }
2126
- catch { /* leave inherited COPILOT_HOME untouched */ }
2127
- }
2128
-
2129
2103
  // Point a spawned agent's TEMP/TMP/TMPDIR at a scratch dir on the worktree
2130
2104
  // volume instead of the (often full) system drive — see
2131
2105
  // shared.resolveAgentTempBaseDir. copilot/node/git + JVM-based MCP servers write
@@ -4598,16 +4572,6 @@ async function spawnAgent(dispatchItem, config) {
4598
4572
  // random localhost port (blank window). Belt-and-suspenders with dashboard.js's
4599
4573
  // stdout.isTTY gate.
4600
4574
  childEnv.MINIONS_NO_AUTO_OPEN = '1';
4601
- // MCP-free copilot config so agents never pop a per-spawn Microsoft auth
4602
- // window — see _applyAgentCopilotHome / shared.resolveAgentCopilotHome.
4603
- // P-1d8f0b93 — SKIPPED for pooled dispatches: the pooled ACP worker is a
4604
- // long-lived process shared across many dispatches, so it always uses the
4605
- // operator's REAL COPILOT_HOME (this is the isolation-removal half of the
4606
- // pooling feature — MCP-auth-popup risk becomes bounded by pool size
4607
- // instead of eliminated per-dispatch; documented tradeoff in the plan).
4608
- if (!useAgentPool) {
4609
- _applyAgentCopilotHome(childEnv);
4610
- }
4611
4575
  // Keep agent scratch off a (possibly full) system drive — anchors TEMP/TMP to
4612
4576
  // the worktree volume. See _applyAgentTempEnv / shared.resolveAgentTempBaseDir.
4613
4577
  _applyAgentTempEnv(childEnv, project);
@@ -5059,8 +5023,6 @@ async function spawnAgent(dispatchItem, config) {
5059
5023
  // W-mqef-dashboard-tty — same browser-popup suppression on steering resume
5060
5024
  // (see the initial spawn site). Agents must never auto-open a browser.
5061
5025
  childEnv.MINIONS_NO_AUTO_OPEN = '1';
5062
- // Same MCP-free copilot home on steering resume (see the initial spawn site).
5063
- _applyAgentCopilotHome(childEnv);
5064
5026
  // Same agent-scratch redirect on steering resume.
5065
5027
  _applyAgentTempEnv(childEnv, project);
5066
5028
  if (getRepoHost(project) === 'ado') {
@@ -11685,17 +11647,6 @@ function ensureGitHooksInstalled() {
11685
11647
 
11686
11648
  if (require.main === module) {
11687
11649
  try { ensureGitHooksInstalled(); } catch { /* best-effort */ }
11688
- // Every copilot this process spawns — agent dispatches AND internal
11689
- // `llm.callLLM` calls (consolidation, classification, meetings, …) — inherits
11690
- // this process's COPILOT_HOME, so point it at the seeded, MCP-filtered home.
11691
- // Without this, those `direct:true` copilot calls load the operator's full
11692
- // `~/.copilot` stack and pop a Microsoft auth window per call. This isolation
11693
- // is engine-process-only: the dashboard process deliberately does NOT set
11694
- // COPILOT_HOME at its own boot, so Command Center / doc-chat / the CC worker
11695
- // pool inherit the operator's real `~/.copilot` home instead (reverted in
11696
- // W-mre75l6x00024f49; see dashboard.js boot for rationale).
11697
- // See shared.ensureAgentCopilotHome. Fail-open.
11698
- try { process.env.COPILOT_HOME = shared.ensureAgentCopilotHome(MINIONS_DIR, getConfig()?.engine); } catch {}
11699
11650
  const { handleCommand } = require('./engine/cli');
11700
11651
  const [cmd, ...args] = process.argv.slice(2);
11701
11652
  handleCommand(cmd, args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2373",
3
+ "version": "0.1.2375",
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"