@yemi33/minions 0.1.2378 → 0.1.2379

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.
package/docs/README.md CHANGED
@@ -41,6 +41,7 @@ Architecture, design proposals, and lifecycle references for people working on t
41
41
  - [cross-repo-plans.md](cross-repo-plans.md) — Cross-repo plans: a single plan whose work items ship into two or more configured projects — per-item `project` field, per-project work-item fan-out, and one verify work item per touched repo.
42
42
  - [dead-code-audit-retractions.md](dead-code-audit-retractions.md) — Retracted dead-code-audit findings (false positives) that future audits MUST read before re-citing.
43
43
  - [deprecated-process.md](deprecated-process.md) — Schema for `docs/deprecated.json` and the weekly `cleanup-deprecated` audit walk that retires entries past their removal signal.
44
+ - [design-inbox-entries-schema.md](design-inbox-entries-schema.md) — Implemented SQL schema and store contract for the future operator/agent inbox queue, including current indexes, mutators, event emission, and integration status.
44
45
  - [design-state-storage.md](design-state-storage.md) — Design proposal evaluating five database options for replacing Minions' file-based JSON state; recommends `node:sqlite` (accepted; implementation tracked in CHANGELOG.md Phases 0–10).
45
46
  - [harness-mode.md](harness-mode.md) — Tri-Agent Harness Mode (`harness_mode: "tri_agent"` on scheduled tasks): Planner → Generator → Evaluator loop that iterates a shared on-disk artifact until a rubric passes or the iteration cap fires.
46
47
  - [harness-propagation.md](harness-propagation.md) — The native runtime
@@ -1,7 +1,7 @@
1
1
  # inbox_entries Table Schema
2
2
 
3
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.
4
+ The `inbox_entries` table and `engine/inbox-store.js` implement a persistent, time-ordered, unread-tracking message queue decoupled from the runtime work-item/dispatch lifecycle. The store API exists, but production engine, dashboard, and consolidation paths do not consume it yet; those paths still use their existing inbox mechanisms.
5
5
 
6
6
  ## Schema Specification
7
7
 
@@ -9,26 +9,27 @@ The `inbox_entries` table is the persistent store for operator and agent inbox m
9
9
 
10
10
  | Column | Type | Constraints | Purpose |
11
11
  |--------|------|-----------|---------|
12
- | `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>` format |
12
+ | `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>-<counter>-<random>` |
13
13
  | `recipient_session_id` | TEXT | NOT NULL | Session ID of the inbox owner (operator or agent); routes messages to specific actor |
14
14
  | `sender_id` | TEXT | NOT NULL | Identifier of the message originator; structured as `<agent-id>` or `'system'` or `'operator'` |
15
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 |
16
+ | `sender_type` | TEXT | NOT NULL | Sender category; `createInboxEntry()` accepts `'agent'`, `'system'`, or `'operator'` |
17
+ | `interaction_id` | TEXT | NOT NULL | Caller-supplied group ID for related messages |
18
+ | `sequence` | INTEGER | NOT NULL, DEFAULT 0 | Caller-supplied per-interaction ordering metadata; not required to be unique |
19
19
  | `summary` | TEXT | NOT NULL | Title/subject line; used for list views and user scanning; always present even if content is truncated |
20
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 |
21
+ | `unread` | INTEGER | NOT NULL, DEFAULT 1 | Boolean flag (0/1); changed by the read/unread mutators |
22
22
  | `sent_at` | INTEGER | NOT NULL | Unix timestamp (milliseconds) of message creation; primary sort key for time-ordered display |
23
23
  | `read_at` | INTEGER | Nullable | Unix timestamp (milliseconds) when recipient first read the message; null until marked read |
24
24
  | `notified_at` | INTEGER | Nullable | Unix timestamp (milliseconds) of last notification event sent to operator; null if no notification dispatched |
25
25
 
26
26
  ### Constraints & Relationships
27
27
 
28
- - **PK constraint:** `id` is globally unique across all inboxes; collision risk is negligible given the composite structure (recipient + timestamp + sender + seq).
28
+ - **PK constraint:** `id` is globally unique across all inboxes. The store combines caller context with a process-local counter and random suffix, then retries a conflicting insert up to five times.
29
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.
30
+ - **FK (soft):** `sender_id` is informational only; no validation requires it to exist in `config.agents` or active sessions.
31
+ - **Application validation:** `createInboxEntry()` accepts only `agent`, `system`, or `operator` for `sender_type`; SQLite does not enforce that enum.
32
+ - **Indexes:** Migration 019 creates composite recipient/time and interaction indexes. See Performance Considerations.
32
33
 
33
34
  ## Architectural Rationale
34
35
 
@@ -36,71 +37,62 @@ The `inbox_entries` table is the persistent store for operator and agent inbox m
36
37
 
37
38
  1. **Separate inbox table vs. inline dispatch/work-item attachments.**
38
39
  - 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.
40
+ - Decoupling allows independent read/unread and deletion lifecycle without cascading work-item deletes.
41
41
 
42
42
  2. **`unread` flag instead of soft-delete or status enum.**
43
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).
44
+ - Unread state is mutable; allows mark-as-read and mark-as-unread behavior without deleting content.
46
45
 
47
46
  3. **`sender_type` as an explicit column.**
48
- - Allows schema validation (e.g., "agent" senders must have valid IDs; "system" is always valid).
47
+ - Allows application-level validation of the sender category.
49
48
  - Enables filtered views ("show only agent messages" or "system alerts only") without string parsing.
50
49
 
51
50
  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.
51
+ - `getInboxByInteraction()` retrieves related messages in chronological order.
54
52
 
55
53
  5. **Persistent `sender_name` at write-time.**
56
54
  - 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.
55
+ - Future consumers can render the stored name without a runtime agent lookup.
58
56
 
59
57
  6. **Unix timestamps (milliseconds) instead of ISO strings.**
60
58
  - Enables range queries (`sent_at BETWEEN ? AND ?`) without parsing.
61
59
  - Smaller column footprint; easier to sort.
62
60
  - Downside: not human-readable; UI must convert to locale strings.
63
61
 
64
- ### Entity Relationships
62
+ ### Integration Status
65
63
 
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.
64
+ The table currently has no foreign keys and `engine/inbox-store.js` has no production callers. Dispatch feedback, PR notifications, dashboard inbox views, and consolidation are potential consumers, not implemented relationships. Until those call sites migrate, this table is not the source of truth for `notes/inbox/` or agent steering inbox files.
69
65
 
70
66
  ## Performance Considerations
71
67
 
72
68
  ### Indexing Strategy
73
69
 
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?").
70
+ **Current indexes (created by migration 019):**
71
+ - `idx_inbox_recipient_sent` on `(recipient_session_id, sent_at DESC)` — supports recipient-scoped chronological reads.
72
+ - `idx_inbox_interaction` on `(interaction_id)` — supports grouping by interaction.
77
73
  - PK `id` is implicit index.
78
74
 
79
75
  **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).
76
+ - **Unread count per session:** `SELECT COUNT(*) ... WHERE recipient_session_id = ? AND unread = 1` narrows by recipient, then filters `unread`.
77
+ - **Latest messages:** `SELECT * ... WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT ? OFFSET ?` uses `idx_inbox_recipient_sent`.
78
+ - **Thread view:** `SELECT * ... WHERE interaction_id = ? ORDER BY sent_at ASC` uses `idx_inbox_interaction`, then sorts matching rows.
79
+ - **Cleanup:** `deleteInboxEntriesOlderThan(timestampMs, { unreadOnly })` deletes all older rows by default or only rows with `unread = 1`; no production cleanup job calls it yet.
84
80
 
85
81
  **Missing indexes (future optimization):**
86
82
  - `idx_sender_id` — if queries like "messages from this agent" are common, consider adding.
87
83
  - `idx_unread` — if badge ("N unread") is a hot query, consider combining with recipient (`idx_recipient_unread` on `(recipient_session_id, unread)`).
88
84
 
89
- ### Size Estimates & Cleanup
85
+ ### Cleanup
90
86
 
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.
87
+ - **Retention:** No automatic cleanup is wired. The store exposes explicit single, batch, and age-based hard-delete helpers.
95
88
 
96
89
  ## Migration & Backward Compatibility
97
90
 
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).
91
+ - **Migration 019** creates `inbox_entries` and its two secondary indexes. It does not import existing Markdown inbox files.
92
+ - The store supports inserts, read/unread updates, notification timestamps, and hard deletes. Every mutator attempts a best-effort `inbox` state event after a real change so dashboard cache versions can advance once consumers are wired.
100
93
 
101
94
  ## Related Code
102
95
 
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
-
96
+ - **Schema:** `engine/db/migrations/019-inbox-entries.js`.
97
+ - **Store:** `engine/inbox-store.js` (reads, transactional mutators, and best-effort `emitStateEvent('inbox', ...)` after real changes).
98
+ - **Consumers:** none in production yet.
@@ -97,9 +97,10 @@ const WARM_MAX_CONCURRENT = 3;
97
97
  // W-mqid8uev). resolveBinary() resolves the real JS entry
98
98
  // (node_modules/@github/copilot/index.js) and reports whether to spawn it
99
99
  // directly (native) or under the current Node process (shim).
100
- function spawnAcp({ cwd } = {}) {
100
+ function spawnAcp({ cwd, env } = {}) {
101
101
  const copilot = require('./runtimes/copilot');
102
- const resolved = copilot.resolveBinary({ env: process.env });
102
+ const workerEnv = env || process.env;
103
+ const resolved = copilot.resolveBinary({ env: workerEnv });
103
104
  if (!resolved || !resolved.bin) {
104
105
  throw new Error(
105
106
  `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
@@ -114,6 +115,7 @@ function spawnAcp({ cwd } = {}) {
114
115
  return spawn(execBin, execArgs, {
115
116
  stdio: ['pipe', 'pipe', 'pipe'],
116
117
  cwd: cwd || process.cwd(),
118
+ env: workerEnv,
117
119
  windowsHide: true,
118
120
  // Don't pass shell:true — the native/leadingArgs handling above already
119
121
  // produces a directly-spawnable argv, and a shell wrapper would swallow
@@ -139,6 +141,33 @@ function hashMcpServers(mcpServers) {
139
141
  return crypto.createHash('sha256').update(json).digest('hex');
140
142
  }
141
143
 
144
+ function _stableObject(value) {
145
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
146
+ return Object.fromEntries(
147
+ Object.entries(value)
148
+ .filter(([, entry]) => entry !== undefined)
149
+ .sort(([a], [b]) => a.localeCompare(b))
150
+ );
151
+ }
152
+
153
+ function hashProcessContext(processEnv) {
154
+ return crypto.createHash('sha256')
155
+ .update(JSON.stringify(_stableObject(processEnv)))
156
+ .digest('hex');
157
+ }
158
+
159
+ function buildTrustedSessionContext(sessionContext) {
160
+ const context = _stableObject(sessionContext);
161
+ if (Object.keys(context).length === 0) return '';
162
+ const json = JSON.stringify(context, null, 2).replace(/<\/system/gi, '<\\/system');
163
+ return [
164
+ 'Trusted pooled-dispatch context (session-scoped):',
165
+ 'These values are the authoritative equivalents of the same-named environment variables for this ACP session.',
166
+ 'Use them directly; do not try to recover them from the reusable worker process environment.',
167
+ json,
168
+ ].join('\n');
169
+ }
170
+
142
171
  // Bug B (issue #2479): `model` and `effort` were stored on the Worker but
143
172
  // never forwarded to ACP `session/new`, so any model override was silently
144
173
  // dropped. Build the params object here and only include fields that are
@@ -216,12 +245,17 @@ function mapAcpToolCallToToolUse(update) {
216
245
  * logger (default no-op); consumers wire their own env-gated trace prefix.
217
246
  */
218
247
  class Worker {
219
- constructor({ id, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd, internals, trace }) {
248
+ constructor({
249
+ id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
250
+ systemPromptHash, cwd, internals, trace,
251
+ }) {
220
252
  this.id = id;
221
253
  this.model = model;
222
254
  this.effort = effort;
223
255
  this.mcpServers = mcpServers || [];
224
256
  this.mcpServersHash = mcpServersHash;
257
+ this.processEnv = processEnv;
258
+ this.processContextHash = processContextHash;
225
259
  this.systemPromptHash = systemPromptHash;
226
260
  this.cwd = cwd || process.cwd();
227
261
  this._internals = internals;
@@ -249,7 +283,7 @@ class Worker {
249
283
  async _spawnAndInit() {
250
284
  let proc;
251
285
  try {
252
- proc = this._internals.spawnAcp({ cwd: this.cwd });
286
+ proc = this._internals.spawnAcp({ cwd: this.cwd, env: this.processEnv });
253
287
  } catch (err) {
254
288
  // spawn() threw synchronously — typically ENOENT (copilot binary not
255
289
  // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
@@ -440,7 +474,10 @@ class Worker {
440
474
 
441
475
  // ── Stream a single turn ───────────────────────────────────────────────
442
476
  stream(promptText, opts = {}) {
443
- const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
477
+ const {
478
+ onChunk, onToolUse, onToolUpdate, onDone, onError, signal,
479
+ systemPromptText, trustedSessionContext, deferSystemPrompt,
480
+ } = opts;
444
481
  if (this.killed) {
445
482
  const err = new Error('acp-transport: worker is closed');
446
483
  if (onError) try { onError(err); } catch { /* swallow */ }
@@ -458,10 +495,12 @@ class Worker {
458
495
  // adapter pattern of <system>...</system> prepended to the first user
459
496
  // message remains correct.
460
497
  let prompt = promptText;
461
- if (!this.firstSystemPromptSent && systemPromptText) {
462
- prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
498
+ const trustedContextText = buildTrustedSessionContext(trustedSessionContext);
499
+ const trustedSystemText = [systemPromptText, trustedContextText].filter(Boolean).join('\n\n');
500
+ if (!this.firstSystemPromptSent && trustedSystemText) {
501
+ prompt = `<system>\n${trustedSystemText}\n</system>\n\n${promptText}`;
463
502
  }
464
- this.firstSystemPromptSent = true;
503
+ if (!deferSystemPrompt) this.firstSystemPromptSent = true;
465
504
 
466
505
  const id = this.nextReqId++;
467
506
  const inflight = {
@@ -586,6 +625,8 @@ module.exports = {
586
625
  spawnAcp,
587
626
  killImmediate,
588
627
  hashMcpServers,
628
+ hashProcessContext,
629
+ buildTrustedSessionContext,
589
630
  buildSessionNewParams,
590
631
  extractChunkText,
591
632
  mapAcpToolCallToToolUse,
@@ -59,7 +59,7 @@
59
59
  */
60
60
 
61
61
  const transport = require('./acp-transport');
62
- const { Worker, hashMcpServers } = transport;
62
+ const { Worker, hashMcpServers, hashProcessContext } = transport;
63
63
 
64
64
  // Test seam — mirrors engine/cc-worker-pool.js's `_internals` pattern, but is
65
65
  // its OWN object (constructor-injected into Worker per acquisition) so this
@@ -106,6 +106,7 @@ let _nextWorkerId = 1;
106
106
  const _free = []; // Worker[] — idle, unleased
107
107
  const _busy = new Map(); // dispatchId → Worker — currently leased
108
108
  const _queue = []; // pending acquire requests, FIFO
109
+ const _leaseContexts = new Map(); // dispatchId → mutable session-only context
109
110
  // Slots reserved for a worker that's mid-reuse/respawn/fresh-spawn — pulled
110
111
  // out of `_free` (or not yet spawned) but not yet placed in `_busy`. Counted
111
112
  // toward `_poolSize` so a burst of concurrent acquireWorker calls can't
@@ -130,7 +131,10 @@ function _pump() {
130
131
  while (_queue.length > 0) {
131
132
  const req = _queue[0];
132
133
 
133
- const matchIdx = _free.findIndex((w) => w.mcpServersHash === req.mcpServersHash);
134
+ const matchIdx = _free.findIndex((w) =>
135
+ w.mcpServersHash === req.mcpServersHash
136
+ && w.processContextHash === req.processContextHash
137
+ );
134
138
  if (matchIdx >= 0) {
135
139
  _queue.shift();
136
140
  const worker = _free.splice(matchIdx, 1)[0];
@@ -215,6 +219,8 @@ async function _bootWorker(req) {
215
219
  effort: req.effort,
216
220
  mcpServers: req.mcpServers,
217
221
  mcpServersHash: req.mcpServersHash,
222
+ processEnv: req.processEnv,
223
+ processContextHash: req.processContextHash,
218
224
  cwd: req.cwd,
219
225
  internals: _internals,
220
226
  trace: _trace,
@@ -246,6 +252,7 @@ function _onWorkerExit(worker) {
246
252
  for (const [dispatchId, w] of _busy) {
247
253
  if (w === worker) {
248
254
  _busy.delete(dispatchId);
255
+ _clearLeaseContext(dispatchId);
249
256
  removed = true;
250
257
  break;
251
258
  }
@@ -272,6 +279,7 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
272
279
  worker.stream(`/add-dir ${dir}`, {
273
280
  onDone: () => { _trace(`worker=${worker.id} add-dir ok: ${dir}`); resolve(); },
274
281
  onError: (err) => { _trace(`worker=${worker.id} add-dir failed: ${dir} (${err && err.message})`); resolve(); },
282
+ deferSystemPrompt: true,
275
283
  }).catch(() => resolve());
276
284
  });
277
285
  }
@@ -279,9 +287,19 @@ async function _applyHermeticDirs(worker, hermeticDirs) {
279
287
 
280
288
  // ── Public API ────────────────────────────────────────────────────────────
281
289
 
282
- async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, hermeticDirs } = {}) {
290
+ function _clearLeaseContext(dispatchId) {
291
+ const context = _leaseContexts.get(dispatchId);
292
+ if (!context) return false;
293
+ for (const key of Object.keys(context)) delete context[key];
294
+ _leaseContexts.delete(dispatchId);
295
+ return true;
296
+ }
297
+
298
+ async function acquireWorker({
299
+ dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
300
+ } = {}) {
283
301
  if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
284
- if (_busy.has(dispatchId)) {
302
+ if (_busy.has(dispatchId) || _leaseContexts.has(dispatchId)) {
285
303
  throw new Error(
286
304
  `agent-worker-pool.acquireWorker: dispatchId ${dispatchId} already has an active worker ` +
287
305
  '(1 worker : 1 active dispatch invariant — call release() before acquiring again)'
@@ -289,16 +307,34 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
289
307
  }
290
308
 
291
309
  const mcpServersHash = hashMcpServers(mcpServers);
292
- const req = { dispatchId, cwd, model, effort, mcpServers, mcpServersHash };
310
+ const workerProcessEnv = processEnv && typeof processEnv === 'object'
311
+ ? Object.freeze({ ...processEnv })
312
+ : undefined;
313
+ const processContextHash = hashProcessContext(workerProcessEnv);
314
+ const leaseContext = sessionContext && typeof sessionContext === 'object'
315
+ ? { ...sessionContext }
316
+ : {};
317
+ const req = {
318
+ dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
319
+ processEnv: workerProcessEnv, processContextHash,
320
+ };
321
+ _leaseContexts.set(dispatchId, leaseContext);
293
322
 
294
- const worker = await new Promise((resolve, reject) => {
295
- req.resolve = resolve;
296
- req.reject = reject;
297
- _queue.push(req);
298
- _pump();
299
- });
323
+ let worker;
324
+ try {
325
+ worker = await new Promise((resolve, reject) => {
326
+ req.resolve = resolve;
327
+ req.reject = reject;
328
+ _queue.push(req);
329
+ _pump();
330
+ });
300
331
 
301
- await _applyHermeticDirs(worker, hermeticDirs);
332
+ await _applyHermeticDirs(worker, hermeticDirs);
333
+ if (worker.killed) throw new Error('agent-worker-pool: worker died during acquisition');
334
+ } catch (err) {
335
+ _clearLeaseContext(dispatchId);
336
+ throw err;
337
+ }
302
338
  _busy.set(dispatchId, worker);
303
339
 
304
340
  return {
@@ -311,8 +347,20 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
311
347
  // captured copy), since sessionId can rotate under a reused worker.
312
348
  get pid() { return (worker.proc && worker.proc.pid) || null; },
313
349
  get sessionId() { return worker.sessionId || null; },
314
- stream: (promptText, opts) => worker.stream(promptText, opts),
315
- cancel: () => worker.cancel(),
350
+ stream: async (promptText, opts = {}) => {
351
+ try {
352
+ return await worker.stream(promptText, {
353
+ ...opts,
354
+ trustedSessionContext: leaseContext,
355
+ });
356
+ } finally {
357
+ _clearLeaseContext(dispatchId);
358
+ }
359
+ },
360
+ cancel: () => {
361
+ _clearLeaseContext(dispatchId);
362
+ return worker.cancel();
363
+ },
316
364
  release: () => release(dispatchId),
317
365
  };
318
366
  }
@@ -325,6 +373,7 @@ async function acquireWorker({ dispatchId, cwd, model, effort, mcpServers, herme
325
373
  // never acquired) — safe to call defensively.
326
374
  function release(dispatchId) {
327
375
  const worker = _busy.get(dispatchId);
376
+ _clearLeaseContext(dispatchId);
328
377
  if (!worker) return false;
329
378
  _busy.delete(dispatchId);
330
379
  if (worker.killed) {
@@ -340,6 +389,7 @@ function release(dispatchId) {
340
389
 
341
390
  function shutdown() {
342
391
  for (const req of _queue.splice(0)) {
392
+ _clearLeaseContext(req.dispatchId);
343
393
  try { req.reject(new Error('agent-worker-pool: shutdown')); } catch { /* swallow */ }
344
394
  }
345
395
  for (const worker of _free.splice(0)) {
@@ -349,6 +399,7 @@ function shutdown() {
349
399
  try { worker.close(); } catch { /* swallow */ }
350
400
  }
351
401
  _busy.clear();
402
+ for (const dispatchId of [..._leaseContexts.keys()]) _clearLeaseContext(dispatchId);
352
403
  _reserved = 0;
353
404
  }
354
405
 
@@ -364,4 +415,5 @@ module.exports = {
364
415
  _free,
365
416
  _busy,
366
417
  _queue,
418
+ _leaseContexts,
367
419
  };
@@ -45,9 +45,15 @@ class PooledAgentProcess extends EventEmitter {
45
45
  * @param {string} [opts.effort]
46
46
  * @param {Array} [opts.mcpServers]
47
47
  * @param {Array<string>} [opts.hermeticDirs]
48
- * @param {string} opts.promptText - fully-built prompt (runtime.buildPrompt output)
48
+ * @param {object} [opts.processEnv] - worker-process environment
49
+ * @param {object} [opts.sessionContext] - trusted values scoped to this lease
50
+ * @param {string} [opts.systemPromptText] - trusted system instructions
51
+ * @param {string} opts.promptText - task/user prompt only
49
52
  */
50
- constructor({ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
53
+ constructor({
54
+ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
55
+ processEnv, sessionContext, systemPromptText, promptText,
56
+ }) {
51
57
  super();
52
58
  this.pid = null;
53
59
  this.killed = false;
@@ -63,14 +69,21 @@ class PooledAgentProcess extends EventEmitter {
63
69
  // Fire-and-forget, mirroring child_process.spawn()'s async nature — the
64
70
  // facade is usable (event listeners can be attached) immediately, before
65
71
  // the underlying ACP worker has actually been acquired.
66
- this._start({ cwd, model, effort, mcpServers, hermeticDirs, promptText });
72
+ this._start({
73
+ cwd, model, effort, mcpServers, hermeticDirs,
74
+ processEnv, sessionContext, systemPromptText, promptText,
75
+ });
67
76
  }
68
77
 
69
- async _start({ cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
78
+ async _start({
79
+ cwd, model, effort, mcpServers, hermeticDirs,
80
+ processEnv, sessionContext, systemPromptText, promptText,
81
+ }) {
70
82
  let handle;
71
83
  try {
72
84
  handle = await this._pool.acquireWorker({
73
85
  dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
86
+ processEnv, sessionContext,
74
87
  });
75
88
  } catch (err) {
76
89
  // Mirrors child_process's 'error' event (spawn failed) — engine.js's
@@ -95,28 +108,35 @@ class PooledAgentProcess extends EventEmitter {
95
108
  this.emit('acquired');
96
109
 
97
110
  let sawError = false;
98
- await handle.stream(promptText, {
99
- onChunk: (text) => {
100
- if (!text) return;
101
- this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
102
- },
103
- onDone: (result) => {
104
- this._writeStdoutEvent({
105
- type: 'result',
106
- sessionId: handle.sessionId || null,
107
- usage: {
108
- totalApiDurationMs: Date.now() - this._startedAt,
109
- stopReason: (result && result.stopReason) || null,
110
- },
111
- });
112
- },
113
- onError: (err) => {
114
- sawError = true;
115
- try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
116
- },
117
- });
118
-
119
- try { handle.release(); } catch { /* best-effort */ }
111
+ try {
112
+ await handle.stream(promptText, {
113
+ systemPromptText,
114
+ onChunk: (text) => {
115
+ if (!text) return;
116
+ this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
117
+ },
118
+ onDone: (result) => {
119
+ this._writeStdoutEvent({
120
+ type: 'result',
121
+ sessionId: handle.sessionId || null,
122
+ usage: {
123
+ totalApiDurationMs: Date.now() - this._startedAt,
124
+ stopReason: (result && result.stopReason) || null,
125
+ },
126
+ });
127
+ },
128
+ onError: (err) => {
129
+ sawError = true;
130
+ try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
131
+ },
132
+ });
133
+ } catch (err) {
134
+ sawError = true;
135
+ try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
136
+ } finally {
137
+ try { handle.release(); } catch { /* best-effort */ }
138
+ this._handle = null;
139
+ }
120
140
  this._finish(sawError ? 1 : 0);
121
141
  }
122
142
 
package/engine.js CHANGED
@@ -4693,13 +4693,24 @@ async function spawnAgent(dispatchItem, config) {
4693
4693
  });
4694
4694
  } else {
4695
4695
  // P-1d8f0b93 — pooled copilot dispatch. No separate spawn-agent.js
4696
- // child process exists, so build the SAME combined prompt text that
4697
- // process would have received via runtime.buildPrompt(...) directly
4698
- // here, and hand it to a PooledAgentProcess facade instead of
4699
- // cold-spawning. Everything downstream (stdout/stderr handlers,
4696
+ // child process exists, so hand the task and trusted system context to
4697
+ // the PooledAgentProcess separately instead of cold-spawning. Everything
4698
+ // downstream (stdout/stderr handlers,
4700
4699
  // onAgentClose, live-output.log, PID-file writing below) treats `proc`
4701
4700
  // identically to a real child_process either way.
4702
- const pooledPromptText = runtime.buildPrompt(fullTaskPrompt, systemPrompt, { sessionId: cachedSessionId });
4701
+ const pooledSessionKeys = [
4702
+ 'MINIONS_COMPLETION_REPORT',
4703
+ 'MINIONS_COMPLETION_NONCE',
4704
+ 'MINIONS_LIVE_OUTPUT_PATH',
4705
+ 'MINIONS_STEERING_ACK_DIR',
4706
+ 'MINIONS_AGENT_CWD',
4707
+ ];
4708
+ const pooledProcessEnv = { ...childEnv };
4709
+ const pooledSessionContext = {};
4710
+ for (const key of pooledSessionKeys) {
4711
+ if (pooledProcessEnv[key] !== undefined) pooledSessionContext[key] = pooledProcessEnv[key];
4712
+ delete pooledProcessEnv[key];
4713
+ }
4703
4714
  proc = new PooledAgentProcess({
4704
4715
  pool: agentWorkerPool,
4705
4716
  dispatchId: id,
@@ -4710,7 +4721,10 @@ async function spawnAgent(dispatchItem, config) {
4710
4721
  // flat top-level config array, not per-agent-resolved.
4711
4722
  mcpServers: (engineConfig && engineConfig.mcpServers) || [],
4712
4723
  hermeticDirs: addDirs,
4713
- promptText: pooledPromptText,
4724
+ processEnv: pooledProcessEnv,
4725
+ sessionContext: pooledSessionContext,
4726
+ systemPromptText: systemPrompt,
4727
+ promptText: fullTaskPrompt,
4714
4728
  });
4715
4729
  // The child_process PID file is normally written asynchronously by
4716
4730
  // spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2378",
3
+ "version": "0.1.2379",
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"