@yemi33/minions 0.1.2131 → 0.1.2133

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.
@@ -7,6 +7,27 @@ let _steerInFlight = false;
7
7
  let _lastRenderedText = '';
8
8
  let _runtimeTimer = null;
9
9
 
10
+ // W-mq1jbd8z0008dfa0 — scrollback state. The tail-region wrapper keeps
11
+ // prepended scrollback chunks safe when the 3s poll re-renders the tail.
12
+ //
13
+ // _oldestLoadedByte — byte offset of the OLDEST bytes currently rendered in
14
+ // #live-messages (anything older lives on disk).
15
+ // _logFileSize — most recent X-Log-Size value from the server. Used to
16
+ // seed _oldestLoadedByte on the first tail poll.
17
+ // _scrollbackFetchInFlight — guard so the scroll handler can't fire concurrent
18
+ // pagination requests.
19
+ // _tailRegionAgentId — agentId the current tail region belongs to. Switching
20
+ // agents resets all scrollback state so chunks from a
21
+ // previous agent never bleed into the new tail.
22
+ let _oldestLoadedByte = 0;
23
+ let _logFileSize = 0;
24
+ let _scrollbackFetchInFlight = false;
25
+ let _tailRegionAgentId = null;
26
+
27
+ const SCROLLBACK_CHUNK = 65536; // 64KB per scroll-up
28
+ const SCROLLBACK_TRIGGER_PX = 200; // load older content when within 200px of top
29
+ const TAIL_REGION_ID = 'live-tail-region';
30
+
10
31
  function _currentAgentRuntime() {
11
32
  var agent = (agentData || []).find(function(a) { return a.id === currentAgentId; });
12
33
  return agent && agent.runtime ? agent.runtime : '';
@@ -25,16 +46,34 @@ function _updateRuntimeCounter() {
25
46
  el.textContent = (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
26
47
  }
27
48
 
49
+ // Resolve (or lazily create) the inner tail-region wrapper. All tail-poll
50
+ // renders write into this region; scrollback chunks live OUTSIDE it (above),
51
+ // so a tail re-render never clobbers the prepended scrollback.
52
+ function _ensureTailRegion() {
53
+ const container = document.getElementById('live-messages');
54
+ if (!container) return null;
55
+ let region = document.getElementById(TAIL_REGION_ID);
56
+ if (!region) {
57
+ region = document.createElement('div');
58
+ region.id = TAIL_REGION_ID;
59
+ region.style.display = 'contents'; // transparent flex container
60
+ container.appendChild(region);
61
+ }
62
+ return region;
63
+ }
64
+
28
65
  function renderLiveChatMessage(raw) {
29
- const el = document.getElementById('live-messages');
30
- if (!el) return;
66
+ const container = document.getElementById('live-messages');
67
+ if (!container) return;
68
+ const region = _ensureTailRegion();
69
+ if (!region) return;
31
70
  const html = renderAgentOutput(raw);
32
71
  // eslint-disable-next-line no-unsanitized/method -- reason: renderAgentOutput() escapes all user-controlled fields before assembling HTML (see dashboard/js/render-utils.js)
33
- if (html) el.insertAdjacentHTML('beforeend', html);
72
+ if (html) region.insertAdjacentHTML('beforeend', html);
34
73
 
35
74
  // Auto-scroll
36
- if (el.scrollHeight - el.scrollTop - el.clientHeight < 150) {
37
- el.scrollTop = el.scrollHeight;
75
+ if (container.scrollHeight - container.scrollTop - container.clientHeight < 150) {
76
+ container.scrollTop = container.scrollHeight;
38
77
  }
39
78
  }
40
79
 
@@ -45,6 +84,12 @@ function startLiveStream(agentId) {
45
84
  const msgEl = document.getElementById('live-messages');
46
85
  if (msgEl) msgEl.innerHTML = '';
47
86
  _lastRenderedText = '';
87
+ _oldestLoadedByte = 0;
88
+ _logFileSize = 0;
89
+ _scrollbackFetchInFlight = false;
90
+ _tailRegionAgentId = agentId;
91
+ _attachScrollHandler();
92
+ _updateJumpToLatestButton();
48
93
 
49
94
  // W-mpob4nyk0006580e — clear any stale banner from a previous agent.
50
95
  const bannerEl = document.getElementById('live-terminal-banner');
@@ -123,30 +168,156 @@ async function refreshLiveOutput() {
123
168
  if (!currentAgentId || currentTab !== 'live') { stopLivePolling(); return; }
124
169
  if (_steerInFlight) return; // Don't clobber immediate steering feedback
125
170
  try {
126
- const text = await safeFetch('/api/agent/' + currentAgentId + '/live?tail=16384').then(r => r.text());
171
+ const r = await safeFetch('/api/agent/' + currentAgentId + '/live?tail=16384');
172
+ const text = await r.text();
173
+ // X-Log-Size lets us seed _oldestLoadedByte on the first tick + know
174
+ // whether more older content exists above what's currently rendered.
175
+ const logSizeHeader = r.headers.get('X-Log-Size');
176
+ if (logSizeHeader != null) _logFileSize = parseInt(logSizeHeader, 10) || 0;
127
177
  const el = document.getElementById('live-messages');
128
- if (el) {
129
- const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
130
- const savedScrollTop = el.scrollTop;
131
- const savedScrollLeft = el.scrollLeft;
132
- const incrementalSafe = _currentAgentRuntime() !== 'copilot';
133
- // Incremental render: only parse new content if text is an extension of previous
134
- if (incrementalSafe && _lastRenderedText && text.length > _lastRenderedText.length && text.startsWith(_lastRenderedText.slice(0, 200))) {
135
- renderLiveChatMessage(text.slice(_lastRenderedText.length));
136
- } else {
137
- el.innerHTML = '';
138
- renderLiveChatMessage(text);
139
- }
140
- _lastRenderedText = text;
141
- if (wasAtBottom) el.scrollTop = el.scrollHeight;
142
- else {
143
- el.scrollTop = savedScrollTop;
144
- el.scrollLeft = savedScrollLeft;
145
- }
178
+ if (!el) return;
179
+ const region = _ensureTailRegion();
180
+ if (!region) return;
181
+ // Seed _oldestLoadedByte the first time we have a server file-size.
182
+ // The tail fetch covers the last min(16384, _logFileSize) bytes, so the
183
+ // oldest byte currently rendered is that lower bound.
184
+ if (_logFileSize > 0 && _oldestLoadedByte === 0 && _lastRenderedText === '') {
185
+ _oldestLoadedByte = Math.max(0, _logFileSize - 16384);
146
186
  }
187
+ const wasAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
188
+ const savedScrollTop = el.scrollTop;
189
+ const savedScrollLeft = el.scrollLeft;
190
+ const incrementalSafe = _currentAgentRuntime() !== 'copilot';
191
+ // Incremental render: only parse new content if text is an extension of previous
192
+ if (incrementalSafe && _lastRenderedText && text.length > _lastRenderedText.length && text.startsWith(_lastRenderedText.slice(0, 200))) {
193
+ renderLiveChatMessage(text.slice(_lastRenderedText.length));
194
+ } else {
195
+ // Clear the tail region only (NOT the whole #live-messages) so
196
+ // scrollback chunks prepended above the region survive the rerender.
197
+ region.innerHTML = '';
198
+ renderLiveChatMessage(text);
199
+ }
200
+ _lastRenderedText = text;
201
+ if (wasAtBottom) el.scrollTop = el.scrollHeight;
202
+ else {
203
+ el.scrollTop = savedScrollTop;
204
+ el.scrollLeft = savedScrollLeft;
205
+ }
206
+ _updateJumpToLatestButton();
147
207
  } catch (e) { console.error('live-stream reload:', e.message); }
148
208
  }
149
209
 
210
+ // W-mq1jbd8z0008dfa0 — scrollback: load older transcript content when the
211
+ // user scrolls near the top.
212
+ async function _loadOlderScrollback() {
213
+ if (_scrollbackFetchInFlight) return;
214
+ if (_oldestLoadedByte <= 0) return;
215
+ if (!currentAgentId || _tailRegionAgentId !== currentAgentId) return;
216
+ _scrollbackFetchInFlight = true;
217
+ const container = document.getElementById('live-messages');
218
+ if (!container) { _scrollbackFetchInFlight = false; return; }
219
+ // Loading indicator at the top — structural HTML string literal, no user
220
+ // data interpolated, so lint passes without a disable directive.
221
+ const loadingId = '_scrollback-loading';
222
+ container.insertAdjacentHTML('afterbegin', '<div id="' + loadingId + '" style="padding:4px 8px;color:var(--muted);font-size:var(--text-sm);font-style:italic;text-align:center">Loading older\u2026</div>');
223
+ const from = Math.max(0, _oldestLoadedByte - SCROLLBACK_CHUNK);
224
+ const to = _oldestLoadedByte;
225
+ try {
226
+ const r = await safeFetch('/api/agent/' + currentAgentId + '/live-output?from=' + from + '&to=' + to);
227
+ const text = await r.text();
228
+ const logSizeHeader = r.headers.get('X-Log-Size');
229
+ if (logSizeHeader != null) _logFileSize = parseInt(logSizeHeader, 10) || _logFileSize;
230
+ const html = renderAgentOutput(text);
231
+ // Capture scrollHeight BEFORE prepend so we can restore the user's visible
232
+ // position afterward — otherwise the scrollbar jumps as content stacks up.
233
+ const savedScrollHeight = container.scrollHeight;
234
+ const savedScrollTop = container.scrollTop;
235
+ // Remove the loading indicator
236
+ const loadingEl = document.getElementById(loadingId);
237
+ if (loadingEl) loadingEl.remove();
238
+ if (html) {
239
+ // eslint-disable-next-line no-unsanitized/method -- reason: renderAgentOutput() escapes all user-controlled fields before assembling HTML
240
+ container.insertAdjacentHTML('afterbegin', html);
241
+ }
242
+ // Anchor the visible position so prepending doesn't visually jump the user.
243
+ container.scrollTop = savedScrollTop + (container.scrollHeight - savedScrollHeight);
244
+ _oldestLoadedByte = from;
245
+ } catch (e) {
246
+ console.error('scrollback fetch:', e.message);
247
+ const loadingEl = document.getElementById(loadingId);
248
+ if (loadingEl) loadingEl.remove();
249
+ } finally {
250
+ _scrollbackFetchInFlight = false;
251
+ }
252
+ }
253
+
254
+ // Wire scroll handler + "Jump to latest" button. Idempotent: re-attaches
255
+ // when the live tab re-renders the container.
256
+ function _attachScrollHandler() {
257
+ const container = document.getElementById('live-messages');
258
+ if (!container || container.dataset.scrollbackHooked === '1') return;
259
+ container.dataset.scrollbackHooked = '1';
260
+ container.addEventListener('scroll', function() {
261
+ // Load older content when within 200px of the top.
262
+ if (container.scrollTop <= SCROLLBACK_TRIGGER_PX && _oldestLoadedByte > 0 && !_scrollbackFetchInFlight) {
263
+ _loadOlderScrollback();
264
+ }
265
+ _updateJumpToLatestButton();
266
+ });
267
+ }
268
+
269
+ function _ensureJumpToLatestButton() {
270
+ // The button is positioned absolute inside #live-chat (already
271
+ // position:relative-friendly because of its flex column layout). Inject
272
+ // once; #live-chat is re-rendered on tab switch, which removes it, so
273
+ // this function lazy-creates as needed.
274
+ const liveChat = document.getElementById('live-chat');
275
+ if (!liveChat) return null;
276
+ // Ensure positioning context for absolute child.
277
+ if (liveChat.style.position !== 'relative' && getComputedStyle(liveChat).position === 'static') {
278
+ liveChat.style.position = 'relative';
279
+ }
280
+ let btn = document.getElementById('live-jump-latest');
281
+ if (!btn) {
282
+ btn = document.createElement('button');
283
+ btn.id = 'live-jump-latest';
284
+ btn.type = 'button';
285
+ btn.textContent = '\u2193 Jump to latest';
286
+ btn.style.cssText = [
287
+ 'position:absolute',
288
+ 'right:16px',
289
+ 'bottom:96px',
290
+ 'padding:6px 12px',
291
+ 'background:var(--blue)',
292
+ 'color:#fff',
293
+ 'border:none',
294
+ 'border-radius:var(--radius-sm)',
295
+ 'font-size:var(--text-sm)',
296
+ 'cursor:pointer',
297
+ 'box-shadow:0 2px 6px rgba(0,0,0,0.25)',
298
+ 'z-index:5',
299
+ 'display:none',
300
+ ].join(';');
301
+ btn.addEventListener('click', function() {
302
+ const container = document.getElementById('live-messages');
303
+ if (!container) return;
304
+ container.scrollTop = container.scrollHeight;
305
+ _updateJumpToLatestButton();
306
+ });
307
+ liveChat.appendChild(btn);
308
+ }
309
+ return btn;
310
+ }
311
+
312
+ function _updateJumpToLatestButton() {
313
+ const container = document.getElementById('live-messages');
314
+ if (!container) return;
315
+ const btn = _ensureJumpToLatestButton();
316
+ if (!btn) return;
317
+ const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
318
+ btn.style.display = atBottom ? 'none' : 'block';
319
+ }
320
+
150
321
  async function sendSteering() {
151
322
  const input = document.getElementById('live-steer-input');
152
323
  if (!input || !input.value.trim() || !currentAgentId) return;
@@ -156,13 +327,15 @@ async function sendSteering() {
156
327
  // Pause polling so the immediate feedback isn't clobbered
157
328
  _steerInFlight = true;
158
329
 
159
- // Immediate feedback — show the message right away
160
- const el = document.getElementById('live-messages');
161
- if (el) {
330
+ // Immediate feedback — show the message right away. Render INTO the tail
331
+ // region so a subsequent scrollback prepend stays above the steering bubble.
332
+ const region = _ensureTailRegion();
333
+ const container = document.getElementById('live-messages');
334
+ if (region) {
162
335
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: steering message)
163
- el.insertAdjacentHTML('beforeend', '<div style="align-self:flex-end;background:var(--blue);color:#fff;padding:6px 12px;border-radius:12px 12px 2px 12px;max-width:80%;margin:4px 0;font-size:var(--text-md)">' + escHtml(message) +
336
+ region.insertAdjacentHTML('beforeend', '<div style="align-self:flex-end;background:var(--blue);color:#fff;padding:6px 12px;border-radius:12px 12px 2px 12px;max-width:80%;margin:4px 0;font-size:var(--text-md)">' + escHtml(message) +
164
337
  '<div id="steer-pending" style="font-size:var(--text-xs);opacity:0.7;margin-top:2px">\u2197 Sending...</div></div>');
165
- el.scrollTop = el.scrollHeight;
338
+ if (container) container.scrollTop = container.scrollHeight;
166
339
  }
167
340
  showToast('cmd-toast', 'Steering message sent to ' + currentAgentId, true);
168
341
 
@@ -503,7 +503,7 @@ function _watchFormHtml() {
503
503
  '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
504
504
  '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
505
505
  '<span>Follow-up Action(s) <span style="font-size:var(--text-sm);color:var(--muted)">(runs after the inbox notification when the watch fires; 1 step = single, 2+ = chain)</span></span>' +
506
- '<button type="button" class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_addWatchStepRow()">+ Add step</button>' +
506
+ '<button type="button" class="btn-add btn-add-lg" onclick="_addWatchStepRow()">+ Add step</button>' +
507
507
  '</div>' +
508
508
  '<div id="watch-edit-steps"></div>' +
509
509
  '</div>' +
@@ -511,7 +511,7 @@ function _watchFormHtml() {
511
511
  '<div style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px;display:flex;flex-direction:column;gap:8px">' +
512
512
  '<div style="color:var(--text);font-size:var(--text-md);display:flex;align-items:center;justify-content:space-between">' +
513
513
  '<span>Requires <span style="font-size:var(--text-sm);color:var(--muted)">(cross-target AND-join — every requirement must also be true to fire)</span></span>' +
514
- '<button type="button" class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_addRequireRow()">+ Add requirement</button>' +
514
+ '<button type="button" class="btn-add btn-add-lg" onclick="_addRequireRow()">+ Add requirement</button>' +
515
515
  '</div>' +
516
516
  '<div id="watch-edit-requires"></div>' +
517
517
  '</div>' +
@@ -12,7 +12,7 @@
12
12
  <span style="color:var(--blue);font-weight:600">Command Center</span>
13
13
  <span id="cmd-powered-by">Ask anything, dispatch work, manage plans</span>
14
14
  <button class="cmd-history-btn" onclick="cmdShowHistory()">Past Commands</button>
15
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="openQuickNoteModal()">+ Note</button>
15
+ <button class="btn-add btn-add-lg" onclick="openQuickNoteModal()">+ Note</button>
16
16
  </div>
17
17
  </section>
18
18
  <section>
@@ -20,7 +20,7 @@
20
20
  <div class="agents" id="agents-grid">Loading...</div>
21
21
  </section>
22
22
  <section>
23
- <h2>Pinned Context <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openPinNoteModal()">+ Pin</button> <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">injected into all agent prompts</span></h2>
23
+ <h2>Pinned Context <button class="btn-add" style="margin-left:8px" onclick="openPinNoteModal()">+ Pin</button> <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">injected into all agent prompts</span></h2>
24
24
  <div id="pinned-content"><p class="empty">No pinned notes.</p></div>
25
25
  </section>
26
26
  <section>
@@ -1,5 +1,5 @@
1
1
  <section>
2
- <h2>Notes Inbox <span class="count" id="inbox-count">0</span> <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openQuickNoteModal()">+ Note</button> <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">auto-consolidates at <span id="inbox-threshold">3</span> notes</span></h2>
2
+ <h2>Notes Inbox <span class="count" id="inbox-count">0</span> <button class="btn-add btn-add-lg" style="margin-left:8px" onclick="openQuickNoteModal()">+ Note</button> <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">auto-consolidates at <span id="inbox-threshold">3</span> notes</span></h2>
3
3
  <div class="inbox-list" id="inbox-list">Loading...</div>
4
4
  </section>
5
5
  <section>
@@ -7,7 +7,7 @@
7
7
  <div id="notes-list">Loading...</div>
8
8
  </section>
9
9
  <section>
10
- <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateKbModal()">+ New</button><button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:var(--text-xs);padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
10
+ <h2>Knowledge Base <span class="count" id="kb-count">0</span> <button class="btn-add" style="margin-left:8px" onclick="openCreateKbModal()">+ New</button><button id="kb-sweep-btn" onclick="kbSweep()" style="font-size:var(--text-xs);padding:2px 8px;background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:4px;cursor:pointer;margin-left:8px;vertical-align:middle">sweep</button><span id="kb-swept-time" style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;margin-left:8px"></span></h2>
11
11
  <div id="kb-sweep-toast" class="cmd-toast cmd-toast-inline" style="margin:6px 0"></div>
12
12
  <div class="kb-tabs" id="kb-tabs"></div>
13
13
  <div class="kb-list" id="kb-list"><p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section>
2
2
  <h2>Team Meetings <span class="count" id="meetings-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateMeetingModal()">+ New Meeting</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreateMeetingModal()">+ New Meeting</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">multi-round agent discussions — investigate, debate, conclude</span>
5
5
  </h2>
6
6
  <div id="meetings-content"><p class="empty">No meetings yet. Start one to have agents investigate, debate, and conclude on a topic.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section>
2
2
  <h2>Pipelines <span class="count" id="pipelines-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreatePipelineModal()">+ New Pipeline</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreatePipelineModal()">+ New Pipeline</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">multi-stage workflows with dependencies — chain meetings, plans, tasks, merges in any order, on a schedule or manual</span>
5
5
  </h2>
6
6
  <div id="pipelines-content"><p class="empty">No pipelines yet. Create one to chain stages like audit → meeting → plan → merge.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section>
2
2
  <h2>Plans <span class="count" id="plans-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreatePlanModal()">+ New Plan</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreatePlanModal()">+ New Plan</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">create via CC or + New Plan, then execute to generate a PRD</span>
5
5
  </h2>
6
6
  <div id="plans-list"><p class="empty">No plans yet. Create one via the command center or click + New Plan.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section class="pr-panel" id="pr-section">
2
2
  <h2>Pull Requests <span class="count" id="pr-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openAddPrModal()">+ Link PR</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openAddPrModal()">+ Link PR</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">created by agents, tracked with review + build status</span>
5
5
  </h2>
6
6
  <div id="pr-toast" class="cmd-toast cmd-toast-inline" style="margin:6px 0"></div>
@@ -1,6 +1,6 @@
1
1
  <section id="scheduled-section">
2
2
  <h2>Scheduled Tasks <span class="count" id="scheduled-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateScheduleModal()">+ New</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreateScheduleModal()">+ New</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">single recurring tasks on a cron — for multi-step workflows, use Pipelines</span>
5
5
  </h2>
6
6
  <div id="scheduled-content"><p class="empty">No scheduled tasks. Add one to automate recurring work.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section id="watches-section">
2
2
  <h2>Watches <span class="count" id="watches-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateWatchModal()">+ New</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreateWatchModal()">+ New</button>
4
4
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">persistent watches that monitor PRs, work items, and branches for changes</span>
5
5
  </h2>
6
6
  <div id="watches-content"><p class="empty">No active watches. Create one to monitor PRs, work items, or branches.</p></div>
@@ -1,6 +1,6 @@
1
1
  <section id="work-items-section" style="overflow:visible">
2
2
  <h2>Work Items <span class="count" id="wi-count">0</span>
3
- <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:1px 6px;color:var(--green);border-color:var(--green);margin-left:8px" onclick="openCreateWorkItemModal()">+ New</button>
3
+ <button class="btn-add" style="margin-left:8px" onclick="openCreateWorkItemModal()">+ New</button>
4
4
  <button class="pr-pager-btn" style="font-size:var(--text-xs);padding:2px 8px;margin-left:4px" onclick="toggleWorkItemArchive()">See Archive</button>
5
5
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">tasks dispatched to agents — auto-created from PRDs or added manually</span>
6
6
  </h2>
@@ -426,6 +426,34 @@
426
426
  .btn-danger { color: var(--red); border-color: var(--red); }
427
427
  .btn-warning { color: var(--yellow); border-color: var(--yellow); }
428
428
 
429
+ /* W-mq10dtaw000l7ad5 — "+ Add" / "+ New" button family. Single source of
430
+ truth for the green-bordered, green-text, "+" buttons used across the
431
+ dashboard: section-header creators (work, plans, prs, knowledge, inbox,
432
+ schedules, pipelines, watches, meetings, home pin/note) AND inline
433
+ form-row adders ("+ Add step", "+ Add requirement"). Change the green
434
+ hex / border / padding / hover once here and every "+ X" button updates.
435
+ Variant:
436
+ .btn-add — compact text-xs / 1px 6px (section-header default)
437
+ .btn-add-lg — text-sm / 2px 8px (form rows + larger header buttons)
438
+ Positional spacing (e.g. margin-left:8px after an h2 count) lives at
439
+ the callsite, not on the button itself. Do NOT re-create the green
440
+ inline style for a new "+ X" button — extend this primitive instead. */
441
+ .btn-add {
442
+ background: var(--surface2);
443
+ border: 1px solid var(--green);
444
+ color: var(--green);
445
+ font-size: var(--text-xs);
446
+ padding: 1px 6px;
447
+ border-radius: var(--radius-sm);
448
+ cursor: pointer;
449
+ transition: background var(--transition-base);
450
+ }
451
+ .btn-add:hover { background: rgba(63,185,80,0.1); }
452
+ .btn-add:focus-visible { outline: 2px solid var(--green); outline-offset: 1px; }
453
+ .btn-add:disabled,
454
+ .btn-add.disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
455
+ .btn-add.btn-add-lg { font-size: var(--text-sm); padding: 2px 8px; }
456
+
429
457
  /* Badge — unified badge/pill base */
430
458
  .badge {
431
459
  font-size: var(--text-sm); font-weight: 600;
package/dashboard.js CHANGED
@@ -6132,12 +6132,69 @@ const server = http.createServer(async (req, res) => {
6132
6132
  const prevPath = path.join(agentDir, 'live-output-prev.log');
6133
6133
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
6134
6134
  const params = new URL(req.url, 'http://localhost').searchParams;
6135
+
6136
+ // W-mq1jbd8z0008dfa0 — scrollback support. When `from`/`to` are supplied,
6137
+ // serve an explicit byte range from live-output.log (or live-output-prev.log
6138
+ // when `prev=1` is set) instead of the tail window. The client uses this to
6139
+ // load progressively older transcript content as the user scrolls up.
6140
+ // `X-Log-Size` is always emitted (for both tail and range responses) so the
6141
+ // client knows whether more older content exists above its current top.
6142
+ const MAX_RANGE_BYTES = 524288; // 512KB hard cap per request
6143
+ const prevOnly = params.get('prev') === '1';
6144
+ const hasRange = params.has('from') || params.has('to');
6145
+
6146
+ if (hasRange) {
6147
+ if (!params.has('from') || !params.has('to')) {
6148
+ return jsonReply(res, 400, { error: 'from and to must both be provided for a range read' });
6149
+ }
6150
+ const rawFrom = params.get('from');
6151
+ const rawTo = params.get('to');
6152
+ if (!/^\d+$/.test(rawFrom) || !/^\d+$/.test(rawTo)) {
6153
+ return jsonReply(res, 400, { error: 'from and to must be non-negative integers' });
6154
+ }
6155
+ const from = parseInt(rawFrom, 10);
6156
+ const to = parseInt(rawTo, 10);
6157
+ if (!(from < to)) {
6158
+ return jsonReply(res, 400, { error: 'from must be less than to' });
6159
+ }
6160
+ if ((to - from) > MAX_RANGE_BYTES) {
6161
+ return jsonReply(res, 400, { error: `range exceeds MAX_RANGE_BYTES (${MAX_RANGE_BYTES})` });
6162
+ }
6163
+ const targetPath = prevOnly ? prevPath : livePath;
6164
+ try {
6165
+ const stat = fs.statSync(targetPath);
6166
+ res.setHeader('X-Log-Size', String(stat.size));
6167
+ res.setHeader('X-Log-Truncated', 'false');
6168
+ // Past EOF — return empty body (NOT 416, the client polls and 416s
6169
+ // would spam the console).
6170
+ if (from >= stat.size) { res.end(''); return; }
6171
+ const clampedTo = Math.min(to, stat.size);
6172
+ const length = clampedTo - from;
6173
+ const buf = Buffer.alloc(length);
6174
+ const fd = fs.openSync(targetPath, 'r');
6175
+ try {
6176
+ fs.readSync(fd, buf, 0, length, from);
6177
+ } finally {
6178
+ fs.closeSync(fd);
6179
+ }
6180
+ res.end(buf.toString('utf8'));
6181
+ } catch {
6182
+ // File missing — fail soft to match the tail path's UX.
6183
+ res.setHeader('X-Log-Size', '0');
6184
+ res.setHeader('X-Log-Truncated', 'false');
6185
+ res.end('');
6186
+ }
6187
+ return;
6188
+ }
6189
+
6135
6190
  const rawTail = parseInt(params.get('tail'));
6136
6191
  if (params.has('tail') && isNaN(rawTail)) return jsonReply(res, 400, { error: 'tail must be a number' });
6137
6192
  const tailBytes = isNaN(rawTail) ? 8192 : Math.max(1, Math.min(65536, rawTail));
6138
6193
  // Read only the tail bytes from disk instead of entire file
6139
6194
  try {
6140
6195
  const stat = fs.statSync(livePath);
6196
+ res.setHeader('X-Log-Size', String(stat.size));
6197
+ res.setHeader('X-Log-Truncated', 'false');
6141
6198
  if (stat.size === 0) { res.end('No live output. Agent may not be running.'); return; }
6142
6199
 
6143
6200
  // Fall back to previous session log when current is sparse (fixes #543)
@@ -6173,6 +6230,8 @@ const server = http.createServer(async (req, res) => {
6173
6230
  if (fs.existsSync(prevPath)) {
6174
6231
  const prevStat = fs.statSync(prevPath);
6175
6232
  if (prevStat.size > 0) {
6233
+ res.setHeader('X-Log-Size', String(prevStat.size));
6234
+ res.setHeader('X-Log-Truncated', 'false');
6176
6235
  const start = Math.max(0, prevStat.size - tailBytes);
6177
6236
  const buf = Buffer.alloc(Math.min(tailBytes, prevStat.size));
6178
6237
  const fd = fs.openSync(prevPath, 'r');
@@ -6183,6 +6242,8 @@ const server = http.createServer(async (req, res) => {
6183
6242
  }
6184
6243
  }
6185
6244
  } catch { /* fall through */ }
6245
+ if (!res.getHeader('X-Log-Size')) res.setHeader('X-Log-Size', '0');
6246
+ if (!res.getHeader('X-Log-Truncated')) res.setHeader('X-Log-Truncated', 'false');
6186
6247
  res.end('No live output. Agent may not be running.');
6187
6248
  }
6188
6249
  return;
@@ -11566,8 +11627,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11566
11627
  { method: 'POST', path: '/api/agents/cancel', desc: 'Cancel an active agent by ID or task substring', params: 'agent? or agentId?, task?', handler: handleAgentsCancel },
11567
11628
  { method: 'POST', path: /^\/api\/agent\/([\w-]+)\/kill$/, template: '/api/agent/:id/kill', desc: 'Kill a running agent: stop process, clear dispatch, reset work items to pending', handler: handleAgentKill },
11568
11629
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-stream(?:\?.*)?$/, template: '/api/agent/:id/live-stream', desc: 'SSE real-time live output streaming', handler: handleAgentLiveStream },
11569
- { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, template: '/api/agent/:id/live', desc: 'Tail live output for a working agent', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
11570
- { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-output(?:\?.*)?$/, template: '/api/agent/:id/live-output', desc: 'Tail live output for a working agent (alias for /live)', params: 'tail? (bytes, default 8192)', handler: handleAgentLive },
11630
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live(?:\?.*)?$/, template: '/api/agent/:id/live', desc: 'Tail or range-read live output for a working agent', params: 'tail? (bytes, default 8192) | from?+to? (byte range, max 524288) | prev?=1 (read live-output-prev.log)', handler: handleAgentLive },
11631
+ { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/live-output(?:\?.*)?$/, template: '/api/agent/:id/live-output', desc: 'Tail or range-read live output for a working agent (alias for /live)', params: 'tail? (bytes, default 8192) | from?+to? (byte range, max 524288) | prev?=1 (read live-output-prev.log)', handler: handleAgentLive },
11571
11632
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)\/output(?:\?.*)?$/, template: '/api/agent/:id/output', desc: 'Fetch final output.log for an agent', handler: handleAgentOutput },
11572
11633
  { method: 'GET', path: /^\/api\/agent\/([\w-]+)$/, template: '/api/agent/:id', desc: 'Get detailed agent info', handler: handleAgentDetail },
11573
11634
  { method: 'GET', path: /^\/api\/dispatch\/([\w.-]+)\/completion-report$/, template: '/api/dispatch/:id/completion-report', desc: 'Read structured completion report for a dispatch', handler: (req, res, match) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2131",
3
+ "version": "0.1.2133",
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"