@yemi33/minions 0.1.1026 → 0.1.1028

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/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1026 (2026-04-16)
3
+ ## 0.1.1028 (2026-04-16)
4
4
 
5
5
  ### Features
6
6
  - route implement items to dedicated implement playbook (#1115)
@@ -25,6 +25,40 @@
25
25
  - harden audited state transitions
26
26
 
27
27
  ### Other
28
+ - [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
29
+ - Fix doc chat session isolation
30
+ - test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
31
+ - chore: test publish after removing stale status check
32
+ - chore: trigger publish test
33
+ - chore: test publish workflow fix
34
+ - chore: trigger publish workflow test
35
+
36
+ ## 0.1.1027 (2026-04-16)
37
+
38
+ ### Features
39
+ - route implement items to dedicated implement playbook (#1115)
40
+
41
+ ### Fixes
42
+ - prevent test from corrupting live meeting state
43
+ - scheduler enabled field falsy check treats undefined as disabled (#1160)
44
+ - update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
45
+ - auto-review not firing for manually-linked PRs with autoObserve=true
46
+ - mark PR abandoned on 404 instead of silently retrying each tick
47
+ - replace undefined PROJECTS with config.projects in checkWatches (#1108)
48
+ - write permission for publish workflow
49
+ - run tests inline and post check runs for publish PRs
50
+ - add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
51
+ - add required CI checks for PRs + update publish for auto-merge
52
+ - revert to PR merge now that stale status check is removed
53
+ - guard live review check against undefined vote/state values (#1132)
54
+ - push version bump directly to master instead of via PR
55
+ - add push-triggered CI for chore/publish branches
56
+ - publish workflow chore PRs failing to merge
57
+ - harden KB ordering
58
+ - harden audited state transitions
59
+
60
+ ### Other
61
+ - Fix doc chat session isolation
28
62
  - test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
29
63
  - chore: test publish after removing stale status check
30
64
  - chore: trigger publish test
@@ -40,6 +40,7 @@ function _renderQaUserMessage(thread, message, selection) {
40
40
  _showThreadWrap();
41
41
  }
42
42
  const _qaSessions = new Map(); // persist conversations across modal open/close {key → {history, threadHtml}}
43
+ const _qaRuntime = new Map(); // key → {history, processing, abortController, queue}
43
44
  // Restore from localStorage
44
45
  try {
45
46
  const saved = JSON.parse(localStorage.getItem('qa-sessions') || '{}');
@@ -55,6 +56,175 @@ function _saveQaSessions() {
55
56
  } catch { /* localStorage might be full */ }
56
57
  }
57
58
 
59
+ function _qaCloneQueue(queue) {
60
+ return Array.isArray(queue) ? queue.map(item => ({ ...item })) : [];
61
+ }
62
+
63
+ function _qaGetRuntime(key) {
64
+ if (!key) return null;
65
+ let runtime = _qaRuntime.get(key);
66
+ if (!runtime) {
67
+ const prior = _qaSessions.get(key);
68
+ runtime = {
69
+ history: Array.isArray(prior?.history) ? prior.history.slice() : [],
70
+ processing: false,
71
+ abortController: null,
72
+ queue: _qaCloneQueue(prior?.queue),
73
+ };
74
+ _qaRuntime.set(key, runtime);
75
+ }
76
+ return runtime;
77
+ }
78
+
79
+ function _qaThreadEl() {
80
+ return document.getElementById('modal-qa-thread');
81
+ }
82
+
83
+ function _qaThreadHtml() {
84
+ return (_qaThreadEl() || {}).innerHTML || '';
85
+ }
86
+
87
+ function _qaIsActiveSession(key) {
88
+ return !!key && _qaSessionKey === key && document.getElementById('modal')?.classList?.contains('open');
89
+ }
90
+
91
+ function _qaSyncActiveRuntime() {
92
+ if (!_qaSessionKey) return;
93
+ const runtime = _qaGetRuntime(_qaSessionKey);
94
+ if (!runtime) return;
95
+ runtime.history = _qaHistory.slice();
96
+ runtime.processing = _qaProcessing;
97
+ runtime.abortController = _qaAbortController || null;
98
+ runtime.queue = _qaCloneQueue(_qaQueue);
99
+ }
100
+
101
+ function _qaPersistSession(key, { threadHtml, docContext, filePath, history, queue } = {}) {
102
+ if (!key) return;
103
+ const runtime = _qaGetRuntime(key);
104
+ const prior = _qaSessions.get(key) || {};
105
+ const persistedHistory = Array.isArray(history)
106
+ ? history.slice()
107
+ : Array.isArray(runtime?.history)
108
+ ? runtime.history.slice()
109
+ : Array.isArray(prior.history)
110
+ ? prior.history.slice()
111
+ : [];
112
+ _qaSessions.set(key, {
113
+ history: persistedHistory,
114
+ threadHtml: threadHtml != null ? threadHtml : (prior.threadHtml || ''),
115
+ docContext: docContext ? { ...docContext } : (prior.docContext ? { ...prior.docContext } : { title: '', content: '', selection: '' }),
116
+ filePath: filePath !== undefined ? filePath : prior.filePath,
117
+ queue: Array.isArray(queue) ? _qaCloneQueue(queue) : _qaCloneQueue(runtime?.queue),
118
+ });
119
+ _saveQaSessions();
120
+ }
121
+
122
+ function _qaSaveActiveSessionState() {
123
+ if (!_qaSessionKey) return;
124
+ _qaSyncActiveRuntime();
125
+ _qaPersistSession(_qaSessionKey, {
126
+ threadHtml: _qaThreadHtml(),
127
+ docContext: { ..._modalDocContext },
128
+ filePath: _modalFilePath,
129
+ history: _qaHistory,
130
+ queue: _qaQueue,
131
+ });
132
+ }
133
+
134
+ function _qaLoadSessionState(key) {
135
+ const prior = _qaSessions.get(key);
136
+ const runtime = _qaGetRuntime(key);
137
+ _qaHistory = Array.isArray(runtime?.history) && runtime.history.length
138
+ ? runtime.history.slice()
139
+ : Array.isArray(prior?.history)
140
+ ? prior.history.slice()
141
+ : [];
142
+ _qaProcessing = !!runtime?.processing;
143
+ _qaAbortController = runtime?.abortController || null;
144
+ _qaQueue = _qaCloneQueue(runtime?.queue);
145
+ const thread = _qaThreadEl();
146
+ if (thread) {
147
+ const tmp = document.createElement('div');
148
+ tmp.innerHTML = prior?.threadHtml || '';
149
+ if (!_qaProcessing) tmp.querySelectorAll('.modal-qa-loading').forEach(el => el.remove());
150
+ thread.innerHTML = tmp.innerHTML;
151
+ }
152
+ }
153
+
154
+ function _qaResetActiveState() {
155
+ _qaHistory = [];
156
+ _qaProcessing = false;
157
+ _qaAbortController = null;
158
+ _qaQueue = [];
159
+ _qaSessionKey = '';
160
+ }
161
+
162
+ function _qaBuildUserMessageHtml(message, selection) {
163
+ let qHtml = '<div class="modal-qa-q">' + escHtml(message);
164
+ if (selection) {
165
+ qHtml += '<span class="selection-ref">Re: "' + escHtml(selection.slice(0, 100)) + ((selection.length > 100) ? '...' : '') + '"</span>';
166
+ }
167
+ qHtml += '</div>';
168
+ return qHtml;
169
+ }
170
+
171
+ function _qaBuildQueuedHtml(message) {
172
+ const preview = escHtml(message.length > 60 ? message.slice(0, 57) + '...' : message);
173
+ return '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>';
174
+ }
175
+
176
+ function _qaBuildLoadingHtml(loadingId, queueCount) {
177
+ const qaQueueBadge = queueCount > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + queueCount + ' queued</span>' : '';
178
+ return '<div class="modal-qa-loading" id="' + loadingId + '">' +
179
+ '<div class="dot-pulse"><span></span><span></span><span></span></div> ' +
180
+ '<span id="' + loadingId + '-text">Thinking...</span> ' +
181
+ '<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)"></span>' +
182
+ ' <button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
183
+ qaQueueBadge + '</div>';
184
+ }
185
+
186
+ function _qaBuildAssistantHtml(text, opts) {
187
+ const body = opts?.isError ? escHtml(text) : renderMd(text);
188
+ const style = opts?.isError
189
+ ? 'color:' + (opts?.color || 'var(--red)')
190
+ : 'border-left-color:' + (opts?.borderColor || 'var(--blue)');
191
+ const pad = opts?.isError ? '' : 'padding-right:24px;';
192
+ return '<div class="modal-qa-a" style="' + style + '">' +
193
+ (opts?.isError ? '' : llmCopyBtn()) +
194
+ body +
195
+ '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
196
+ '</div>';
197
+ }
198
+
199
+ function _qaMutateThreadHtml(key, mutate) {
200
+ const tmp = document.createElement('div');
201
+ tmp.innerHTML = _qaIsActiveSession(key) ? _qaThreadHtml() : ((_qaSessions.get(key) || {}).threadHtml || '');
202
+ mutate(tmp);
203
+ const html = tmp.innerHTML;
204
+ if (_qaIsActiveSession(key)) {
205
+ const thread = _qaThreadEl();
206
+ if (thread) {
207
+ thread.innerHTML = html;
208
+ thread.scrollTop = thread.scrollHeight;
209
+ }
210
+ _showThreadWrap();
211
+ }
212
+ return html;
213
+ }
214
+
215
+ function _qaResumeQueuedMessages() {
216
+ if (!_qaSessionKey || _qaProcessing || _qaQueue.length === 0) return;
217
+ const next = _qaQueue.shift();
218
+ const thread = _qaThreadEl();
219
+ if (thread) {
220
+ const queuedEl = thread.querySelector('.qa-queued-item');
221
+ if (queuedEl) queuedEl.remove();
222
+ _renderQaUserMessage(thread, next.message, next.selection);
223
+ }
224
+ _qaSaveActiveSessionState();
225
+ _processQaMessage(next.message, next.selection);
226
+ }
227
+
58
228
  function modalAskAboutSelection() {
59
229
  document.getElementById('ask-selection-btn').style.display = 'none';
60
230
 
@@ -91,16 +261,14 @@ function clearQaSelection() {
91
261
  function _initQaSession() {
92
262
  var key = _modalFilePath || _modalDocContext.title || '';
93
263
  if (!key || _qaSessionKey === key) return;
264
+ if (_qaSessionKey && _qaSessionKey !== key) _qaSaveActiveSessionState();
94
265
  _qaSessionKey = key;
95
- // Clear notification badge on the source card when reopening
96
266
  const card = findCardForFile(_modalFilePath);
97
267
  if (card) clearNotifBadge(card);
98
268
  var prior = _qaSessions.get(key);
269
+ _qaLoadSessionState(key);
99
270
  if (prior) {
100
- _qaHistory = prior.history;
101
- document.getElementById('modal-qa-thread').innerHTML = prior.threadHtml;
102
271
  if (prior.docContext) {
103
- // Preserve freshly-fetched content and title — prior session may have stale/empty content
104
272
  const freshContent = _modalDocContext.content;
105
273
  const freshTitle = _modalDocContext.title;
106
274
  _modalDocContext = Object.assign({}, prior.docContext, {
@@ -111,11 +279,13 @@ function _initQaSession() {
111
279
  }
112
280
  if (prior.filePath) _modalFilePath = prior.filePath;
113
281
  _showThreadWrap();
114
- // Defer scroll — container just transitioned from display:none, layout not yet computed
115
282
  requestAnimationFrame(function() {
116
283
  var thread = document.getElementById('modal-qa-thread');
117
284
  if (thread) thread.scrollTop = thread.scrollHeight;
118
285
  });
286
+ if (_qaQueue.length > 0 && !_qaProcessing) {
287
+ setTimeout(_qaResumeQueuedMessages, 0);
288
+ }
119
289
  } else {
120
290
  _qaHistory = [];
121
291
  document.getElementById('modal-qa-thread').innerHTML = '';
@@ -127,9 +297,13 @@ function _initQaSession() {
127
297
  }
128
298
 
129
299
  function clearQaConversation() {
300
+ const runtime = _qaGetRuntime(_qaSessionKey);
301
+ if (_qaAbortController) _qaAbortController.abort();
302
+ if (runtime?.abortController && runtime.abortController !== _qaAbortController) runtime.abortController.abort();
130
303
  _qaHistory = [];
131
304
  _qaQueue = [];
132
305
  _qaProcessing = false;
306
+ _qaAbortController = null;
133
307
  document.getElementById('modal-qa-thread').innerHTML = '';
134
308
  var wrap = document.getElementById('modal-qa-thread-wrap');
135
309
  var expandBar = document.getElementById('qa-expand-bar');
@@ -137,6 +311,7 @@ function clearQaConversation() {
137
311
  if (expandBar) expandBar.style.display = 'none';
138
312
  if (_qaSessionKey) {
139
313
  _qaSessions.delete(_qaSessionKey);
314
+ _qaRuntime.set(_qaSessionKey, { history: [], processing: false, abortController: null, queue: [] });
140
315
  _saveQaSessions();
141
316
  }
142
317
  }
@@ -164,7 +339,6 @@ function modalSend() {
164
339
  var thread = document.getElementById('modal-qa-thread');
165
340
  const selection = _modalDocContext.selection || '';
166
341
 
167
- // Clear input immediately so user can type next message
168
342
  input.value = '';
169
343
  _modalDocContext.selection = '';
170
344
  document.getElementById('modal-qa-pill').style.display = 'none';
@@ -174,63 +348,79 @@ function modalSend() {
174
348
  showToast('cmd-toast', 'Queue full — wait for current response', false);
175
349
  return;
176
350
  }
177
- // Queue the message — show only grey queued indicator (blue bubble shows when processing starts)
178
351
  _qaQueue.push({ message, selection });
179
- const preview = escHtml(message.length > 60 ? message.slice(0, 57) + '...' : message);
180
- thread.insertAdjacentHTML('beforeend', '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>');
352
+ thread.insertAdjacentHTML('beforeend', _qaBuildQueuedHtml(message));
181
353
  thread.scrollTop = thread.scrollHeight;
182
354
  _showThreadWrap();
355
+ _qaSaveActiveSessionState();
183
356
  return;
184
357
  }
185
358
 
186
- // Show message in thread when processing starts (not when queued)
187
359
  _renderQaUserMessage(thread, message, selection);
188
-
360
+ _qaSaveActiveSessionState();
189
361
  _processQaMessage(message, selection);
190
362
  }
191
363
 
192
- async function _processQaMessage(message, selection) {
193
- const thread = document.getElementById('modal-qa-thread');
194
- const btn = document.getElementById('modal-send-btn');
195
- _qaProcessing = true;
196
-
197
- // Capture state now — closeModal may null these while we're awaiting
198
- const capturedFilePath = _modalFilePath;
199
- const capturedDocContext = { ..._modalDocContext };
364
+ async function _processQaMessage(message, selection, opts) {
365
+ const sessionKey = opts?.sessionKey || _qaSessionKey;
366
+ if (!sessionKey) return;
367
+ const runtime = _qaGetRuntime(sessionKey);
368
+ if (!runtime) return;
369
+
370
+ const prior = _qaSessions.get(sessionKey);
371
+ const capturedFilePath = opts?.filePath !== undefined ? opts.filePath : (prior?.filePath || _modalFilePath);
372
+ const capturedDocContext = Object.assign({}, prior?.docContext || {}, opts?.docContext || (_qaIsActiveSession(sessionKey) ? _modalDocContext : {}));
373
+ const isActiveSession = _qaIsActiveSession(sessionKey);
374
+
375
+ runtime.processing = true;
376
+ const abortController = new AbortController();
377
+ runtime.abortController = abortController;
378
+ if (isActiveSession) {
379
+ _qaProcessing = true;
380
+ _qaAbortController = abortController;
381
+ }
200
382
 
201
- // Show processing badge on the source card
202
383
  const sourceCard = findCardForFile(capturedFilePath);
203
384
  if (sourceCard) showNotifBadge(sourceCard, 'processing');
204
385
 
205
386
  const loadingId = 'chat-loading-' + Date.now();
206
- const qaQueueBadge = _qaQueue.length > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + _qaQueue.length + ' queued</span>' : '';
207
- _qaAbortController = new AbortController();
208
- thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-loading" id="' + loadingId + '">' +
209
- '<div class="dot-pulse"><span></span><span></span><span></span></div> ' +
210
- '<span id="' + loadingId + '-text">Thinking...</span> ' +
211
- '<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)"></span>' +
212
- ' <button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
213
- qaQueueBadge + '</div>');
214
- thread.scrollTop = thread.scrollHeight;
387
+ const loadingHtml = _qaBuildLoadingHtml(loadingId, runtime.queue.length);
388
+ const startThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
389
+ tmp.insertAdjacentHTML('beforeend', loadingHtml);
390
+ });
391
+ _qaPersistSession(sessionKey, {
392
+ threadHtml: startThreadHtml,
393
+ docContext: capturedDocContext,
394
+ filePath: capturedFilePath,
395
+ history: runtime.history,
396
+ queue: runtime.queue,
397
+ });
215
398
 
216
- const isPlanEdit = _modalFilePath && _modalFilePath.match(/^plans\/.*\.md$/);
399
+ const isPlanEdit = capturedFilePath && capturedFilePath.match(/^plans\/.*\.md$/);
217
400
  const qaStartTime = Date.now();
218
401
  const qaPhases = isPlanEdit
219
402
  ? [[0,'Reading plan...'],[3000,'Analyzing structure...'],[8000,'Researching context...'],[15000,'Drafting revisions...'],[30000,'Writing updated plan...'],[60000,'Still working (large document)...'],[120000,'Deep edit in progress...'],[300000,'Almost there...']]
220
403
  : [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
221
404
  const qaTimer = setInterval(() => {
222
405
  const elapsed = Date.now() - qaStartTime;
223
- const timeEl = document.getElementById(loadingId + '-time');
224
- const textEl = document.getElementById(loadingId + '-text');
406
+ const timeEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-time') : null;
407
+ const textEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-text') : null;
225
408
  if (timeEl) timeEl.textContent = Math.floor(elapsed / 1000) + 's';
226
- if (textEl) { for (let i = qaPhases.length - 1; i >= 0; i--) { if (elapsed >= qaPhases[i][0]) { textEl.textContent = qaPhases[i][1]; break; } } }
409
+ if (textEl) {
410
+ for (let i = qaPhases.length - 1; i >= 0; i--) {
411
+ if (elapsed >= qaPhases[i][0]) {
412
+ textEl.textContent = qaPhases[i][1];
413
+ break;
414
+ }
415
+ }
416
+ }
227
417
  }, 500);
228
418
 
229
419
  try {
230
420
  const res = await fetch('/api/doc-chat', {
231
421
  method: 'POST',
232
422
  headers: { 'Content-Type': 'application/json' },
233
- signal: _qaAbortController ? _qaAbortController.signal : undefined,
423
+ signal: abortController.signal,
234
424
  body: JSON.stringify({
235
425
  message,
236
426
  document: capturedDocContext.content,
@@ -242,109 +432,141 @@ async function _processQaMessage(message, selection) {
242
432
  });
243
433
  const data = await res.json();
244
434
  clearInterval(qaTimer);
245
- const loadingEl = document.getElementById(loadingId);
246
- if (loadingEl) loadingEl.remove();
435
+ const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
436
+ let sessionDocContext = { ...capturedDocContext };
247
437
 
248
438
  if (data.ok) {
249
439
  const borderColor = data.edited ? 'var(--green)' : 'var(--blue)';
250
440
  const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
251
- const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
252
- const qaTimeLabel = '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;padding-right:24px">' + qaElapsed + 's</div>';
253
- thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="border-left-color:' + borderColor + '">' + llmCopyBtn() + renderMd(data.answer + suffix) + qaTimeLabel + '</div>');
441
+ const answerHtml = _qaBuildAssistantHtml(data.answer + suffix, { borderColor, elapsed: qaElapsed });
442
+ const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
443
+ const loadingEl = tmp.querySelector('#' + loadingId);
444
+ if (loadingEl) loadingEl.remove();
445
+ tmp.insertAdjacentHTML('beforeend', answerHtml);
446
+ });
254
447
 
255
- // Track conversation history
256
- _qaHistory.push({ role: 'user', text: message });
257
- _qaHistory.push({ role: 'assistant', text: data.answer });
448
+ runtime.history.push({ role: 'user', text: message });
449
+ runtime.history.push({ role: 'assistant', text: data.answer });
450
+ if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
258
451
 
259
- // Notify sidebar page link
260
452
  _qaNotifySidebar(capturedFilePath);
261
-
262
- // Execute any CC actions (dispatch, note, etc.)
263
453
  if (data.actions && data.actions.length > 0) {
264
- for (const action of data.actions) { await ccExecuteAction(action); }
454
+ for (const action of data.actions) await ccExecuteAction(action);
265
455
  }
266
456
 
267
- // Refresh modal body if document was edited
268
457
  if (data.edited && data.content) {
269
458
  const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
270
459
  const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
271
- const body = document.getElementById('modal-body');
272
- if (isJson) {
273
- body.textContent = display;
274
- } else {
275
- body.innerHTML = renderMd(display);
276
- body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
277
- body.style.whiteSpace = 'normal';
460
+ sessionDocContext.content = display;
461
+ sessionDocContext.selection = '';
462
+ if (_qaIsActiveSession(sessionKey)) {
463
+ const body = document.getElementById('modal-body');
464
+ if (isJson) {
465
+ body.textContent = display;
466
+ } else {
467
+ body.innerHTML = renderMd(display);
468
+ body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
469
+ body.style.whiteSpace = 'normal';
470
+ }
471
+ _modalDocContext.content = display;
278
472
  }
279
- _modalDocContext.content = display;
280
473
  }
474
+
475
+ _qaPersistSession(sessionKey, {
476
+ threadHtml: updatedThreadHtml,
477
+ docContext: sessionDocContext,
478
+ filePath: capturedFilePath,
479
+ history: runtime.history,
480
+ queue: runtime.queue,
481
+ });
281
482
  } else {
282
- const qaElapsedErr = Math.round((Date.now() - qaStartTime) / 1000);
283
- thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(data.error || 'Failed') + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedErr + 's</div></div>');
483
+ const errorHtml = _qaBuildAssistantHtml('Error: ' + (data.error || 'Failed'), { color: 'var(--red)', isError: true, elapsed: qaElapsed });
484
+ const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
485
+ const loadingEl = tmp.querySelector('#' + loadingId);
486
+ if (loadingEl) loadingEl.remove();
487
+ tmp.insertAdjacentHTML('beforeend', errorHtml);
488
+ });
489
+ _qaPersistSession(sessionKey, {
490
+ threadHtml: updatedThreadHtml,
491
+ docContext: sessionDocContext,
492
+ filePath: capturedFilePath,
493
+ history: runtime.history,
494
+ queue: runtime.queue,
495
+ });
284
496
  }
285
497
  } catch (e) {
286
498
  clearInterval(qaTimer);
287
- const loadingEl = document.getElementById(loadingId);
288
- if (loadingEl) loadingEl.remove();
289
499
  const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
290
- if (e.name === 'AbortError') {
291
- thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--muted)">Stopped<div style="font-size:9px;margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>');
292
- } else {
293
- thread.insertAdjacentHTML('beforeend', '<div class="modal-qa-a" style="color:var(--red)">Error: ' + escHtml(e.message) + '<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right">' + qaElapsedExc + 's</div></div>');
294
- }
500
+ const messageHtml = e.name === 'AbortError'
501
+ ? _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc })
502
+ : _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc });
503
+ const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
504
+ const loadingEl = tmp.querySelector('#' + loadingId);
505
+ if (loadingEl) loadingEl.remove();
506
+ tmp.insertAdjacentHTML('beforeend', messageHtml);
507
+ });
508
+ _qaPersistSession(sessionKey, {
509
+ threadHtml: updatedThreadHtml,
510
+ docContext: capturedDocContext,
511
+ filePath: capturedFilePath,
512
+ history: runtime.history,
513
+ queue: runtime.queue,
514
+ });
295
515
  }
296
516
 
297
- _qaProcessing = false;
298
- _qaAbortController = null;
299
- thread.scrollTop = thread.scrollHeight;
300
-
301
- // Clear processing badge on source card (unless more messages queued)
302
- if (_qaQueue.length === 0) {
303
- const doneCard = findCardForFile(capturedFilePath);
304
- if (doneCard) clearNotifBadge(doneCard);
517
+ runtime.processing = false;
518
+ runtime.abortController = null;
519
+ if (_qaIsActiveSession(sessionKey)) {
520
+ _qaProcessing = false;
521
+ _qaAbortController = null;
305
522
  }
306
523
 
307
- // Save session (persists even if modal was closed during processing)
308
- const modalIsOpen = document.getElementById('modal').classList.contains('open');
309
- if (_qaSessionKey) {
310
- // Use captured values if closeModal nulled the globals during processing
311
- const sessionFilePath = _modalFilePath || capturedFilePath;
312
- const sessionDocContext = _modalDocContext.title ? { ..._modalDocContext } : { ...capturedDocContext, ..._modalDocContext, title: capturedDocContext.title };
313
- _qaSessions.set(_qaSessionKey, {
314
- history: _qaHistory,
315
- threadHtml: thread.innerHTML,
316
- docContext: sessionDocContext,
317
- filePath: sessionFilePath,
318
- });
319
- _saveQaSessions();
320
- // Show notification badge on source card when modal was closed during processing
321
- if (!modalIsOpen) {
322
- const card = findCardForFile(sessionFilePath);
323
- if (card) showNotifBadge(card, _qaQueue.length > 0 ? 'processing' : 'done');
324
- _qaSessionKey = '';
524
+ if (runtime.queue.length === 0) {
525
+ const doneCard = findCardForFile(capturedFilePath);
526
+ if (_qaIsActiveSession(sessionKey)) {
527
+ if (doneCard) clearNotifBadge(doneCard);
528
+ } else if (doneCard) {
529
+ showNotifBadge(doneCard, 'done');
325
530
  }
531
+ } else {
532
+ const pendingCard = findCardForFile(capturedFilePath);
533
+ if (pendingCard) showNotifBadge(pendingCard, 'processing');
326
534
  }
327
535
 
328
- // Process next queued message
329
- if (_qaQueue.length > 0) {
330
- const next = _qaQueue.shift();
331
- // Remove the queued indicator and show the blue user bubble now that it's processing
332
- const queuedEl = thread.querySelector('.qa-queued-item');
333
- if (queuedEl) queuedEl.remove();
334
- _renderQaUserMessage(thread, next.message, next.selection);
335
- _processQaMessage(next.message, next.selection);
336
- } else if (modalIsOpen) {
536
+ if (runtime.queue.length > 0) {
537
+ const next = runtime.queue.shift();
538
+ const nextThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
539
+ const queuedEl = tmp.querySelector('.qa-queued-item');
540
+ if (queuedEl) queuedEl.remove();
541
+ tmp.insertAdjacentHTML('beforeend', _qaBuildUserMessageHtml(next.message, next.selection));
542
+ });
543
+ _qaPersistSession(sessionKey, {
544
+ threadHtml: nextThreadHtml,
545
+ docContext: (_qaSessions.get(sessionKey) || {}).docContext || capturedDocContext,
546
+ filePath: capturedFilePath,
547
+ history: runtime.history,
548
+ queue: runtime.queue,
549
+ });
550
+ if (_qaIsActiveSession(sessionKey)) _qaQueue = _qaCloneQueue(runtime.queue);
551
+ _processQaMessage(next.message, next.selection, {
552
+ sessionKey,
553
+ filePath: capturedFilePath,
554
+ docContext: (_qaSessions.get(sessionKey) || {}).docContext || capturedDocContext,
555
+ });
556
+ } else if (_qaIsActiveSession(sessionKey)) {
557
+ _qaQueue = [];
337
558
  document.getElementById('modal-qa-input')?.focus();
338
559
  }
339
560
  }
340
561
 
341
562
  function qaAbort() {
342
- if (_qaAbortController) {
343
- _qaAbortController.abort();
344
- _qaAbortController = null;
563
+ const runtime = _qaGetRuntime(_qaSessionKey);
564
+ if (runtime?.abortController) {
565
+ runtime.abortController.abort();
566
+ runtime.abortController = null;
345
567
  }
346
- // Don't reset _qaProcessing here — the catch block in _processQaMessage handles it,
347
- // avoiding a double-drain race on _qaQueue from a microtask gap.
568
+ if (_qaAbortController) _qaAbortController = null;
569
+ // Don't reset _qaProcessing here the catch block in _processQaMessage handles it.
348
570
  // Don't clear _qaQueue — queued messages auto-process after abort.
349
571
  }
350
572
 
@@ -13,24 +13,17 @@ function closeModal() {
13
13
  const resetBtn = document.getElementById('modal-settings-reset');
14
14
  if (resetBtn) resetBtn.remove();
15
15
  // Save Q&A session for this document (persist across modal open/close)
16
- if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0)) {
17
- _qaSessions.set(_qaSessionKey, {
18
- history: _qaHistory,
19
- threadHtml: (document.getElementById('modal-qa-thread') || {}).innerHTML || '',
20
- docContext: { ..._modalDocContext },
21
- filePath: _modalFilePath,
22
- });
23
- _saveQaSessions();
16
+ if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0 || _qaProcessing)) {
17
+ _qaSaveActiveSessionState();
24
18
  }
25
19
  // If still processing, show animated badge on the source card
26
20
  if (_qaProcessing && _modalFilePath) {
27
21
  const card = findCardForFile(_modalFilePath);
28
22
  if (card) showNotifBadge(card, 'processing');
29
23
  }
30
- // Reset UI state but don't kill processing/queuethey run in background
24
+ // Reset active UI state but don't kill background processing — it is tracked per session
25
+ _qaResetActiveState();
31
26
  _modalDocContext = { title: '', content: '', selection: '' };
32
- // Keep session key alive if processing is in flight — result will save when it completes
33
- if (!_qaProcessing) _qaSessionKey = '';
34
27
  document.getElementById('modal-qa-input').value = '';
35
28
  document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
36
29
  document.getElementById('modal-qa-pill').style.display = 'none';
package/dashboard.js CHANGED
@@ -171,7 +171,14 @@ function getVerifyGuides() {
171
171
  function getArchivedPrds() { return []; }
172
172
  function getEngineState() { return queries.getControl(); }
173
173
 
174
+ let _worktreeCountCache = 0;
175
+ let _worktreeCountCacheTs = 0;
176
+
174
177
  function _countWorktrees() {
178
+ const now = Date.now();
179
+ if (_worktreeCountCacheTs && (now - _worktreeCountCacheTs) < shared.ENGINE_DEFAULTS.worktreeCountCacheTtl) {
180
+ return _worktreeCountCache;
181
+ }
175
182
  try {
176
183
  const config = queries.getConfig();
177
184
  const projects = shared.getProjects(config);
@@ -199,6 +206,8 @@ function _countWorktrees() {
199
206
  }
200
207
  } catch {}
201
208
  }
209
+ _worktreeCountCache = count;
210
+ _worktreeCountCacheTs = now;
202
211
  return count;
203
212
  } catch { return 0; }
204
213
  }
@@ -302,13 +311,20 @@ function parsePinnedEntries(content) {
302
311
  return entries;
303
312
  }
304
313
 
314
+ // Two-tier status cache: fast state (10s) for frequently-changing data, slow state (60s) for rarely-changing data.
315
+ // Combined into _statusCache for API/SSE consumers — no API contract change.
316
+ let _fastState = null;
317
+ let _fastStateTs = 0;
318
+ const FAST_STATE_TTL = 10000; // 10s — dispatch, agents, metrics, work items, etc.
319
+ let _slowState = null;
320
+ let _slowStateTs = 0;
321
+ const SLOW_STATE_TTL = 60000; // 60s — skills, PRDs, pinned, version, projects, etc.
305
322
  let _statusCache = null;
306
323
  let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
307
- let _statusCacheTs = 0;
308
- const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
324
+ let _statusCacheGzip = null; // pre-computed gzip of _statusCacheJson — avoids per-request gzipSync
309
325
  const _statusStreamClients = new Set();
310
326
  let _statusPushTimer = null;
311
- let _lastStatusHash = '';
327
+ let _lastStatusPushRef = null; // last JSON string reference pushed to SSE — O(1) change detection
312
328
 
313
329
  // mtime-based cache invalidation — skip full rebuild if no tracked files changed
314
330
  const _mtimeTrackedFiles = () => {
@@ -348,8 +364,12 @@ function _mtimesChanged(prev, curr) {
348
364
  }
349
365
 
350
366
  function invalidateStatusCache() {
367
+ _fastState = null;
368
+ _fastStateTs = 0;
369
+ // Slow state continues on its own TTL — not invalidated by mutations
351
370
  _statusCache = null;
352
371
  _statusCacheJson = null;
372
+ _statusCacheGzip = null;
353
373
  // Push to SSE clients (debounced 500ms to avoid flooding during batch mutations)
354
374
  if (_statusPushTimer) return;
355
375
  _statusPushTimer = setTimeout(() => {
@@ -364,89 +384,110 @@ function invalidateStatusCache() {
364
384
 
365
385
  function getStatus() {
366
386
  const now = Date.now();
367
- if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) {
368
- // Within TTL check mtimes for early return (skip full rebuild if nothing changed)
387
+
388
+ // Fast state: 10s TTL with mtime-based validation for early exit
389
+ let fastStale = !_fastState || (now - _fastStateTs) >= FAST_STATE_TTL;
390
+ if (!fastStale) {
391
+ // Within TTL — check mtimes for early return (skip rebuild if no tracked files changed)
369
392
  const currMtimes = _getMtimes();
370
- if (!_mtimesChanged(_lastMtimes, currMtimes)) return _statusCache;
393
+ if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
371
394
  }
372
395
 
373
- // Reload config on each cache miss picks up external changes (minions init, minions add)
374
- reloadConfig();
375
-
376
- const prdInfo = getPrdInfo();
377
- _statusCache = {
378
- agents: getAgents(),
379
- prdProgress: prdInfo.progress,
380
- inbox: getInbox(),
381
- notes: getNotesWithMeta(),
382
- prd: prdInfo.status,
383
- pullRequests: getPullRequests(),
384
- verifyGuides: getVerifyGuides(),
385
- archivedPrds: getArchivedPrds(),
386
- engine: { ...getEngineState(), worktreeCount: _countWorktrees() },
387
- adoThrottle: ado.getAdoThrottleState(),
388
- ghThrottle: gh.getGhThrottleState(),
389
- dispatch: getDispatchQueue(),
390
- engineLog: getEngineLog(),
391
- metrics: getMetrics(),
392
- workItems: getWorkItems(),
393
- skills: getSkills(),
394
- mcpServers: getMcpServers(),
395
- schedules: (() => {
396
- const scheds = CONFIG.schedules || [];
397
- const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
398
- return scheds.map(s => {
399
- const runEntry = runs[s.id];
400
- // Backward compat: runEntry can be a string (old format) or object (new format with back-references)
401
- const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
402
- const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
403
- return { ...s, _lastRun, ...extra };
404
- });
405
- })(),
406
- watches: watchesMod.getWatches(),
407
- meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
408
- pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
409
- pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
410
- projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
411
- autoMode: {
412
- approvePlans: !!CONFIG.engine?.autoApprovePlans,
413
- decompose: CONFIG.engine?.autoDecompose !== false,
414
- tempAgents: !!CONFIG.engine?.allowTempAgents,
415
- inboxThreshold: CONFIG.engine?.inboxConsolidateThreshold || shared.ENGINE_DEFAULTS.inboxConsolidateThreshold,
416
- ccModel: CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel,
417
- ccEffort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
418
- },
419
- initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
420
- installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
421
- version: (() => {
422
- const engine = getEngineState();
423
- const { diskVersion, diskCommit, isGitRepo } = getDiskVersion();
424
- const engineStale = !!(engine.codeVersion && diskVersion && engine.codeVersion !== diskVersion) ||
425
- !!(engine.codeCommit && diskCommit && engine.codeCommit !== diskCommit);
426
- const dashboardStale = !!(diskVersion && _dashboardVersion.codeVersion && diskVersion !== _dashboardVersion.codeVersion) ||
427
- !!(diskCommit && _dashboardVersion.codeCommit && diskCommit !== _dashboardVersion.codeCommit);
428
- return {
429
- running: engine.codeVersion || null,
430
- runningCommit: engine.codeCommit || null,
431
- dashboardRunning: _dashboardVersion.codeVersion,
432
- dashboardRunningCommit: _dashboardVersion.codeCommit,
433
- dashboardStartedAt: _dashboardVersion.startedAt,
434
- disk: diskVersion,
435
- diskCommit,
436
- engineStale,
437
- dashboardStale,
438
- stale: engineStale || dashboardStale,
439
- latest: _npmVersionCache?.latest || null,
440
- // Only show "update available" for npm installs (no git repo) — repo users manage their own updates
441
- updateAvailable: !isGitRepo && !!(diskVersion && _npmVersionCache?.latest && _npmVersionCache.latest !== diskVersion && _compareVersions(_npmVersionCache.latest, diskVersion) > 0),
442
- _npmCheckError: _npmVersionCache?.error || null,
443
- };
444
- })(),
445
- timestamp: new Date().toISOString(),
446
- };
447
- _statusCacheTs = now;
396
+ // Slow state: 60s TTL, pure TTL (no mtime check these files change rarely)
397
+ const slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
398
+
399
+ // If nothing stale, return cached merged result
400
+ if (!fastStale && !slowStale && _statusCache) return _statusCache;
401
+
402
+ // Rebuild fast state (frequently-changing data: ~12-15 reads)
403
+ if (fastStale) {
404
+ // Reload config on fast-state miss — picks up external changes (minions init, minions add)
405
+ reloadConfig();
406
+ _fastState = {
407
+ agents: getAgents(),
408
+ inbox: getInbox(),
409
+ notes: getNotesWithMeta(),
410
+ pullRequests: getPullRequests(),
411
+ engine: { ...getEngineState(), worktreeCount: _countWorktrees() },
412
+ adoThrottle: ado.getAdoThrottleState(),
413
+ ghThrottle: gh.getGhThrottleState(),
414
+ dispatch: getDispatchQueue(),
415
+ engineLog: getEngineLog(),
416
+ metrics: getMetrics(),
417
+ workItems: getWorkItems(),
418
+ watches: watchesMod.getWatches(),
419
+ meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
420
+ };
421
+ _fastStateTs = now;
422
+ _lastMtimes = _getMtimes();
423
+ }
424
+
425
+ // Rebuild slow state (rarely-changing data: ~8-15 reads, 60s TTL)
426
+ if (slowStale) {
427
+ const prdInfo = getPrdInfo();
428
+ _slowState = {
429
+ prdProgress: prdInfo.progress,
430
+ prd: prdInfo.status,
431
+ verifyGuides: getVerifyGuides(),
432
+ archivedPrds: getArchivedPrds(),
433
+ skills: getSkills(),
434
+ mcpServers: getMcpServers(),
435
+ schedules: (() => {
436
+ const scheds = CONFIG.schedules || [];
437
+ const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
438
+ return scheds.map(s => {
439
+ const runEntry = runs[s.id];
440
+ // Backward compat: runEntry can be a string (old format) or object (new format with back-references)
441
+ const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
442
+ const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
443
+ return { ...s, _lastRun, ...extra };
444
+ });
445
+ })(),
446
+ pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
447
+ pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
448
+ projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
449
+ autoMode: {
450
+ approvePlans: !!CONFIG.engine?.autoApprovePlans,
451
+ decompose: CONFIG.engine?.autoDecompose !== false,
452
+ tempAgents: !!CONFIG.engine?.allowTempAgents,
453
+ inboxThreshold: CONFIG.engine?.inboxConsolidateThreshold || shared.ENGINE_DEFAULTS.inboxConsolidateThreshold,
454
+ ccModel: CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel,
455
+ ccEffort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
456
+ },
457
+ initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
458
+ installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
459
+ version: (() => {
460
+ const engine = getEngineState();
461
+ const { diskVersion, diskCommit, isGitRepo } = getDiskVersion();
462
+ const engineStale = !!(engine.codeVersion && diskVersion && engine.codeVersion !== diskVersion) ||
463
+ !!(engine.codeCommit && diskCommit && engine.codeCommit !== diskCommit);
464
+ const dashboardStale = !!(diskVersion && _dashboardVersion.codeVersion && diskVersion !== _dashboardVersion.codeVersion) ||
465
+ !!(diskCommit && _dashboardVersion.codeCommit && diskCommit !== _dashboardVersion.codeCommit);
466
+ return {
467
+ running: engine.codeVersion || null,
468
+ runningCommit: engine.codeCommit || null,
469
+ dashboardRunning: _dashboardVersion.codeVersion,
470
+ dashboardRunningCommit: _dashboardVersion.codeCommit,
471
+ dashboardStartedAt: _dashboardVersion.startedAt,
472
+ disk: diskVersion,
473
+ diskCommit,
474
+ engineStale,
475
+ dashboardStale,
476
+ stale: engineStale || dashboardStale,
477
+ latest: _npmVersionCache?.latest || null,
478
+ // Only show "update available" for npm installs (no git repo) — repo users manage their own updates
479
+ updateAvailable: !isGitRepo && !!(diskVersion && _npmVersionCache?.latest && _npmVersionCache.latest !== diskVersion && _compareVersions(_npmVersionCache.latest, diskVersion) > 0),
480
+ _npmCheckError: _npmVersionCache?.error || null,
481
+ };
482
+ })(),
483
+ };
484
+ _slowStateTs = now;
485
+ }
486
+
487
+ // Merge both tiers — no API contract change
488
+ _statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
448
489
  _statusCacheJson = null; // invalidate cached JSON — will be lazily rebuilt by getStatusJson()
449
- _lastMtimes = _getMtimes();
490
+ _statusCacheGzip = null;
450
491
  return _statusCache;
451
492
  }
452
493
 
@@ -455,6 +496,7 @@ function getStatusJson() {
455
496
  getStatus(); // ensure _statusCache is fresh
456
497
  if (!_statusCacheJson) {
457
498
  _statusCacheJson = JSON.stringify(_statusCache);
499
+ _statusCacheGzip = zlib.gzipSync(_statusCacheJson); // pre-compute gzip once per cache rebuild
458
500
  }
459
501
  return _statusCacheJson;
460
502
  }
@@ -463,9 +505,8 @@ function getStatusJson() {
463
505
  setInterval(() => {
464
506
  if (_statusStreamClients.size === 0) return;
465
507
  const data = getStatusJson();
466
- const hash = require('crypto').createHash('md5').update(data).digest('hex');
467
- if (hash === _lastStatusHash) return;
468
- _lastStatusHash = hash;
508
+ if (data === _lastStatusPushRef) return; // O(1) reference comparison — new string ref means content changed
509
+ _lastStatusPushRef = data;
469
510
  for (const res of _statusStreamClients) {
470
511
  try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
471
512
  }
@@ -4222,15 +4263,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4222
4263
 
4223
4264
  async function handleStatus(req, res) {
4224
4265
  try {
4225
- // Use pre-serialized JSON to avoid double-stringify in jsonReply
4266
+ // Use pre-serialized JSON and pre-computed gzip buffer — zero per-request compression
4226
4267
  const json = getStatusJson();
4227
4268
  res.setHeader('Content-Type', 'application/json');
4228
4269
  res.setHeader('Access-Control-Allow-Origin', '*');
4229
4270
  res.statusCode = 200;
4230
4271
  const ae = req && req.headers && req.headers['accept-encoding'] || '';
4231
- if (ae.includes('gzip') && json.length > 1024) {
4272
+ if (ae.includes('gzip') && _statusCacheGzip) {
4232
4273
  res.setHeader('Content-Encoding', 'gzip');
4233
- res.end(zlib.gzipSync(json));
4274
+ res.end(_statusCacheGzip);
4234
4275
  } else {
4235
4276
  res.end(json);
4236
4277
  }
package/engine/cleanup.js CHANGED
@@ -442,22 +442,24 @@ function runCleanup(config, verbose = false) {
442
442
 
443
443
  // 6a. Reconcile failed work items that have an attached PR (#407)
444
444
  // If a work item is 'failed' but already has _pr, it should be 'done'.
445
+ // Uses mutateWorkItems() for locked atomic read-modify-write — prevents
446
+ // race conditions with concurrent engine/dashboard/lifecycle writers.
445
447
  for (const project of projects) {
446
448
  try {
447
449
  const wiPath = projectWorkItemsPath(project);
448
- const items = safeJson(wiPath) || [];
449
450
  let reconciled = 0;
450
- for (const item of items) {
451
- if (item.status === shared.WI_STATUS.FAILED && item._pr) {
452
- item.status = shared.WI_STATUS.DONE;
453
- if (item.failReason) delete item.failReason;
454
- if (item.failedAt) delete item.failedAt;
455
- if (!item.completedAt) item.completedAt = shared.ts();
456
- reconciled++;
451
+ mutateWorkItems(wiPath, items => {
452
+ for (const item of items) {
453
+ if (item.status === shared.WI_STATUS.FAILED && item._pr) {
454
+ item.status = shared.WI_STATUS.DONE;
455
+ if (item.failReason) delete item.failReason;
456
+ if (item.failedAt) delete item.failedAt;
457
+ if (!item.completedAt) item.completedAt = shared.ts();
458
+ reconciled++;
459
+ }
457
460
  }
458
- }
461
+ });
459
462
  if (reconciled > 0) {
460
- safeWrite(wiPath, items);
461
463
  log('info', `Reconciled ${reconciled} failed-with-PR item(s) → done in ${project.name}`);
462
464
  }
463
465
  } catch (e) { log('warn', 'reconcile failed-with-PR: ' + e.message); }
package/engine/shared.js CHANGED
@@ -534,6 +534,7 @@ const ENGINE_DEFAULTS = {
534
534
  worktreeCreateTimeout: 300000, // 5min for git worktree add on large Windows repos
535
535
  worktreeCreateRetries: 1, // retry once on transient timeout/lock races
536
536
  worktreeRoot: '../worktrees',
537
+ worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
537
538
  idleAlertMinutes: 15,
538
539
  fanOutTimeout: null, // falls back to agentTimeout
539
540
  restartGracePeriod: 1200000, // 20min
@@ -554,7 +555,7 @@ const ENGINE_DEFAULTS = {
554
555
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
555
556
  logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
556
557
  logBufferSize: 50, // flush immediately when buffer exceeds this many entries
557
- lockRetries: 2, // retry lock acquisition this many times after initial timeout (total attempts = 1 + lockRetries)
558
+ lockRetries: 0, // no retries single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
558
559
  lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
559
560
  maxBuildFixAttempts: 3, // max consecutive auto-fix dispatch cycles per PR before escalation to human
560
561
  buildFixGracePeriod: 600000, // 10min — wait for CI to run after build fix before re-dispatching
package/engine.js CHANGED
@@ -3229,16 +3229,19 @@ async function tickInner() {
3229
3229
  // Awaited so PR state is consistent before discoverWork reads it
3230
3230
  // Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
3231
3231
  if (tickCount % adoPollStatusEvery === 0 || needsAdoPollRetry()) {
3232
+ // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
3233
+ const statusPolls = [];
3232
3234
  if (adoPollEnabled && !isAdoThrottled()) {
3233
- try { await pollPrStatus(config); } catch (err) { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3235
+ statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3234
3236
  } else if (adoPollEnabled && isAdoThrottled()) {
3235
3237
  log('info', '[ado] PR status poll skipped — throttled');
3236
3238
  }
3237
3239
  if (ghPollEnabled && !isGhThrottled()) {
3238
- try { await ghPollPrStatus(config); } catch (err) { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3240
+ statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3239
3241
  } else if (ghPollEnabled && isGhThrottled()) {
3240
3242
  log('info', '[gh] PR status poll skipped — throttled');
3241
3243
  }
3244
+ if (statusPolls.length) await Promise.allSettled(statusPolls);
3242
3245
  try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
3243
3246
  // Sync PR status back to PRD items (missing → done when active PR exists)
3244
3247
  try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
@@ -3260,19 +3263,25 @@ async function tickInner() {
3260
3263
 
3261
3264
  // 2.7. Poll PR threads for human comments (every adoPollCommentsEvery ticks, default ~12 minutes)
3262
3265
  if (tickCount % adoPollCommentsEvery === 0) {
3266
+ // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
3267
+ const commentPolls = [];
3263
3268
  if (adoPollEnabled && !isAdoThrottled()) {
3264
- try { await pollPrHumanComments(config); } catch (err) { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3269
+ commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3265
3270
  } else if (adoPollEnabled && isAdoThrottled()) {
3266
3271
  log('info', '[ado] PR comment poll skipped — throttled');
3267
3272
  }
3268
3273
  if (ghPollEnabled && !isGhThrottled()) {
3269
- try { await ghPollPrHumanComments(config); } catch (err) { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3274
+ commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3270
3275
  } else if (ghPollEnabled && isGhThrottled()) {
3271
3276
  log('info', '[gh] PR comment poll skipped — throttled');
3272
3277
  }
3278
+ if (commentPolls.length) await Promise.allSettled(commentPolls);
3273
3279
  // Reconciliation runs regardless of poll flags — it's a recovery sweep, not a convenience poll
3274
- try { await reconcilePrs(config); } catch (err) { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3275
- try { await ghReconcilePrs(config); } catch (err) { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3280
+ // Reconciliation also parallelized ADO and GitHub reconciliation are independent
3281
+ const reconcilePolls = [];
3282
+ reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3283
+ reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
3284
+ await Promise.allSettled(reconcilePolls);
3276
3285
  }
3277
3286
 
3278
3287
  // 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (every 20 ticks = ~10 min)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1026",
3
+ "version": "0.1.1028",
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"