@yemi33/minions 0.1.2174 → 0.1.2176

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.
@@ -29,6 +29,12 @@
29
29
  var messages = [];
30
30
  var sending = false;
31
31
  var abortController = null;
32
+ // Per-tab queue of follow-up messages submitted while a turn is streaming
33
+ // (W-mqayw3x6). Keyed by tabId so each chat tab keeps its own queue across
34
+ // tab switches. In-memory only — a hard refresh drops the queue by design.
35
+ var queues = {};
36
+ var queueSuspended = {}; // tabId -> true after an abort/error suspends auto-drain
37
+ var queueEl = null; // bottom-pinned container that holds the queued bubbles
32
38
  var currentProject = null;
33
39
  try { currentProject = localStorage.getItem(SLIM_PROJECT_KEY) || null; } catch (_e) { /* private mode */ }
34
40
 
@@ -241,7 +247,7 @@
241
247
  } else {
242
248
  div.textContent = text;
243
249
  }
244
- msgsEl.appendChild(div);
250
+ _appendMsgEl(div);
245
251
  scrollToBottom();
246
252
  return div;
247
253
  }
@@ -249,11 +255,85 @@
249
255
  var div = document.createElement('div');
250
256
  div.className = 'chat-action ' + (severity || '');
251
257
  div.textContent = label;
252
- msgsEl.appendChild(div);
258
+ _appendMsgEl(div);
253
259
  scrollToBottom();
254
260
  return div;
255
261
  }
256
262
 
263
+ // ── Queued-message rendering (W-mqayw3x6) ──────────────────────────
264
+ function _getQueue() {
265
+ return queues[tabId] || (queues[tabId] = []);
266
+ }
267
+ // Keep a single bottom-pinned container so queued bubbles always sit below
268
+ // the live transcript (newest content nearest the composer). appendChild on
269
+ // an already-attached node moves it, so this re-pins the container to the end.
270
+ function _ensureQueueEl() {
271
+ if (!queueEl || queueEl.parentNode !== msgsEl) {
272
+ queueEl = document.createElement('div');
273
+ queueEl.className = 'chat-queue';
274
+ }
275
+ msgsEl.appendChild(queueEl);
276
+ return queueEl;
277
+ }
278
+ // Insert a real/stream bubble above the queue container so the pending
279
+ // queued bubbles stay pinned at the bottom of the transcript.
280
+ function _appendMsgEl(el) {
281
+ if (queueEl && queueEl.parentNode === msgsEl) msgsEl.insertBefore(el, queueEl);
282
+ else msgsEl.appendChild(el);
283
+ }
284
+ // Rebuild the queued-message bubbles for the active tab: greyed-out user
285
+ // bubbles with a per-message dismiss. When auto-drain is suspended (after an
286
+ // abort/error) a "Send queued (N)" control lets the user resume explicitly.
287
+ function renderQueue() {
288
+ var q = _getQueue();
289
+ if (!q.length) {
290
+ if (queueEl && queueEl.parentNode) queueEl.parentNode.removeChild(queueEl);
291
+ queueEl = null;
292
+ queueSuspended[tabId] = false;
293
+ return;
294
+ }
295
+ _ensureQueueEl();
296
+ queueEl.textContent = '';
297
+ if (queueSuspended[tabId]) {
298
+ var resume = document.createElement('button');
299
+ resume.className = 'chat-queue-resume';
300
+ resume.type = 'button';
301
+ resume.textContent = 'Send queued (' + q.length + ')';
302
+ resume.title = 'Send the queued messages now';
303
+ resume.addEventListener('click', function() {
304
+ queueSuspended[tabId] = false;
305
+ renderQueue();
306
+ _drainQueue();
307
+ });
308
+ queueEl.appendChild(resume);
309
+ }
310
+ q.forEach(function(text, i) {
311
+ var row = document.createElement('div');
312
+ row.className = 'chat-msg user queued';
313
+ var span = document.createElement('span');
314
+ span.className = 'chat-queue-text';
315
+ span.textContent = text;
316
+ row.appendChild(span);
317
+ var badge = document.createElement('span');
318
+ badge.className = 'chat-queue-badge';
319
+ badge.textContent = 'queued';
320
+ row.appendChild(badge);
321
+ var dismiss = document.createElement('button');
322
+ dismiss.className = 'chat-queue-dismiss';
323
+ dismiss.type = 'button';
324
+ dismiss.textContent = '×';
325
+ dismiss.title = 'Remove from queue';
326
+ dismiss.addEventListener('click', function() {
327
+ var qq = _getQueue();
328
+ qq.splice(i, 1);
329
+ renderQueue();
330
+ });
331
+ row.appendChild(dismiss);
332
+ queueEl.appendChild(row);
333
+ });
334
+ scrollToBottom();
335
+ }
336
+
257
337
  // Render a tool invocation as a one-line string. `full` keeps the complete
258
338
  // command/args (used by the tool-calls modal); otherwise long values are
259
339
  // clipped for the collapsed 3-line panel.
@@ -370,7 +450,7 @@
370
450
  tp.panel.style.display = 'none';
371
451
  toolbar.appendChild(tp.panel);
372
452
 
373
- msgsEl.appendChild(div);
453
+ _appendMsgEl(div);
374
454
  scrollToBottom();
375
455
 
376
456
  return {
@@ -411,26 +491,32 @@
411
491
 
412
492
  function setSending(on) {
413
493
  sending = on;
414
- sendBtn.disabled = on;
415
- sendBtn.textContent = on ? 'Sending…' : 'Send';
494
+ // Composer stays interactive while a turn streams so the user can keep
495
+ // typing and queue follow-up messages (W-mqayw3x6 #1). The Send button
496
+ // stays enabled — submitting mid-turn enqueues — and only its label
497
+ // reflects the in-flight state.
498
+ sendBtn.disabled = false;
499
+ sendBtn.textContent = on ? 'Queue' : 'Send';
416
500
  stopBtn.style.display = on ? 'block' : 'none';
417
- inputEl.disabled = on;
501
+ inputEl.disabled = false;
418
502
  }
419
503
 
420
504
  function rerenderHistory() {
421
505
  msgsEl.innerHTML = '';
506
+ queueEl = null; // detached by the innerHTML reset; renderQueue rebuilds it
422
507
  if (!messages.length) {
423
508
  var empty = document.createElement('div');
424
509
  empty.className = 'chat-empty';
425
510
  empty.textContent = 'No messages yet — say hi to Command Center.';
426
511
  msgsEl.appendChild(empty);
427
- return;
428
- }
429
- for (var i = 0; i < messages.length; i++) {
430
- var m = messages[i];
431
- if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
432
- else appendBubble(m.role, m.text || '', m.toolCalls);
512
+ } else {
513
+ for (var i = 0; i < messages.length; i++) {
514
+ var m = messages[i];
515
+ if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
516
+ else appendBubble(m.role, m.text || '', m.toolCalls);
517
+ }
433
518
  }
519
+ renderQueue();
434
520
  }
435
521
  // ── Command Center tab bar (shared with the classic dashboard) ──
436
522
  // One chip per shared cc-tabs entry, so the same conversations are visible
@@ -23,10 +23,60 @@
23
23
  saveState();
24
24
  }
25
25
 
26
- async function sendMessage() {
27
- if (sending) return;
26
+ // Public entry point for the Send button / Enter key. While a turn is in
27
+ // flight the composer stays interactive (setSending no longer disables it),
28
+ // so a second submit QUEUES the message instead of dropping it or racing the
29
+ // active stream. The queue drains FIFO, one turn at a time (W-mqayw3x6).
30
+ function sendMessage() {
28
31
  var text = inputEl.value.trim();
29
32
  if (!text) return;
33
+ inputEl.value = '';
34
+ inputEl.style.height = 'auto';
35
+ if (sending) {
36
+ _enqueue(text);
37
+ inputEl.focus();
38
+ return;
39
+ }
40
+ _performSend(text);
41
+ }
42
+
43
+ // Append to the active tab's pending queue and render the greyed bubbles.
44
+ function _enqueue(text) {
45
+ _getQueue().push(text);
46
+ renderQueue();
47
+ }
48
+
49
+ // Promote the next queued message for the active tab, one at a time. No-op
50
+ // while a turn is in flight or the queue is suspended (after abort/error).
51
+ function _drainQueue() {
52
+ if (sending || queueSuspended[tabId]) return;
53
+ var q = _getQueue();
54
+ if (!q.length) return;
55
+ var next = q.shift();
56
+ renderQueue();
57
+ _performSend(next);
58
+ }
59
+
60
+ // Called from a finished turn's `finally`. On clean completion auto-fire the
61
+ // next queued message; on abort or error suspend auto-drain and leave the
62
+ // queue visible so the user can resend or dismiss explicitly (W-mqayw3x6 #5/#6).
63
+ function _afterTurn(turnTabId, outcome) {
64
+ var q = queues[turnTabId];
65
+ if (!q || !q.length) return;
66
+ if (outcome === 'done' && turnTabId === tabId) {
67
+ _drainQueue();
68
+ } else {
69
+ queueSuspended[turnTabId] = true;
70
+ if (turnTabId === tabId) renderQueue();
71
+ }
72
+ }
73
+
74
+ async function _performSend(text) {
75
+ // Bind this turn to the tab it started on so a mid-turn tab switch (which
76
+ // aborts the stream) settles the queue against the right tab, not the one
77
+ // the user navigated to.
78
+ var turnTabId = tabId;
79
+ var turnOutcome = 'done';
30
80
 
31
81
  // The tab title is derived from the first user message; only that message
32
82
  // changes the chip, so re-render the bar once instead of on every send.
@@ -36,8 +86,6 @@
36
86
  saveState();
37
87
  if (wasFirstUser) renderTabBar();
38
88
 
39
- inputEl.value = '';
40
- inputEl.style.height = 'auto';
41
89
  setSending(true);
42
90
 
43
91
  var stream = buildStreamBubble();
@@ -101,6 +149,7 @@
101
149
  stream.replaceWithError(errorEvt.error || 'Error');
102
150
  messages.push({ role: 'error', text: errorEvt.error || 'Error' });
103
151
  saveState();
152
+ turnOutcome = 'error';
104
153
  return;
105
154
  }
106
155
 
@@ -130,6 +179,7 @@
130
179
  } catch (e) {
131
180
  if (e && (e.name === 'AbortError' || /aborted/i.test(String(e.message || '')))) {
132
181
  userAborted = true;
182
+ turnOutcome = 'aborted';
133
183
  if (streamedText) {
134
184
  stream.finalize(streamedText);
135
185
  messages.push({ role: 'assistant', text: streamedText, toolCalls: stream.getToolList() });
@@ -139,6 +189,7 @@
139
189
  }
140
190
  saveState();
141
191
  } else {
192
+ turnOutcome = 'error';
142
193
  var msg = 'Send failed: ' + (e && e.message ? e.message : e);
143
194
  stream.replaceWithError(msg);
144
195
  messages.push({ role: 'error', text: msg });
@@ -147,7 +198,8 @@
147
198
  } finally {
148
199
  setSending(false);
149
200
  abortController = null;
150
- if (!userAborted) inputEl.focus();
201
+ if (turnTabId === tabId && !userAborted) inputEl.focus();
202
+ _afterTurn(turnTabId, turnOutcome);
151
203
  }
152
204
  }
153
205
 
@@ -165,6 +217,10 @@
165
217
 
166
218
  async function slimChatNew() {
167
219
  if (sending) abortInFlight();
220
+ // New chat = fresh session: drop the leaving tab's pending queue (#7). The
221
+ // aborted in-flight turn's _afterTurn then finds an empty queue and no-ops.
222
+ delete queues[tabId];
223
+ queueSuspended[tabId] = false;
168
224
  // Register a fresh tab in the shared cc-tabs store and switch slim onto
169
225
  // it — classic's other tabs are left intact, and the previous session
170
226
  // keeps its own server-side cc-sessions.json entry (no DELETE).
@@ -360,6 +360,59 @@
360
360
  border: 1px solid var(--red);
361
361
  font-size: var(--text-lg);
362
362
  }
363
+ /* Queued messages (W-mqayw3x6): follow-ups submitted while a turn is still
364
+ streaming. The container is pinned to the bottom of the transcript; each
365
+ item is a greyed-out user bubble that fires FIFO on turn completion. */
366
+ .chat-queue {
367
+ display: flex;
368
+ flex-direction: column;
369
+ gap: 8px;
370
+ align-self: stretch;
371
+ }
372
+ .chat-msg.user.queued {
373
+ align-self: flex-end;
374
+ background: var(--surface2);
375
+ color: var(--muted);
376
+ border: 1px dashed var(--border);
377
+ opacity: 0.8;
378
+ display: flex;
379
+ align-items: center;
380
+ gap: 8px;
381
+ }
382
+ .chat-queue-text { white-space: pre-wrap; word-wrap: break-word; }
383
+ .chat-queue-badge {
384
+ font-size: var(--text-base);
385
+ text-transform: uppercase;
386
+ letter-spacing: 0.5px;
387
+ color: var(--muted);
388
+ border: 1px solid var(--border);
389
+ border-radius: var(--radius);
390
+ padding: 1px 6px;
391
+ flex-shrink: 0;
392
+ }
393
+ .chat-queue-dismiss {
394
+ background: none;
395
+ border: none;
396
+ color: var(--muted);
397
+ cursor: pointer;
398
+ font-size: var(--text-lg);
399
+ line-height: 1;
400
+ padding: 0 2px;
401
+ flex-shrink: 0;
402
+ }
403
+ .chat-queue-dismiss:hover { color: var(--red); }
404
+ .chat-queue-resume {
405
+ align-self: center;
406
+ background: var(--blue);
407
+ color: #fff;
408
+ border: none;
409
+ border-radius: var(--radius);
410
+ padding: 6px 14px;
411
+ font-size: var(--text-md);
412
+ font-weight: 600;
413
+ cursor: pointer;
414
+ }
415
+ .chat-queue-resume:hover { filter: brightness(1.1); }
363
416
  .chat-input-wrap {
364
417
  display: flex;
365
418
  gap: 8px;
package/engine/watches.js CHANGED
@@ -150,7 +150,14 @@ function registerTargetType(type, spec) {
150
150
  throw new Error(`registerTargetType(${type}): absoluteConditions entry '${c}' is not in conditions[]`);
151
151
  }
152
152
  }
153
- TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet };
153
+ // W-mqa63opd000ha836 optional hook: returns true when the target has
154
+ // reached a state from which `condition` can never fire again (e.g. a
155
+ // PR's status is `merged` and we're watching `build-fail`). Default
156
+ // returns false (back-compat — current target types unchanged).
157
+ const terminalFn = typeof spec.isTerminalForCondition === 'function'
158
+ ? spec.isTerminalForCondition
159
+ : () => false;
160
+ TARGET_TYPES[type] = { ...spec, absoluteConditions: absoluteSet, isTerminalForCondition: terminalFn };
154
161
  }
155
162
 
156
163
  /** Returns the registered spec for a target type, or null. */
@@ -517,6 +524,40 @@ function checkWatches(config, state) {
517
524
  });
518
525
  }
519
526
 
527
+ // W-mqa63opd000ha836 — auto-expire watches whose target has reached a
528
+ // terminal state from which the watched condition can never fire again.
529
+ // Independent of the absolute-condition fire-once path above: covers the
530
+ // long-tail case where a `build-fail` (or similar) watch was armed on an
531
+ // active PR that subsequently merged without ever tripping its condition.
532
+ // Without this, the watch sits `active` forever, polling every interval.
533
+ //
534
+ // Guardrail: do NOT auto-expire until the watch has had a chance to fire
535
+ // on its first real check — `triggerCount > 0` covers post-fire watches;
536
+ // `prevState.status === entity.status` covers second-and-later checks
537
+ // (initial _captureState on tick-1 makes these equal, so a watch armed
538
+ // on an already-terminal target with `condition: merged` still fires
539
+ // once via the absolute-condition path before this branch expires it).
540
+ if (watch.status === WATCH_STATUS.ACTIVE) {
541
+ const _ttForTerm = TARGET_TYPES[watch.targetType];
542
+ if (_ttForTerm && typeof _ttForTerm.isTerminalForCondition === 'function') {
543
+ try {
544
+ const _entityForTerm = _ttForTerm.fetchEntity(watch.target, state || {});
545
+ if (_entityForTerm) {
546
+ const _hadShot = (watch.triggerCount || 0) > 0
547
+ || (previousState && previousState.status !== undefined
548
+ && previousState.status === _entityForTerm.status);
549
+ if (_hadShot && _ttForTerm.isTerminalForCondition(watch.condition, _entityForTerm, previousState)) {
550
+ watch.status = WATCH_STATUS.EXPIRED;
551
+ const _termStatus = _entityForTerm.status;
552
+ log('info', `Watch auto-expired (terminal target state): ${watch.id} — ${watch.targetType} ${watch.target} is ${_termStatus}, condition ${watch.condition} cannot fire again`);
553
+ }
554
+ }
555
+ } catch (termErr) {
556
+ log('warn', `Watch terminal-state check error (${watch.id}): ${termErr.message}`);
557
+ }
558
+ }
559
+ }
560
+
520
561
  // Capture state for change detection on next check
521
562
  watch._lastState = _captureState(watch, state);
522
563
 
@@ -704,6 +745,36 @@ registerTargetType(WATCH_TARGET_TYPE.PR, {
704
745
  WATCH_CONDITION.MERGED, WATCH_CONDITION.BUILD_FAIL, WATCH_CONDITION.BUILD_PASS,
705
746
  WATCH_CONDITION.READY_FOR_MERGE,
706
747
  ],
748
+ // W-mqa63opd000ha836 — zombie-watch janitor: once a PR reaches a terminal
749
+ // status (merged / closed / abandoned), most PR conditions can never fire
750
+ // again so the watch should auto-expire instead of polling forever.
751
+ //
752
+ // Excluded from terminal-expire:
753
+ // - merged — already handled by the absoluteConditions fire-once
754
+ // path; including it here is redundant and risks
755
+ // expiring before the watch's one shot to fire.
756
+ // - status-change — could theoretically still fire on a status flip
757
+ // (e.g. closed→reopened on GitHub), even though rare.
758
+ // - any — same rationale as status-change.
759
+ //
760
+ // Build-fail / build-pass / vote-change / new-comments / head-commit-change /
761
+ // mergeable-flipped / behind-master / ready-for-merge / draft-flipped all
762
+ // mutate fields that are frozen the moment the PR is merged/closed/abandoned,
763
+ // so a fresh fire is impossible.
764
+ isTerminalForCondition: (condition, pr) => {
765
+ if (!pr || !pr.status) return false;
766
+ const status = pr.status;
767
+ const isTerminal = status === shared.PR_STATUS.MERGED
768
+ || status === shared.PR_STATUS.CLOSED
769
+ || status === shared.PR_STATUS.ABANDONED;
770
+ if (!isTerminal) return false;
771
+ if (condition === WATCH_CONDITION.MERGED
772
+ || condition === WATCH_CONDITION.STATUS_CHANGE
773
+ || condition === WATCH_CONDITION.ANY) {
774
+ return false;
775
+ }
776
+ return true;
777
+ },
707
778
  fetchEntity: (target, state) => findPrByTarget(state.pullRequests, target),
708
779
  captureState: (pr) => ({
709
780
  status: pr.status, buildStatus: pr.buildStatus, reviewStatus: pr.reviewStatus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2174",
3
+ "version": "0.1.2176",
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"