@yemi33/minions 0.1.2124 → 0.1.2125

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/dashboard.js CHANGED
@@ -11477,7 +11477,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11477
11477
  invalidateStatusCache();
11478
11478
  return jsonReply(res, 200, { ok: true });
11479
11479
  }},
11480
- { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message', handler: async (req, res) => {
11480
+ { method: 'POST', path: '/api/agents/steer', desc: 'Inject steering message into a running agent', params: 'agent, message, supersede? (all|unacked|<steerId>), scope? (agent|current-dispatch)', handler: async (req, res) => {
11481
11481
  const body = await readBody(req);
11482
11482
  const { agent, message } = body;
11483
11483
  if (!agent || !message) return jsonReply(res, 400, { error: 'agent and message required' });
@@ -11491,7 +11491,54 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11491
11491
  return jsonReply(res, 409, { error: 'Agent session is finishing; retry when the next session starts' });
11492
11492
  }
11493
11493
 
11494
- const entry = steering.writeSteeringMessage(agentId, text);
11494
+ // W-mq066js7000fff1f-e (Gap E) — supersede prior messages before
11495
+ // writing the new one. Accept 'all', 'unacked', or a specific
11496
+ // steerId. Bad shapes are silently ignored (best-effort).
11497
+ const supersede = body.supersede ? String(body.supersede).trim() : '';
11498
+ let superseded = [];
11499
+ if (supersede) {
11500
+ const provisionalSteerId = `steer-pending-${Date.now()}`;
11501
+ superseded = steering.supersedeMessages(agentId, supersede, { reason: `superseded by new steer (${supersede})`, newSteerId: provisionalSteerId });
11502
+ }
11503
+
11504
+ // W-mq066js7000fff1f-e — dedupe: if no supersede arg AND an
11505
+ // identical body is already pending within the 5-min window, echo
11506
+ // back the existing steerId instead of writing a duplicate file.
11507
+ if (!supersede) {
11508
+ const dup = steering.findRecentDuplicate(agentId, text);
11509
+ if (dup) {
11510
+ const delivery = _steeringDeliveryState(agentId);
11511
+ return jsonReply(res, 200, {
11512
+ ok: true,
11513
+ deduplicated: true,
11514
+ steerId: dup.steerId,
11515
+ status: dup.status || steering.STATUS.QUEUED,
11516
+ file: dup.file,
11517
+ // Gap D observability URL — points at the SQL delivery-state row
11518
+ // for /api/steering/:id (back-compat with master's contract).
11519
+ deliveryUrl: dup.steerId ? `/api/steering/${dup.steerId}` : null,
11520
+ message: 'Identical steering message already pending — returning existing entry',
11521
+ ...delivery,
11522
+ inboxCount: steering.listUnreadSteeringMessages(agentId).length,
11523
+ });
11524
+ }
11525
+ }
11526
+
11527
+ // W-mq066js7000fff1f-f (Gap F) — per-dispatch scoping. With
11528
+ // scope='current-dispatch' we stamp the active dispatch id so the
11529
+ // engine filters this entry out of any future unrelated dispatch's
11530
+ // resume prompt. With scope='agent' (default) the message is
11531
+ // agent-wide and picks up on the next dispatch regardless of id.
11532
+ const scope = body.scope ? String(body.scope).trim().toLowerCase() : 'agent';
11533
+ let targetDispatchId = null;
11534
+ let scopeApplied = scope;
11535
+ if (scope === 'current-dispatch') {
11536
+ const active = (getDispatchQueue().active || []).find(d => d.agent === agentId);
11537
+ if (active?.id) targetDispatchId = String(active.id);
11538
+ else scopeApplied = 'agent'; // no active dispatch → fall back
11539
+ }
11540
+
11541
+ const entry = steering.writeSteeringMessage(agentId, text, { targetDispatchId });
11495
11542
  const delivery = _steeringDeliveryState(agentId);
11496
11543
 
11497
11544
  // Also append to live-output.log so it shows in the chat view
@@ -11506,12 +11553,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11506
11553
  const steerId = entry?.steerId || null;
11507
11554
  return jsonReply(res, 200, {
11508
11555
  ok: true,
11556
+ deduplicated: false,
11557
+ steerId,
11558
+ status: entry?.status || (steerId ? steering.STATUS.QUEUED : null),
11559
+ targetDispatchId: entry?.targetDispatchId || null,
11560
+ scope: scopeApplied,
11561
+ superseded: superseded.map(s => ({ steerId: s.steerId, previousStatus: s.previousStatus })),
11509
11562
  message: delivery.pendingDelivery ? 'Steering message pending delivery' : 'Steering message queued',
11510
11563
  ...delivery,
11511
11564
  file: entry?.file || null,
11512
11565
  inboxCount: steering.listUnreadSteeringMessages(agentId).length,
11513
- steerId,
11514
- status: steerId ? 'queued' : null,
11515
11566
  deliveryUrl: steerId ? `/api/steering/${steerId}` : null,
11516
11567
  });
11517
11568
  }},
@@ -19,6 +19,43 @@ function _generateSteerId() {
19
19
  return `steer-${crypto.randomBytes(8).toString('hex').slice(0, 10)}`;
20
20
  }
21
21
 
22
+ // W-mq066js7000fff1f-e (Gap E): identical-text dedupe window. POST
23
+ // /api/agents/steer collapses identical bodies within this window to
24
+ // the existing steerId instead of writing a duplicate inbox file.
25
+ const DEDUPE_WINDOW_MS = 5 * 60 * 1000;
26
+
27
+ // W-mq066js7000fff1f-e/f: status enum for the inbox frontmatter +
28
+ // supersede operations. Mirrors the steering_deliveries observability
29
+ // table (Gap D) so callers can speak the same vocabulary regardless
30
+ // of which storage backend is live.
31
+ const STATUS = Object.freeze({
32
+ QUEUED: 'queued',
33
+ LIVE_KILL: 'live_kill',
34
+ DEFERRED: 'deferred',
35
+ RE_SPAWNING: 're_spawning',
36
+ DELIVERED: 'delivered',
37
+ ACKNOWLEDGED: 'acknowledged',
38
+ STRANDED: 'stranded',
39
+ DROPPED: 'dropped',
40
+ });
41
+
42
+ const UNACKED_STATUSES = new Set([
43
+ STATUS.QUEUED,
44
+ STATUS.LIVE_KILL,
45
+ STATUS.DEFERRED,
46
+ STATUS.RE_SPAWNING,
47
+ STATUS.STRANDED,
48
+ ]);
49
+
50
+ // Statuses that should still appear as "live" in the dedupe lookup.
51
+ const DEDUPE_CANDIDATE_STATUSES = new Set([
52
+ STATUS.QUEUED,
53
+ STATUS.LIVE_KILL,
54
+ STATUS.DEFERRED,
55
+ STATUS.RE_SPAWNING,
56
+ STATUS.DELIVERED,
57
+ ]);
58
+
22
59
  function agentInboxDir(agentId) {
23
60
  return path.join(AGENTS_DIR, agentId, 'inbox');
24
61
  }
@@ -73,15 +110,22 @@ function _readEntry(filePath, legacy = false) {
73
110
  ? fmCreatedAtMs
74
111
  : _createdAtFromPath(filePath, stat);
75
112
  const steerId = _frontmatterValue(raw, 'steerId') || null;
113
+ const status = _frontmatterValue(raw, 'status') || STATUS.QUEUED;
114
+ const targetDispatchId = _frontmatterValue(raw, 'targetDispatchId') || null;
115
+ const lastError = _frontmatterValue(raw, 'lastError') || null;
116
+ const source = _frontmatterValue(raw, 'source') || 'human';
76
117
  return {
77
118
  path: filePath,
78
119
  file: path.basename(filePath),
79
120
  createdAtMs,
80
121
  createdAt: new Date(createdAtMs).toISOString(),
81
- steerId,
82
122
  raw,
83
123
  message: _messageFromRaw(raw),
84
124
  steerId,
125
+ status,
126
+ targetDispatchId,
127
+ lastError,
128
+ source,
85
129
  legacy,
86
130
  };
87
131
  }
@@ -94,10 +138,6 @@ function _uniqueSteeringPath(inboxDir, createdAtMs) {
94
138
  return filePath;
95
139
  }
96
140
 
97
- function _generateSteerId() {
98
- return crypto.randomBytes(6).toString('hex');
99
- }
100
-
101
141
  // Contract block describing the ACK-file protocol. Injected into the prompt
102
142
  // alongside any pending steering messages so the agent knows how to confirm
103
143
  // it has read+addressed a labeled message. Mirrored verbatim into
@@ -111,9 +151,24 @@ function ackContractBlock() {
111
151
  ].join('\n');
112
152
  }
113
153
 
154
+ function _renderFrontmatter(data) {
155
+ const createdAtMs = Number(data.createdAtMs) || Date.now();
156
+ const lines = [
157
+ '---',
158
+ `createdAt: ${new Date(createdAtMs).toISOString()}`,
159
+ `createdAtMs: ${createdAtMs}`,
160
+ `source: ${data.source || 'human'}`,
161
+ `steerId: ${data.steerId}`,
162
+ `status: ${data.status || STATUS.QUEUED}`,
163
+ ];
164
+ if (data.targetDispatchId) lines.push(`targetDispatchId: ${data.targetDispatchId}`);
165
+ if (data.lastError) lines.push(`lastError: ${String(data.lastError).replace(/[\r\n]+/g, ' ').trim()}`);
166
+ lines.push('---', '', String(data.message || '').trim(), '');
167
+ return lines.join('\n');
168
+ }
169
+
114
170
  function writeSteeringMessage(agentId, message, opts = {}) {
115
171
  const createdAtMs = Number(opts.createdAtMs) || Date.now();
116
- const createdAt = new Date(createdAtMs).toISOString();
117
172
  const inboxDir = agentInboxDir(agentId);
118
173
  fs.mkdirSync(inboxDir, { recursive: true });
119
174
  const filePath = _uniqueSteeringPath(inboxDir, createdAtMs);
@@ -134,17 +189,15 @@ function writeSteeringMessage(agentId, message, opts = {}) {
134
189
  steerId,
135
190
  }));
136
191
  }
137
- const body = [
138
- '---',
139
- `createdAt: ${createdAt}`,
140
- `createdAtMs: ${createdAtMs}`,
141
- `source: ${source}`,
142
- `steerId: ${steerId}`,
143
- '---',
144
- '',
145
- bodyText,
146
- '',
147
- ].join('\n');
192
+ const body = _renderFrontmatter({
193
+ createdAtMs,
194
+ source,
195
+ steerId,
196
+ status: opts.status || STATUS.QUEUED,
197
+ targetDispatchId: opts.targetDispatchId || null,
198
+ lastError: opts.lastError || null,
199
+ message: bodyText,
200
+ });
148
201
  shared.safeWrite(filePath, body);
149
202
 
150
203
  // W-mq066js7000fff1f-a (Gap D): insert a 'queued' row into the
@@ -169,7 +222,22 @@ function writeSteeringMessage(agentId, message, opts = {}) {
169
222
  return _readEntry(filePath);
170
223
  }
171
224
 
172
- function listUnreadSteeringMessages(agentId, opts = {}) {
225
+ function _updateEntryStatus(entry, newStatus, opts = {}) {
226
+ if (!entry?.path) return null;
227
+ const body = _renderFrontmatter({
228
+ createdAtMs: entry.createdAtMs,
229
+ source: entry.source || 'human',
230
+ steerId: entry.steerId,
231
+ status: newStatus,
232
+ targetDispatchId: entry.targetDispatchId,
233
+ lastError: opts.lastError !== undefined ? opts.lastError : entry.lastError,
234
+ message: entry.message,
235
+ });
236
+ shared.safeWrite(entry.path, body);
237
+ return _readEntry(entry.path);
238
+ }
239
+
240
+ function listAllSteeringEntries(agentId, opts = {}) {
173
241
  const includeLegacy = opts.includeLegacy !== false;
174
242
  const entries = [];
175
243
  const inboxDir = agentInboxDir(agentId);
@@ -189,8 +257,40 @@ function listUnreadSteeringMessages(agentId, opts = {}) {
189
257
  return entries;
190
258
  }
191
259
 
192
- function buildPendingSteeringPrompt(agentId) {
193
- const entries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
260
+ function listUnreadSteeringMessages(agentId, opts = {}) {
261
+ // Back-compat shape: "unread" = anything still pending agent attention
262
+ // (queued/live_kill/deferred/re_spawning/delivered/stranded). Explicit
263
+ // 'dropped' or 'acknowledged' rows are filtered so supersede/ack don't
264
+ // resurrect carry-over messages on the next dispatch.
265
+ return listAllSteeringEntries(agentId, opts).filter(entry => {
266
+ const status = entry.status || STATUS.QUEUED;
267
+ return status !== STATUS.DROPPED && status !== STATUS.ACKNOWLEDGED;
268
+ });
269
+ }
270
+
271
+ // W-mq066js7000fff1f-f (Gap F): per-dispatch scoping. Callers that know
272
+ // which dispatch they're about to spawn can pass {currentDispatchId} so
273
+ // messages tagged for a different (older) dispatch are filtered out.
274
+ // Pre-spawn messages (targetDispatchId null) always pass through.
275
+ // Without currentDispatchId, no filtering happens — back-compat for
276
+ // callers that haven't been updated to pass the dispatch id yet.
277
+ function buildPendingSteeringPrompt(agentId, opts = {}) {
278
+ const includePrior = opts.includePrior === true;
279
+ const currentDispatchId = opts.currentDispatchId || null;
280
+ const allEntries = listUnreadSteeringMessages(agentId).filter(entry => entry.message.trim());
281
+ const entries = includePrior
282
+ ? allEntries
283
+ : allEntries.filter(entry => {
284
+ // Pre-spawn / agent-scoped messages (null targetDispatchId) always
285
+ // pick up on the next dispatch — the agent never had a chance to
286
+ // hear them yet.
287
+ if (!entry.targetDispatchId) return true;
288
+ // No dispatch context → don't try to filter (back-compat).
289
+ if (!currentDispatchId) return true;
290
+ // Per-dispatch messages only belong to their tagged dispatch.
291
+ return entry.targetDispatchId === currentDispatchId;
292
+ });
293
+
194
294
  if (entries.length === 0) return { entries, prompt: '' };
195
295
 
196
296
  const sections = [
@@ -206,6 +306,69 @@ function buildPendingSteeringPrompt(agentId) {
206
306
  return { entries, prompt: sections.join('\n') };
207
307
  }
208
308
 
309
+ // W-mq066js7000fff1f-e (Gap E): dedupe lookup for POST /api/agents/steer.
310
+ // Returns the existing entry whose normalized body matches `message` and
311
+ // was created within `opts.windowMs` (default 5 min); null otherwise.
312
+ // Only entries in "live" delivery states qualify — dropped/acknowledged
313
+ // rows are ignored so a previously-superseded duplicate body is allowed
314
+ // to be re-sent.
315
+ function findRecentDuplicate(agentId, message, opts = {}) {
316
+ const trimmed = String(message || '').trim();
317
+ if (!trimmed) return null;
318
+ const windowMs = Number(opts.windowMs) > 0 ? Number(opts.windowMs) : DEDUPE_WINDOW_MS;
319
+ const now = Number(opts.now) > 0 ? Number(opts.now) : Date.now();
320
+ const entries = listAllSteeringEntries(agentId);
321
+ // Newest first — UI usually steers in a tight loop, the most recent
322
+ // identical message is the one we want to deduplicate against.
323
+ entries.sort((a, b) => b.createdAtMs - a.createdAtMs);
324
+ for (const entry of entries) {
325
+ const status = entry.status || STATUS.QUEUED;
326
+ if (!DEDUPE_CANDIDATE_STATUSES.has(status)) continue;
327
+ if (now - entry.createdAtMs > windowMs) continue;
328
+ if (entry.message.trim() !== trimmed) continue;
329
+ return entry;
330
+ }
331
+ return null;
332
+ }
333
+
334
+ // W-mq066js7000fff1f-e (Gap E): supersede prior steering messages.
335
+ // mode:
336
+ // 'all' — drop every entry whose status is neither dropped nor
337
+ // acknowledged (queued/live_kill/deferred/re_spawning/
338
+ // delivered/stranded).
339
+ // 'unacked' — drop queued/live_kill/deferred/re_spawning/stranded but
340
+ // leave 'delivered' alone (agent already saw it).
341
+ // <steerId> — drop the single entry with that steerId.
342
+ // Returns the list of dropped {steerId, file, path, previousStatus}.
343
+ function supersedeMessages(agentId, mode, opts = {}) {
344
+ if (!mode) return [];
345
+ const newSteerId = opts.newSteerId || null;
346
+ const reason = opts.reason || (newSteerId ? `superseded by ${newSteerId}` : 'superseded');
347
+ const entries = listAllSteeringEntries(agentId);
348
+ const dropped = [];
349
+ for (const entry of entries) {
350
+ const status = entry.status || STATUS.QUEUED;
351
+ let target = false;
352
+ if (mode === 'all') {
353
+ if (status !== STATUS.ACKNOWLEDGED && status !== STATUS.DROPPED) target = true;
354
+ } else if (mode === 'unacked') {
355
+ if (UNACKED_STATUSES.has(status)) target = true;
356
+ } else {
357
+ // Specific steerId
358
+ if (entry.steerId && entry.steerId === mode) target = true;
359
+ }
360
+ if (!target) continue;
361
+ _updateEntryStatus(entry, STATUS.DROPPED, { lastError: reason });
362
+ dropped.push({
363
+ steerId: entry.steerId,
364
+ file: entry.file,
365
+ path: entry.path,
366
+ previousStatus: status,
367
+ });
368
+ }
369
+ return dropped;
370
+ }
371
+
209
372
  function _eventTimestampMs(obj, observedAtMs) {
210
373
  const value = obj?.timestamp || obj?.createdAt || obj?.created_at || obj?.time || obj?.data?.timestamp;
211
374
  const parsed = value ? Date.parse(value) : NaN;
@@ -342,9 +505,17 @@ module.exports = {
342
505
  ackContractBlock,
343
506
  writeSteeringMessage,
344
507
  listUnreadSteeringMessages,
508
+ listAllSteeringEntries,
345
509
  buildPendingSteeringPrompt,
510
+ findRecentDuplicate,
511
+ supersedeMessages,
346
512
  sessionIdFromEvent,
347
513
  sessionIdFromOutputLine,
348
514
  ackProcessedSteeringMessages,
349
515
  ackSteeringFromAckDir,
516
+ STATUS,
517
+ DEDUPE_WINDOW_MS,
518
+ // Exposed for unit tests only.
519
+ _updateEntryStatus,
520
+ _generateSteerId,
350
521
  };
package/engine.js CHANGED
@@ -1326,7 +1326,7 @@ async function spawnAgent(dispatchItem, config) {
1326
1326
  // work-item prompts after setup because reused worktrees can live at arbitrary paths.
1327
1327
  const systemPrompt = buildSystemPrompt(agentId, config, project);
1328
1328
  const agentContext = buildAgentContext(agentId, config, project);
1329
- const pendingSteering = steering.buildPendingSteeringPrompt(agentId);
1329
+ const pendingSteering = steering.buildPendingSteeringPrompt(agentId, { currentDispatchId: id });
1330
1330
  const completionReportPath = shared.dispatchCompletionReportPath(id);
1331
1331
  if (completionReportPath) {
1332
1332
  try {
@@ -2793,7 +2793,7 @@ async function spawnAgent(dispatchItem, config) {
2793
2793
  // Write new prompt with all unACKed steering messages. This keeps delivery
2794
2794
  // durable if the killed process had older pending messages that never
2795
2795
  // produced processing evidence before the resume.
2796
- const pendingForResume = steering.buildPendingSteeringPrompt(agentId);
2796
+ const pendingForResume = steering.buildPendingSteeringPrompt(agentId, { currentDispatchId: id });
2797
2797
  const steerPromptBody = pendingForResume.prompt || steerMsg;
2798
2798
  const steerPrompt = `Message from your human teammate:\n\n${steerPromptBody}\n\nRespond to this, then continue working on your current task.`;
2799
2799
  const steerPromptPath = path.join(dispatchTmpDir, `prompt-steer-${safeId}.md`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2124",
3
+ "version": "0.1.2125",
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"