@yemi33/minions 0.1.2377 → 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.
@@ -130,7 +130,8 @@ function _detectPageChanges(data) {
130
130
  // for the same input. Cross-restart safety lives in the dashboardBuildId
131
131
  // reload path below — RENDER_VERSIONS handles the within-process case.
132
132
  const RENDER_VERSIONS = {
133
- agents: 2,
133
+ // Bumped 2→3 for client-ticked relative last-action timestamps.
134
+ agents: 3,
134
135
  prdProgress: 2,
135
136
  prdPrs: 1,
136
137
  inbox: 2,
@@ -516,17 +517,19 @@ function _processStatusUpdate(data, opts) {
516
517
  // the /api/work-items + /api/pull-requests .then handlers
517
518
  // (_fireCrossSliceRender) where the FRESH fetched lists are available.
518
519
  // Agents now come from /api/agents — a dedicated fresh-JSON endpoint with
519
- // input-mtime ETag (issue #2949). No /api/status outer cache in the path.
520
+ // input-mtime ETag (issue #2949). Conditional requests keep unchanged polls
521
+ // from reparsing the roster and rebuilding every card/timer.
520
522
  // cmdUpdateAgentList consumes the same slice but renders the command-line
521
523
  // chip pop-up so we feed it the fresh array too.
522
524
  _safeRender('agents', function() {
523
525
  const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
524
526
  window._lastRequestedSeq = window._lastRequestedSeq || {};
525
527
  window._lastRequestedSeq.agents = seq;
526
- fetch('/api/agents')
527
- .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
528
- .then(function (fresh) {
528
+ _condFetchJson('/api/agents')
529
+ .then(function (resp) {
529
530
  if (seq < (window._lastRequestedSeq.agents || 0)) return;
531
+ if (resp.notModified) return;
532
+ const fresh = resp.data;
530
533
  const list = Array.isArray(fresh) ? fresh : window._lastAgents;
531
534
  if (list) window._lastAgents = list;
532
535
  _safeRender('agents', function() { renderAgents(list); });
@@ -44,6 +44,34 @@ function _modelChipHtml(model) {
44
44
  return '<span class="agent-model-tag" title="Model: ' + escapeHtml(model) + '" style="font-size:var(--text-xs);font-weight:600;letter-spacing:0.4px;padding:1px 5px;margin-left:4px;border:1px solid var(--muted);border-radius:3px;color:var(--muted);background:transparent">' + escapeHtml(model) + '</span>';
45
45
  }
46
46
 
47
+ function _agentHasTimedAction(agent) {
48
+ return !!(agent && agent.lastActionPrefix && Number.isFinite(Number(agent.lastActionAt)));
49
+ }
50
+
51
+ function _formatAgentAction(prefix, actionAt, fallback) {
52
+ var actionAtMs = Number(actionAt);
53
+ var text = prefix && Number.isFinite(actionAtMs)
54
+ ? String(prefix) + ' (' + timeAgo(actionAtMs) + ')'
55
+ : String(fallback || '');
56
+ return text.length > 120 ? text.slice(0, 120) + '...' : text;
57
+ }
58
+
59
+ function _agentLastActionText(agent) {
60
+ return _formatAgentAction(agent.lastActionPrefix, agent.lastActionAt, agent.lastAction);
61
+ }
62
+
63
+ function _agentTimedActionAttrs(agent) {
64
+ return _agentHasTimedAction(agent)
65
+ ? ' data-action-prefix="' + escapeHtml(agent.lastActionPrefix) + '" data-action-at="' + Number(agent.lastActionAt) + '"'
66
+ : '';
67
+ }
68
+
69
+ function _agentActionHtml(agent) {
70
+ var text = _agentLastActionText(agent);
71
+ var attrs = _agentTimedActionAttrs(agent);
72
+ return '<div class="agent-action"' + attrs + ' title="' + escapeHtml(text) + '">' + escapeHtml(text) + '</div>';
73
+ }
74
+
47
75
  function renderAgents(agents) {
48
76
  agentData = agents;
49
77
  const grid = document.getElementById('agents-grid');
@@ -56,7 +84,7 @@ function renderAgents(agents) {
56
84
  <span class="status-badge ${escapeHtml(a.status)}">${escapeHtml(a.status)}</span>
57
85
  </div>
58
86
  <div class="agent-role">${escapeHtml(a.role)}</div>
59
- <div class="agent-action" title="${escapeHtml(a.lastAction)}">${escapeHtml(a.lastAction)}</div>
87
+ ${_agentActionHtml(a)}
60
88
  ${(function() {
61
89
  var s = a.started_at, c = a.completed_at;
62
90
  if (s && c) { var d = new Date(c) - new Date(s); if (d > 0) { var sec = Math.floor(d/1000)%60, min = Math.floor(d/60000)%60, hr = Math.floor(d/3600000); return '<div style="font-size:var(--text-xs);color:var(--muted)">Last run: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's</div>'; } }
@@ -144,7 +172,7 @@ async function openAgentDetail(id) {
144
172
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
145
173
  document.getElementById('detail-status-line').innerHTML =
146
174
  '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
147
- '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
175
+ '<span class="agent-detail-action"' + _agentTimedActionAttrs(agent) + ' style="color:var(--muted)">' + escapeHtml(_agentLastActionText(agent)) + '</span>' +
148
176
  (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:var(--text-base);color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
149
177
  (agent.resultSummary ? '<div style="margin-top:4px;font-size:var(--text-base);color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
150
178
 
@@ -168,11 +196,19 @@ async function openAgentDetail(id) {
168
196
 
169
197
  }
170
198
 
171
- // Tick running agent timers on cards every second
199
+ // Tick wall-clock-derived card text every second.
172
200
  var _agentRuntimeTimer = null;
173
201
  function _tickAgentRuntimes() {
174
- var els = document.querySelectorAll('.agent-runtime-tick');
175
- if (els.length === 0) { if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; } return; }
202
+ var grid = document.getElementById('agents-grid');
203
+ var els = grid ? grid.querySelectorAll('.agent-runtime-tick') : [];
204
+ var actionEls = grid ? Array.from(grid.querySelectorAll('.agent-action[data-action-at]')) : [];
205
+ var detailStatus = document.getElementById('detail-status-line');
206
+ var detailAction = detailStatus && detailStatus.querySelector('.agent-detail-action[data-action-at]');
207
+ if (detailAction) actionEls.push(detailAction);
208
+ if (els.length === 0 && actionEls.length === 0) {
209
+ if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
210
+ return;
211
+ }
176
212
  var now = Date.now();
177
213
  els.forEach(function(el) {
178
214
  var ms = now - new Date(el.dataset.started).getTime();
@@ -180,13 +216,20 @@ function _tickAgentRuntimes() {
180
216
  var sec = Math.floor(ms / 1000) % 60, min = Math.floor(ms / 60000) % 60, hr = Math.floor(ms / 3600000);
181
217
  el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
182
218
  });
219
+ actionEls.forEach(function(el) {
220
+ var text = _formatAgentAction(el.dataset.actionPrefix, Number(el.dataset.actionAt), el.textContent);
221
+ el.textContent = text;
222
+ el.title = text;
223
+ });
183
224
  }
184
- // Start ticker after each render if working agents exist
225
+ // Start ticker after each render if any card has wall-clock-derived text.
185
226
  var _origRenderAgents = renderAgents;
186
227
  renderAgents = function(agents) {
187
228
  _origRenderAgents(agents);
188
229
  if (_agentRuntimeTimer) { clearInterval(_agentRuntimeTimer); _agentRuntimeTimer = null; }
189
- if (agents.some(function(a) { return a.status === 'working' && a.started_at; })) {
230
+ if (agents.some(function(a) {
231
+ return (a.status === 'working' && a.started_at) || _agentHasTimedAction(a);
232
+ })) {
190
233
  _tickAgentRuntimes();
191
234
  _agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
192
235
  }
package/dashboard.js CHANGED
@@ -2996,68 +2996,56 @@ function _stateReadContentType(ext) {
2996
2996
  // issue #2949 documented. This pattern bypasses both:
2997
2997
  //
2998
2998
  // 1. Caller supplies an array of input file paths.
2999
- // 2. We statSync each one and take MAX(mtimeMs). That's the ETag.
2999
+ // 2. We asynchronously stat each one and take MAX(mtimeMs). That's the ETag.
3000
3000
  // 3. If the client's If-None-Match matches → 304 with no body.
3001
3001
  // 4. Otherwise call `builder()` to assemble the response from scratch
3002
3002
  // (no outer cache layer to go stale).
3003
3003
  // 5. Stamp ETag + Content-Type and send.
3004
3004
  //
3005
- // Cost per request when ETag matches: N statSync calls (microseconds).
3006
- // Cost on a miss: same N statSync + the builder, which is exactly the
3005
+ // Cost per request when ETag matches: N asynchronous stat calls.
3006
+ // Cost on a miss: the same stat walk + the builder, which is exactly the
3007
3007
  // existing queries.getAgents() / getMetrics() / etc. that /api/status
3008
3008
  // already pays. Net change: same compute, no caching, always-fresh.
3009
- function _maxInputMtimeMs(inputs) {
3010
- let max = 0;
3011
- for (const fp of inputs) {
3012
- try {
3013
- // Use lstatSync so a broken Windows junction (common after DevBox
3014
- // swap) or a circular symlink doesn't block the event loop on a
3015
- // ~30 s OS timeout trying to follow the link. Engine never writes
3016
- // symlinks into state dirs in normal operation, so an lstat that
3017
- // detects a symlink is treated as "skip" — the input is effectively
3018
- // not tracked. (Round-2 review finding #7.)
3019
- const s = fs.lstatSync(fp);
3020
- if (s.isSymbolicLink()) continue;
3021
- if (s.mtimeMs > max) max = s.mtimeMs;
3022
- // Directory inputs: dir mtime only advances on add/remove of
3023
- // immediate entries, not on in-place file edits. Walk two levels
3024
- // down so editing prd/<plan>.json in place (depth 1) and
3025
- // agents/<id>/live-output.log (depth 2) both bust the ETag.
3026
- // Steering edits at agents/<id>/inbox/steering-*.md (depth 3)
3027
- // currently rely on add/remove cadence rather than per-file content
3028
- // mtime — accept this lag rather than walking unbounded.
3029
- // (Review finding #6.)
3030
- if (s.isDirectory()) {
3031
- let level1;
3032
- try { level1 = fs.readdirSync(fp); } catch { level1 = []; }
3033
- for (const name of level1) {
3034
- const child = path.join(fp, name);
3035
- let cs;
3036
- try { cs = fs.lstatSync(child); } catch { continue; }
3037
- if (cs.isSymbolicLink()) continue;
3038
- if (cs.mtimeMs > max) max = cs.mtimeMs;
3039
- if (cs.isDirectory()) {
3040
- let level2;
3041
- try { level2 = fs.readdirSync(child); } catch { level2 = []; }
3042
- for (const inner of level2) {
3043
- try {
3044
- const is = fs.lstatSync(path.join(child, inner));
3045
- if (is.isSymbolicLink()) continue;
3046
- if (is.mtimeMs > max) max = is.mtimeMs;
3047
- } catch { /* skip unreadable */ }
3048
- }
3049
- }
3050
- }
3051
- }
3052
- } catch {
3053
- // Missing input = not yet on disk. Don't bust the ETag — a builder
3054
- // that legitimately depends on the file will surface its own empty
3055
- // state, and the ETag will move once the file appears.
3056
- }
3009
+ async function _freshnessEntryMtimeMs(fp, depth) {
3010
+ let stat;
3011
+ try {
3012
+ // lstat avoids following broken Windows junctions or circular symlinks,
3013
+ // which can incur long OS timeouts. State inputs never require symlinks.
3014
+ stat = await fs.promises.lstat(fp);
3015
+ } catch {
3016
+ return 0;
3057
3017
  }
3058
- return Math.floor(max);
3018
+ if (stat.isSymbolicLink()) return 0;
3019
+
3020
+ let max = stat.mtimeMs;
3021
+ if (!stat.isDirectory() || depth <= 0) return max;
3022
+
3023
+ let names;
3024
+ try {
3025
+ names = await fs.promises.readdir(fp);
3026
+ } catch {
3027
+ return max;
3028
+ }
3029
+ const childMtimes = await Promise.all(
3030
+ names.map(name => _freshnessEntryMtimeMs(path.join(fp, name), depth - 1)),
3031
+ );
3032
+ for (const childMtime of childMtimes) {
3033
+ if (childMtime > max) max = childMtime;
3034
+ }
3035
+ return max;
3036
+ }
3037
+
3038
+ async function _maxInputMtimeMs(inputs) {
3039
+ // Directory mtime only advances on add/remove, not in-place edits. Walk two
3040
+ // levels so prd/<plan>.json and agents/<id>/live-output.log both bust ETags.
3041
+ // Deeper steering edits still rely on add/remove cadence, matching the prior
3042
+ // bounded walk.
3043
+ const mtimes = await Promise.all(
3044
+ inputs.map(fp => _freshnessEntryMtimeMs(fp, 2)),
3045
+ );
3046
+ return Math.floor(mtimes.reduce((max, mtime) => Math.max(max, mtime), 0));
3059
3047
  }
3060
- function serveFreshJson(req, res, opts) {
3048
+ async function serveFreshJson(req, res, opts) {
3061
3049
  const inputs = Array.isArray(opts && opts.inputs) ? opts.inputs : [];
3062
3050
  const builder = opts && typeof opts.builder === 'function' ? opts.builder : null;
3063
3051
  const tag = opts && opts.tag ? String(opts.tag) : 'v';
@@ -3067,7 +3055,7 @@ function serveFreshJson(req, res, opts) {
3067
3055
  res.end(JSON.stringify({ error: 'serveFreshJson: builder is required' }));
3068
3056
  return;
3069
3057
  }
3070
- const mtime = _maxInputMtimeMs(inputs);
3058
+ const mtime = await _maxInputMtimeMs(inputs);
3071
3059
  const eventVersion = _getCurrentEventVersion();
3072
3060
  const etag = '"' + tag + '-' + mtime + '-' + eventVersion + '"';
3073
3061
  res.setHeader('ETag', etag);
@@ -7677,7 +7665,7 @@ const server = http.createServer(async (req, res) => {
7677
7665
  h = Math.imul(h ^ ((e.size || 0) >>> 0), 16777619);
7678
7666
  h = Math.imul(h ^ Math.floor(e.sortTs || 0), 16777619);
7679
7667
  }
7680
- const sweepMtime = _maxInputMtimeMs([
7668
+ const sweepMtime = await _maxInputMtimeMs([
7681
7669
  path.join(ENGINE_DIR, 'kb-swept.json'),
7682
7670
  path.join(ENGINE_DIR, 'kb-sweep-state.json'),
7683
7671
  ]);
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/queries.js CHANGED
@@ -758,6 +758,8 @@ function getAgents(config) {
758
758
  const s = getAgentStatus(a.id); // derives from dispatch.json
759
759
 
760
760
  let lastAction = 'Waiting for assignment';
761
+ let lastActionPrefix = null;
762
+ let lastActionAt = null;
761
763
  // Show the dispatch task as a stable label for the whole run — don't swap
762
764
  // in the transient per-tool-call `_runningToolDescription`, which reads like
763
765
  // a task change to operators (e.g. an agent's own "Wait until temp dirs
@@ -768,17 +770,23 @@ function getAgents(config) {
768
770
  else if (s.status === 'error') lastAction = `Error: ${s.task}`;
769
771
  else if (steeringInboxFiles.length > 0) {
770
772
  const lastSteer = steeringInboxFiles[steeringInboxFiles.length - 1];
771
- lastAction = `Pending steering: ${lastSteer.file} (${timeSince(lastSteer.createdAtMs)})`;
773
+ lastActionPrefix = `Pending steering: ${lastSteer.file}`;
774
+ lastActionAt = lastSteer.createdAtMs;
775
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
772
776
  }
773
777
  else if (inboxFiles.length > 0) {
774
778
  const lastOutput = path.join(INBOX_DIR, inboxFiles[inboxFiles.length - 1]);
775
- try { lastAction = `Output: ${path.basename(lastOutput)} (${timeSince(fs.statSync(lastOutput).mtimeMs)})`; } catch { /* optional */ }
779
+ try {
780
+ lastActionPrefix = `Output: ${path.basename(lastOutput)}`;
781
+ lastActionAt = fs.statSync(lastOutput).mtimeMs;
782
+ lastAction = `${lastActionPrefix} (${timeSince(lastActionAt)})`;
783
+ } catch { /* optional */ }
776
784
  }
777
785
 
778
786
  const chartered = fs.existsSync(path.join(AGENTS_DIR, a.id, 'charter.md'));
779
787
  if (lastAction.length > 120) lastAction = lastAction.slice(0, 120) + '...';
780
788
  return {
781
- ...a, runtime, model, status: s.status, lastAction,
789
+ ...a, runtime, model, status: s.status, lastAction, lastActionPrefix, lastActionAt,
782
790
  // Descriptive capability tags; tolerate legacy `skills` configs by
783
791
  // normalizing to `expertise` for the dashboard/settings UI.
784
792
  expertise: a.expertise ?? a.skills ?? [],
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.2377",
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"