@yemi33/minions 0.1.2305 → 0.1.2306

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.
@@ -134,7 +134,8 @@ const RENDER_VERSIONS = {
134
134
  prdProgress: 2,
135
135
  prdPrs: 1,
136
136
  inbox: 2,
137
- projects: 4,
137
+ // Bumped 4→5 for the clickable checkout-mode pill + picker (W-mr1b67zi0006b788).
138
+ projects: 5,
138
139
  notes: 1,
139
140
  prd: 3,
140
141
  prs: 3,
@@ -147,7 +148,7 @@ const RENDER_VERSIONS = {
147
148
  dispatch: 2,
148
149
  engineLog: 2,
149
150
  metrics: 1,
150
- workItems: 6,
151
+ workItems: 7,
151
152
  skills: 1,
152
153
  commands: 1,
153
154
  mcpServers: 1,
@@ -1066,8 +1067,17 @@ let _refreshInFlight = false;
1066
1067
  // it's not obvious the dashboard itself is dead (vs. wedged, vs. the
1067
1068
  // engine being down).
1068
1069
  //
1069
- // Trip conditions: 2 consecutive failed polls OR >12 s since the last
1070
- // success (3× the 4 s poll cadence, so a single flaky tick doesn't fire).
1070
+ // Trip conditions (2026-06-30): SUSTAINED failure only the banner must never
1071
+ // flash while the dashboard is merely slow. 3 consecutive failed polls
1072
+ // (timeout/abort or 5xx). At the 4s cadence a wedged dashboard aborts each poll
1073
+ // at safeFetch's 15s timeout, so 3 in a row is ~45s of continuous
1074
+ // unresponsiveness — well beyond the legitimate 15-25s cold getStatus() rebuild
1075
+ // documented in restart-health.js. A truly-dead dashboard still trips promptly
1076
+ // (~12s) because connection-refused rejects fast rather than waiting out the
1077
+ // 15s timeout. The old "> N seconds since last success" OR side was REMOVED:
1078
+ // aborts during a healthy cold rebuild pushed elapsed-time past the budget and
1079
+ // surfaced the banner while the dashboard was alive-but-busy. Consecutive
1080
+ // failures are the only reliable "actually unreachable" signal.
1071
1081
  // On trip we show a sticky red banner with two recovery actions:
1072
1082
  // 1. "Restart Minions" → ccRestartMinions (works when dashboard is
1073
1083
  // alive-but-stale; falls through to copy-to-clipboard when the POST
@@ -1080,14 +1090,7 @@ let _lastStatusOkAt = Date.now();
1080
1090
  let _consecutiveStatusFails = 0;
1081
1091
  let _unreachableSince = 0; // 0 = currently reachable
1082
1092
  let _unreachableAgeTimer = null;
1083
- // 3 + 20s (was 2 + 12s) — the prior thresholds tripped a banner on a single
1084
- // safeFetch abort (timeout 15s in state.js) because the OR'd age side was
1085
- // already satisfied. With safeFetch=15s, the age budget needs >15s to avoid
1086
- // the trip-on-first-slow-response footgun. 3 consecutive fails ≈ 12s of real
1087
- // outage at the 4s poll cadence, which still surfaces the banner promptly
1088
- // during a real dashboard crash but tolerates one slow rebuild + retry.
1089
1093
  const _UNREACHABLE_FAIL_THRESHOLD = 3;
1090
- const _UNREACHABLE_AGE_MS = 20000;
1091
1094
  // Once the banner is up we throttle polls (exponential, capped at 30s) to
1092
1095
  // avoid hammering a struggling dashboard / network. Reset on recovery.
1093
1096
  let _nextPollAllowedAt = 0;
@@ -1345,8 +1348,9 @@ async function refresh(opts) {
1345
1348
  _diagEntry.error_message = String((e && e.message) || e);
1346
1349
  }
1347
1350
  _consecutiveStatusFails++;
1348
- const ageMs = Date.now() - _lastStatusOkAt;
1349
- if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
1351
+ // Sustained failure only see the detector header for why elapsed time
1352
+ // alone must not trip (it flashed the banner on healthy-but-slow rebuilds).
1353
+ if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD) {
1350
1354
  _markDashboardUnreachable(e);
1351
1355
  }
1352
1356
  // Backoff: once we've tripped the banner, throttle subsequent polls
@@ -96,18 +96,266 @@ function _renderProjectBranch(p) {
96
96
  // is set, coding WIs author in isolated worktrees and only the named validation
97
97
  // type runs in-place on the live checkout. This is a materially different
98
98
  // dispatch profile from full-live, so it gets its own pill.
99
+ // W-mr1b67zi0006b788: the pill is clickable — opens an inline picker (see
100
+ // _openCheckoutModeMenu below) to switch between Worktrees / Live checkout /
101
+ // Hybrid dispatch modes without leaving the Projects view. Shared
102
+ // data-checkout-pill / role="button" / tabindex / aria-haspopup attributes
103
+ // are attached to all three pill variants; the delegated listener wired at
104
+ // the bottom of this file reads data-checkout-pill to know which project to
105
+ // open the menu for.
99
106
  function _renderWorktreeModePill(p) {
100
107
  if (!p) return '';
108
+ const name = escHtml(p.name);
109
+ const common = ' data-checkout-pill="' + name + '" role="button" tabindex="0" aria-haspopup="true" aria-label="Change checkout mode"';
101
110
  if (p.checkoutMode === 'live') {
102
111
  if (p.liveValidationType) {
103
112
  const vt = escapeHtml(p.liveValidationType);
104
- return ' <span class="project-mode-pill project-mode-hybrid" title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree)">⚡ Hybrid · ' + vt + '</span>';
113
+ return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
105
114
  }
106
- return ' <span class="project-mode-pill project-mode-live" title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree">⚡ Live checkout</span>';
115
+ return ' <span class="project-mode-pill project-mode-live project-mode-pill-clickable"' + common + ' title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree. Click to change.">⚡ Live checkout</span>';
107
116
  }
108
- return ' <span class="project-mode-pill project-mode-isolated" title="Worktree dispatch mode (default) — each agent runs in its own git worktree">Worktrees</span>';
117
+ return ' <span class="project-mode-pill project-mode-isolated project-mode-pill-clickable"' + common + ' title="Worktree dispatch mode (default) — each agent runs in its own git worktree. Click to change.">Worktrees</span>';
109
118
  }
110
119
 
120
+ // Work-item types eligible for hybrid live-validation (W-mr1b67zi0006b788).
121
+ // Mirrors the set the server documents for project.liveValidation.type;
122
+ // intentionally excludes the illustrative "build-and-test" example from the
123
+ // server comments since that isn't a real WORK_TYPE value.
124
+ const CHECKOUT_MODE_HYBRID_TYPES = ['implement', 'fix', 'docs', 'decompose', 'explore', 'ask', 'review', 'test', 'verify', 'setup'];
125
+ const CHECKOUT_MODE_HYBRID_DEFAULT_TYPE = 'test';
126
+
127
+ function _findProjectInLastStatus(name) {
128
+ const projects = window._lastStatus && window._lastStatus.projects;
129
+ if (!Array.isArray(projects)) return null;
130
+ return projects.find(function(p) { return p.name === name; }) || null;
131
+ }
132
+
133
+ function _closeCheckoutModeMenu() {
134
+ const existing = document.getElementById('checkout-mode-menu');
135
+ if (existing) existing.remove();
136
+ document.removeEventListener('mousedown', _checkoutModeMenuOutsideClick, true);
137
+ document.removeEventListener('keydown', _checkoutModeMenuKeydown, true);
138
+ }
139
+
140
+ function _checkoutModeMenuOutsideClick(e) {
141
+ const menu = document.getElementById('checkout-mode-menu');
142
+ if (!menu) return;
143
+ if (menu.contains(e.target)) return;
144
+ if (e.target.closest && e.target.closest('[data-checkout-pill]')) return;
145
+ _closeCheckoutModeMenu();
146
+ }
147
+
148
+ function _checkoutModeMenuKeydown(e) {
149
+ if (e.key === 'Escape') _closeCheckoutModeMenu();
150
+ }
151
+
152
+ // Builds and positions the checkout-mode picker popover beneath the clicked
153
+ // pill. No innerHTML is used here (createElement/textContent throughout) to
154
+ // stay clear of the innerHTML lint gate (npm run lint).
155
+ function _openCheckoutModeMenu(projectName, anchorEl) {
156
+ _closeCheckoutModeMenu();
157
+ const project = _findProjectInLastStatus(projectName);
158
+ if (!project) return;
159
+
160
+ const menu = document.createElement('div');
161
+ menu.id = 'checkout-mode-menu';
162
+ menu.className = 'checkout-mode-menu';
163
+ menu.setAttribute('role', 'menu');
164
+
165
+ const title = document.createElement('div');
166
+ title.className = 'checkout-mode-menu-title';
167
+ title.textContent = 'Checkout mode — ' + projectName;
168
+ menu.appendChild(title);
169
+
170
+ function addItem(labelText, mode, descText) {
171
+ const item = document.createElement('div');
172
+ item.className = 'checkout-mode-menu-item';
173
+ item.setAttribute('role', 'menuitemradio');
174
+ item.tabIndex = 0;
175
+ const label = document.createElement('div');
176
+ label.className = 'checkout-mode-menu-item-label';
177
+ label.textContent = labelText;
178
+ item.appendChild(label);
179
+ if (descText) {
180
+ const desc = document.createElement('div');
181
+ desc.className = 'checkout-mode-menu-item-desc';
182
+ desc.textContent = descText;
183
+ item.appendChild(desc);
184
+ }
185
+ const isActive = (mode === 'worktree' && project.checkoutMode !== 'live')
186
+ || (mode === 'live' && project.checkoutMode === 'live' && !project.liveValidationType)
187
+ || (mode === 'hybrid' && project.checkoutMode === 'live' && !!project.liveValidationType);
188
+ if (isActive) {
189
+ item.setAttribute('aria-checked', 'true');
190
+ item.classList.add('checkout-mode-menu-item-active');
191
+ } else {
192
+ item.setAttribute('aria-checked', 'false');
193
+ }
194
+ item.addEventListener('click', function() {
195
+ if (mode === 'hybrid') {
196
+ _renderCheckoutModeHybridStep(menu, projectName);
197
+ return;
198
+ }
199
+ _closeCheckoutModeMenu();
200
+ _applyCheckoutModeChange(projectName, mode === 'live' ? 'live' : 'worktree', null);
201
+ });
202
+ item.addEventListener('keydown', function(e) {
203
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); }
204
+ });
205
+ menu.appendChild(item);
206
+ }
207
+
208
+ addItem('Worktrees (default)', 'worktree', 'Each dispatch runs in its own isolated git worktree. Fully parallel, never touches your working directory.');
209
+ addItem('Live checkout', 'live', 'Agents run in-place in the project folder. One mutating dispatch at a time; refuses to run on a dirty tree.');
210
+ addItem('Hybrid dispatch…', 'hybrid', 'Coding work authors in isolated worktrees; only a chosen validation step (e.g. build-and-test) runs on the real checkout.');
211
+
212
+ document.body.appendChild(menu);
213
+ const rect = anchorEl.getBoundingClientRect();
214
+ menu.style.position = 'absolute';
215
+ menu.style.top = (window.scrollY + rect.bottom + 4) + 'px';
216
+ menu.style.left = (window.scrollX + rect.left) + 'px';
217
+
218
+ document.addEventListener('mousedown', _checkoutModeMenuOutsideClick, true);
219
+ document.addEventListener('keydown', _checkoutModeMenuKeydown, true);
220
+ const first = menu.querySelector('.checkout-mode-menu-item');
221
+ if (first) first.focus();
222
+ }
223
+
224
+ // Second step of the picker for hybrid mode: pick which work-item type runs
225
+ // in-place on the live checkout while everything else authors in isolated
226
+ // worktrees. Renders a <select> + Back/Apply into the existing menu element.
227
+ function _renderCheckoutModeHybridStep(menu, projectName) {
228
+ const project = _findProjectInLastStatus(projectName);
229
+ while (menu.firstChild) menu.removeChild(menu.firstChild);
230
+
231
+ const title = document.createElement('div');
232
+ title.className = 'checkout-mode-menu-title';
233
+ title.textContent = 'Hybrid dispatch — ' + projectName;
234
+ menu.appendChild(title);
235
+
236
+ const label = document.createElement('div');
237
+ label.className = 'checkout-mode-menu-item';
238
+ label.textContent = 'Work-item type to validate live:';
239
+ menu.appendChild(label);
240
+
241
+ const select = document.createElement('select');
242
+ select.className = 'checkout-mode-menu-select';
243
+ CHECKOUT_MODE_HYBRID_TYPES.forEach(function(t) {
244
+ const opt = document.createElement('option');
245
+ opt.value = t;
246
+ opt.textContent = t;
247
+ select.appendChild(opt);
248
+ });
249
+ select.value = (project && project.liveValidationType && CHECKOUT_MODE_HYBRID_TYPES.includes(project.liveValidationType))
250
+ ? project.liveValidationType
251
+ : CHECKOUT_MODE_HYBRID_DEFAULT_TYPE;
252
+ menu.appendChild(select);
253
+
254
+ const actions = document.createElement('div');
255
+ actions.className = 'checkout-mode-menu-actions';
256
+
257
+ const backBtn = document.createElement('button');
258
+ backBtn.type = 'button';
259
+ backBtn.className = 'checkout-mode-menu-btn';
260
+ backBtn.textContent = 'Back';
261
+ backBtn.addEventListener('click', function() {
262
+ const anchor = document.querySelector('[data-checkout-pill="' + CSS.escape(projectName) + '"]');
263
+ _closeCheckoutModeMenu();
264
+ if (anchor) _openCheckoutModeMenu(projectName, anchor);
265
+ });
266
+ actions.appendChild(backBtn);
267
+
268
+ const applyBtn = document.createElement('button');
269
+ applyBtn.type = 'button';
270
+ applyBtn.className = 'checkout-mode-menu-btn checkout-mode-menu-btn-primary';
271
+ applyBtn.textContent = 'Apply';
272
+ applyBtn.addEventListener('click', function() {
273
+ const type = select.value;
274
+ _closeCheckoutModeMenu();
275
+ _applyCheckoutModeChange(projectName, 'live', type);
276
+ });
277
+ actions.appendChild(applyBtn);
278
+
279
+ menu.appendChild(actions);
280
+ }
281
+
282
+ // Applies a checkout-mode change: confirms via confirmDialog(), optimistically
283
+ // flips the cached project entry and re-renders BEFORE awaiting the network
284
+ // call (mirrors projectChipRemove / removePinnedNote), then POSTs to
285
+ // /api/settings. On failure, reverts the optimistic flip, re-renders, and
286
+ // shows an error toast.
287
+ async function _applyCheckoutModeChange(projectName, newMode, liveValidationType) {
288
+ const project = _findProjectInLastStatus(projectName);
289
+ if (!project) return;
290
+ const label = newMode === 'worktree'
291
+ ? 'Worktrees'
292
+ : (liveValidationType ? ('Hybrid (' + liveValidationType + ')') : 'Live checkout');
293
+ const ok = await confirmDialog({
294
+ title: 'Change checkout mode?',
295
+ message: 'Switch "' + projectName + '" to ' + label + '? This changes how every future dispatch on this project runs.',
296
+ confirmLabel: 'Change mode',
297
+ cancelLabel: 'Cancel',
298
+ danger: newMode !== 'worktree',
299
+ });
300
+ if (!ok) return;
301
+
302
+ const prevMode = project.checkoutMode;
303
+ const prevType = project.liveValidationType;
304
+ const nextType = (newMode === 'live' && liveValidationType) ? liveValidationType : null;
305
+ // Optimistic flip BEFORE awaiting the API call (per dashboard convention —
306
+ // see projectChipRemove / removePinnedNote); reverted in the catch below.
307
+ project.checkoutMode = newMode;
308
+ project.liveValidationType = nextType;
309
+ if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
310
+
311
+ try {
312
+ const res = await safeFetch('/api/settings', {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({
316
+ projects: [{
317
+ name: projectName,
318
+ checkoutMode: newMode,
319
+ liveValidation: nextType ? { type: nextType } : null,
320
+ }],
321
+ }),
322
+ });
323
+ const result = await res.json().catch(function() { return {}; });
324
+ if (!res.ok) throw new Error((result && result.error) || ('HTTP ' + res.status));
325
+ showToast('cmd-toast', 'Checkout mode updated for "' + projectName + '".', true);
326
+ } catch (err) {
327
+ project.checkoutMode = prevMode;
328
+ project.liveValidationType = prevType;
329
+ if (window._lastStatus && Array.isArray(window._lastStatus.projects)) renderProjects(window._lastStatus.projects);
330
+ showToast('cmd-toast', 'Failed to update checkout mode: ' + (err && err.message || err), false);
331
+ }
332
+ }
333
+
334
+ // Delegated click/keydown on the persistent #projects-list container (its
335
+ // children are replaced wholesale on every renderProjects() call, but the
336
+ // container itself survives re-renders — see the Long-Running Commands /
337
+ // event-delegation convention referenced in the playbook).
338
+ (function _wireCheckoutModePillDelegation() {
339
+ const list = document.getElementById('projects-list');
340
+ if (!list) return;
341
+ list.addEventListener('click', function(e) {
342
+ const pill = e.target.closest('[data-checkout-pill]');
343
+ if (!pill) return;
344
+ e.stopPropagation();
345
+ const name = pill.getAttribute('data-checkout-pill');
346
+ _openCheckoutModeMenu(name, pill);
347
+ });
348
+ list.addEventListener('keydown', function(e) {
349
+ if (e.key !== 'Enter' && e.key !== ' ') return;
350
+ const pill = e.target.closest('[data-checkout-pill]');
351
+ if (!pill) return;
352
+ e.preventDefault();
353
+ e.stopPropagation();
354
+ const name = pill.getAttribute('data-checkout-pill');
355
+ _openCheckoutModeMenu(name, pill);
356
+ });
357
+ })();
358
+
111
359
  function _projectCachePath(project) {
112
360
  return String((project && (project.localPath || project.path)) || '').replace(/\\/g, '/');
113
361
  }
@@ -371,7 +371,7 @@ function renderPlans(plans) {
371
371
  'onclick="event.stopPropagation();planDelete(\'' + escapeHtml(p.file) + '\')">Delete</button>' : '';
372
372
 
373
373
  const versionBadge = p.version ? ' <span style="font-size:var(--text-xs);font-weight:700;padding:1px 5px;border-radius:3px;background:rgba(56,139,253,0.15);color:var(--blue);vertical-align:middle">v' + p.version + '</span>' : '';
374
- const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'converting': 'var(--yellow)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--green)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)', 'draft': 'var(--muted)' };
374
+ const statusColors = { 'completed': 'var(--green)', 'dispatched': 'var(--blue)', 'converting': 'var(--yellow)', 'paused': 'var(--muted)', 'awaiting-approval': 'var(--yellow)', 'approved': 'var(--purple,#a855f7)', 'rejected': 'var(--red)', 'has-failures': 'var(--red)', 'revision-requested': 'var(--purple,#a855f7)', 'active': 'var(--muted)', 'draft': 'var(--muted)' };
375
375
  const cardClass = effectiveStatus === 'dispatched' || effectiveStatus === 'converting' ? 'working' : effectiveStatus === 'awaiting-approval' || effectiveStatus === 'paused' ? 'awaiting' : effectiveStatus;
376
376
  // P-e8d49105 — cross-repo plans surface every touched project as its
377
377
  // own badge. Single-project plans (and old PRDs without _projects)
@@ -646,7 +646,10 @@ async function togglePrObserve(btn) {
646
646
  try {
647
647
  const res = await fetch('/api/pull-requests/observe', {
648
648
  method: 'POST', headers: { 'Content-Type': 'application/json' },
649
- body: JSON.stringify({ host, slug, number, observe: next })
649
+ // Canonical contextOnly is the primary payload (P-2b6e4d81); the
650
+ // legacy `observe` body param remains a server-side back-compat
651
+ // fallback for any other caller, so the client no longer sends it.
652
+ body: JSON.stringify({ host, slug, number, contextOnly: !next })
650
653
  });
651
654
  if (!res.ok) {
652
655
  const d = await res.json().catch(() => ({}));
@@ -114,6 +114,19 @@ function ccSegmentsApplyChunk(segments, text, segmentId) {
114
114
  var last = segments.length ? segments[segments.length - 1] : null;
115
115
  if (hasId) {
116
116
  if (last && last.kind === 'text' && last.id === segmentId) { last.text = text; return segments; }
117
+ // W-mr1bgfyr — a reconnect replay (or any other id-less push, see the
118
+ // heuristic branch below) leaves the trailing text segment with `id:
119
+ // null` even though the resumed live stream now carries a real
120
+ // segmentId for that SAME logical block. Without this, the id
121
+ // mismatch (null !== segmentId) opened a brand-new duplicate text
122
+ // segment on every following chunk, rendering as a second bubble/block
123
+ // stacked under the first with a little more text each time instead of
124
+ // updating in place. Adopt the id and merge into the existing segment.
125
+ if (last && last.kind === 'text' && last.id === null) {
126
+ last.text = _ccSegMergeText(last.text, text);
127
+ last.id = segmentId;
128
+ return segments;
129
+ }
117
130
  segments.push({ kind: 'text', id: segmentId, text: text });
118
131
  return segments;
119
132
  }
@@ -876,10 +876,17 @@ function _wiRenderDetail(item) {
876
876
  // Artifacts — branch (non-navigation) + navigation chips routed through
877
877
  // renderArtifactLink so chip click → openArtifact() → modal-stack push +
878
878
  // URL hash + browser-Back integration come for free (P-e265cd31).
879
- // P-79b47b0c also surface item._source (the WI's raw source file) as a
880
- // dedicated chip when it isn't already covered by arts.plan / arts.prd /
881
- // arts.sourcePlan. Chip type is inferred from the file extension (.md →
882
- // plan, .json prd).
879
+ // NOTE (issue #603): item._source is the WI's *project name* (stamped by
880
+ // fetchWorkItemsFromDisk above / used by every action button to route
881
+ // retry/cancel/delete/etc.), NOT a plan/PRD file id. A prior "Source" chip
882
+ // here treated item._source as if it held a genuine source-plan/PRD
883
+ // filename, which rendered a bogus chip labeled with the project name on
884
+ // nearly every work item and 404'd when clicked (there is no plan/PRD
885
+ // named e.g. "src"). The genuine source-plan/PRD case is already fully
886
+ // covered by arts.plan / arts.prd / arts.sourcePlan below, which are
887
+ // populated server-side from the WI's real sourcePlan/_planFileName/
888
+ // planFile fields (engine/queries.js) — no separate item._source-based
889
+ // chip is needed.
883
890
  var arts = item._artifacts || {};
884
891
  var artPills = '';
885
892
  var branchStyle = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:default';
@@ -887,15 +894,6 @@ function _wiRenderDetail(item) {
887
894
  if (arts.plan) artPills += renderArtifactLink({ type: 'plan', id: arts.plan, label: 'Plan' }) + ' ';
888
895
  if (arts.prd) artPills += renderArtifactLink({ type: 'prd', id: arts.prd, label: 'PRD' }) + ' ';
889
896
  if (arts.sourcePlan) artPills += renderArtifactLink({ type: 'plan', id: arts.sourcePlan, label: 'Source Plan' }) + ' ';
890
- // Source chip (item._source) — render when it's a file path that isn't
891
- // already represented in arts.plan / arts.prd / arts.sourcePlan.
892
- if (item._source && typeof item._source === 'string' && item._source !== 'central') {
893
- var sourceAlreadyShown = arts.plan === item._source || arts.prd === item._source || arts.sourcePlan === item._source;
894
- if (!sourceAlreadyShown) {
895
- var sourceType = item._source.endsWith('.json') ? 'prd' : 'plan';
896
- artPills += renderArtifactLink({ type: sourceType, id: item._source, label: 'Source', title: item._source }) + ' ';
897
- }
898
- }
899
897
  if (arts.notes && arts.notes.length > 0) {
900
898
  arts.notes.forEach(function(n) {
901
899
  var noteFile = (n && typeof n === 'object') ? (n.file || n) : String(n || '');
@@ -282,8 +282,8 @@ async function openSettings() {
282
282
  '<div data-search="' + escHtml(wtModeSearch) + '" style="margin-bottom:6px">' +
283
283
  '<label style="font-size:var(--text-sm);color:var(--muted);display:block;margin-bottom:2px">Checkout mode</label>' +
284
284
  '<select id="set-checkoutMode-' + escHtml(p.name) + '" data-checkout-mode-select="' + escHtml(p.name) + '" style="width:100%;padding:4px 6px;background:var(--surface);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:var(--text-md)">' +
285
- '<option value="worktree"' + (currentWtMode === 'worktree' ? ' selected' : '') + '>Worktree (default)</option>' +
286
- '<option value="live"' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
285
+ '<option value="worktree" title="Each dispatch runs in its own isolated git worktree. Fully parallel, never touches your working directory."' + (currentWtMode === 'worktree' ? ' selected' : '') + '>Worktree (default)</option>' +
286
+ '<option value="live" title="Agents run in-place in the project folder. One mutating dispatch at a time; refuses to run on a dirty tree."' + (currentWtMode === 'live' ? ' selected' : '') + '>Live checkout</option>' +
287
287
  '</select>' +
288
288
  '<div data-checkout-mode-chip="' + escHtml(p.name) + '" style="' + (currentWtMode === 'live' ? '' : 'display:none;') + 'margin-top:6px;padding:6px 8px;background:rgba(234,179,8,0.12);border:1px solid var(--yellow);border-radius:4px;color:var(--yellow);font-size:var(--text-xs);line-height:1.4">' +
289
289
  '⚠ Live mode: dispatches run directly in this repo\'s checkout. Only one mutating dispatch runs at a time. Dirty working trees block dispatch — commit or stash before running.' +
@@ -384,7 +384,7 @@ async function openSettings() {
384
384
  settingsToggle('Auto-reap orphan worktree holders', 'set-autoReapOrphanWorktreeHolders', !!e.autoReapOrphanWorktreeHolders,
385
385
  'When ON, the periodic orphan-worktree sweep automatically kills unambiguous orphan `spawn-agent.js` processes holding a stuck worktree\'s cwd (Windows file-lock pattern, e.g. W-mq1k8z6o003acd89 / PID 16828). Safe-reap criteria: cmdline must match spawn-agent.js, reference the worktree basename, AND process age must exceed `engine.agentTimeout * 2`. Default OFF because killing a foreign process is destructive. The holder SCAN runs either way and is appended to the escalation inbox note.') +
386
386
  settingsToggle('Live-checkout auto-reset (fleet-wide)', 'set-liveCheckoutAutoReset', !!e.liveCheckoutAutoReset,
387
- 'When ON, a dirty or broken live-checkout (in-place) tree is force-recovered before dispatch via `git fetch origin` + `git reset --hard origin/<branch>` instead of failing the dispatch with non-retryable LIVE_CHECKOUT_DIRTY. This is the FLEET-WIDE fallback; a per-project `liveCheckoutAutoReset` in config.json overrides it. Default OFF because the reset is DESTRUCTIVE it discards the operator\'s uncommitted changes in the live checkout (a `live-checkout-autoreset-<workItem>` inbox note records exactly what was discarded for reflog recovery). Only affects projects running in live/in-place checkout mode.') +
387
+ 'When ON, a dirty or broken live-checkout (in-place) tree is force-recovered before dispatch via `git fetch origin` + `git reset --hard origin/<branch>` instead of failing the dispatch with non-retryable LIVE_CHECKOUT_DIRTY. This is the FLEET-WIDE fallback; a per-project `liveCheckoutAutoReset` in config.json overrides it. Default ON discarding disposable dirt keeps live-checkout branches aligned with origin instead of accumulating auto-save WIP commits; the reset is DESTRUCTIVE (it discards the operator\'s uncommitted changes in the live checkout, and a `live-checkout-autoreset-<workItem>` inbox note records exactly what was discarded for reflog recovery), so set it OFF for live checkouts whose local drift you want preserved. Only affects projects running in live/in-place checkout mode.') +
388
388
  '</div>' +
389
389
  '<div class="settings-grid-2">' +
390
390
  settingsField('CC Session Idle Timeout', 'set-ccWorkerIdleTimeoutMs', Math.round((e.ccWorkerIdleTimeoutMs || 1800000) / 60000), 'minutes', 'How long a persistent `copilot --acp` CC worker stays warm with no activity before the idle reaper kills it. After a reap the next message cold-spawns a FRESH session with no memory of prior turns and CC shows a "context cleared after inactivity" notice. Shorter = less idle memory/process footprint; longer = more context durability across gaps between messages. Only affects the Copilot worker-pool path (CC Worker Pool ON). Clamped 1–480 minutes.') +
@@ -1288,6 +1288,64 @@
1288
1288
  .project-mode-live { background: rgba(210,153,34,0.15); color: var(--yellow); border: 1px solid var(--yellow); }
1289
1289
  .project-mode-hybrid { background: rgba(88,166,255,0.15); color: var(--blue); border: 1px solid var(--blue); }
1290
1290
 
1291
+ /* Clickable checkout-mode pill (W-mr1b67zi0006b788) — swaps the "help"
1292
+ * cursor for "pointer" and adds a hover/focus highlight so it reads as
1293
+ * interactive, opening the picker in _openCheckoutModeMenu(). */
1294
+ .project-mode-pill-clickable { cursor: pointer; }
1295
+ .project-mode-pill-clickable:hover,
1296
+ .project-mode-pill-clickable:focus-visible {
1297
+ filter: brightness(1.2);
1298
+ outline: 1px solid var(--blue);
1299
+ outline-offset: 1px;
1300
+ }
1301
+
1302
+ /* Checkout-mode picker popover (W-mr1b67zi0006b788). Rendered via
1303
+ * document.createElement in _openCheckoutModeMenu (no innerHTML), appended
1304
+ * to <body> and positioned under the clicked pill. */
1305
+ .checkout-mode-menu {
1306
+ z-index: 1000;
1307
+ min-width: 220px;
1308
+ background: var(--surface2);
1309
+ border: 1px solid var(--border);
1310
+ border-radius: var(--radius-md, 6px);
1311
+ box-shadow: 0 4px 16px rgba(0,0,0,0.3);
1312
+ padding: 6px;
1313
+ font-size: var(--text-sm);
1314
+ }
1315
+ .checkout-mode-menu-title {
1316
+ font-weight: 600; color: var(--muted);
1317
+ padding: 4px 8px 6px; border-bottom: 1px solid var(--border);
1318
+ margin-bottom: 4px;
1319
+ }
1320
+ .checkout-mode-menu-item {
1321
+ padding: 6px 8px; border-radius: 4px; cursor: pointer;
1322
+ color: var(--text);
1323
+ }
1324
+ .checkout-mode-menu-item:hover,
1325
+ .checkout-mode-menu-item:focus {
1326
+ background: var(--surface); outline: none;
1327
+ }
1328
+ .checkout-mode-menu-item-active { color: var(--blue); font-weight: 600; }
1329
+ .checkout-mode-menu-item-desc {
1330
+ font-size: var(--text-xs); color: var(--muted); font-weight: 400;
1331
+ margin-top: 2px; line-height: 1.35;
1332
+ }
1333
+ .checkout-mode-menu-select {
1334
+ width: 100%; margin: 4px 0 8px; padding: 4px 6px;
1335
+ background: var(--surface); color: var(--text); border: 1px solid var(--border);
1336
+ border-radius: 4px;
1337
+ }
1338
+ .checkout-mode-menu-actions {
1339
+ display: flex; justify-content: flex-end; gap: 6px; padding-top: 4px;
1340
+ }
1341
+ .checkout-mode-menu-btn {
1342
+ padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border);
1343
+ background: var(--surface); color: var(--text); cursor: pointer; font-size: var(--text-sm);
1344
+ }
1345
+ .checkout-mode-menu-btn-primary {
1346
+ background: var(--blue); color: #fff; border-color: var(--blue);
1347
+ }
1348
+
1291
1349
  /* QA tab (W-mpeiwz6k0005bf34-d) — targets / runbooks / runs sections.
1292
1350
  * Reuses surface/border/text tokens defined in :root so the QA page
1293
1351
  * blends with the rest of the dashboard. */
package/dashboard.js CHANGED
@@ -1350,12 +1350,15 @@ function linkPullRequestForTracking({ url, title, project: projectName, contextO
1350
1350
  // W-mpmwxkzm0009ba0b — Per-row auto-observe toggle backing helper for
1351
1351
  // POST /api/pull-requests/observe. Flips canonical `contextOnly` on an
1352
1352
  // existing tracked PR record under a lock (per CLAUDE.md mutate convention).
1353
- // Body shape: { host: 'github'|'ado', slug, number, observe: boolean }.
1354
- // The public `observe` body param is preserved for backward compatibility
1355
- // (W-mq5s5ttx000j7ab8-c); internally it maps to `contextOnly = !observe`.
1353
+ // Body shape: { host: 'github'|'ado', slug, number, contextOnly: boolean }.
1354
+ // `contextOnly` is canonical and read first; the legacy `observe` body
1355
+ // param (W-mq5s5ttx000j7ab8-c) is accepted as a documented back-compat
1356
+ // fallback (`contextOnly = !observe`) for any caller that still sends it —
1357
+ // the dashboard client itself now sends `contextOnly` exclusively
1358
+ // (P-2b6e4d81; see docs/deprecated.json#pr-observe-observe-body-param).
1356
1359
  // Returns the updated record + the PR path that was touched. Throws an
1357
1360
  // Error with `statusCode` for the route handler to map to an HTTP status.
1358
- function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
1361
+ function updatePullRequestObserveFlag({ host, slug, number, observe, contextOnly } = {}, config = CONFIG, minionsDir = MINIONS_DIR) {
1359
1362
  const hostStr = String(host || '').trim().toLowerCase();
1360
1363
  const slugStr = String(slug || '').trim();
1361
1364
  const numberInt = Number.parseInt(number, 10);
@@ -1374,8 +1377,13 @@ function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, conf
1374
1377
  err.statusCode = 400;
1375
1378
  throw err;
1376
1379
  }
1377
- if (typeof observe !== 'boolean') {
1378
- const err = new Error('observe must be a boolean');
1380
+ let contextOnlyValue;
1381
+ if (typeof contextOnly === 'boolean') {
1382
+ contextOnlyValue = contextOnly;
1383
+ } else if (typeof observe === 'boolean') {
1384
+ contextOnlyValue = !observe;
1385
+ } else {
1386
+ const err = new Error('contextOnly (or legacy observe) must be a boolean');
1379
1387
  err.statusCode = 400;
1380
1388
  throw err;
1381
1389
  }
@@ -1394,7 +1402,7 @@ function updatePullRequestObserveFlag({ host, slug, number, observe } = {}, conf
1394
1402
  shared.mutatePullRequests(prPath, (prs) => {
1395
1403
  const pr = prs.find(p => p && p.id === canonicalId);
1396
1404
  if (!pr) return prs;
1397
- pr.contextOnly = !observe;
1405
+ pr.contextOnly = contextOnlyValue;
1398
1406
  updated = { id: pr.id, contextOnly: pr.contextOnly };
1399
1407
  updatedPath = prPath;
1400
1408
  return prs;
@@ -13086,6 +13094,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13086
13094
  const inputs = [
13087
13095
  CONFIG_PATH,
13088
13096
  path.join(ENGINE_DIR, 'dispatch.json'),
13097
+ // Central work-items.json at MINIONS_DIR root — backs the 'central'
13098
+ // scope (schedule/pipeline/CC-created rootless WIs, per
13099
+ // work-items-store.js#_filePathForScope). Without this, a new
13100
+ // central-scope WI never busts the ETag until dispatch.json changes.
13101
+ shared.centralWorkItemsPath(MINIONS_DIR),
13089
13102
  ];
13090
13103
  for (const p of projects) {
13091
13104
  if (p && p.name) {
@@ -13106,7 +13119,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13106
13119
  { method: 'GET', path: '/api/pull-requests', desc: 'Fully-enriched pull requests (per-project files joined + url backfill + _project stamp)', handler: (req, res) => {
13107
13120
  const config = queries.getConfig();
13108
13121
  const projects = config.projects || [];
13109
- const inputs = [CONFIG_PATH];
13122
+ const inputs = [
13123
+ CONFIG_PATH,
13124
+ // Central pull-requests.json at MINIONS_DIR root — backs the
13125
+ // 'central' scope per pull-requests-store.js#_filePathForScope.
13126
+ // Mirrors the same class of staleness fix as /api/work-items.
13127
+ shared.centralPullRequestsPath(MINIONS_DIR),
13128
+ ];
13110
13129
  for (const p of projects) {
13111
13130
  if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
13112
13131
  }
@@ -13137,6 +13156,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13137
13156
  const inputs = [
13138
13157
  path.join(ENGINE_DIR, 'metrics.json'),
13139
13158
  path.join(ENGINE_DIR, 'dispatch.json'),
13159
+ // getMetrics() enriches from getPullRequests(), which includes the
13160
+ // central pull-requests.json scope — include it here too so a new
13161
+ // central-scope PR busts the ETag (same class of bug as /api/work-items).
13162
+ shared.centralPullRequestsPath(MINIONS_DIR),
13140
13163
  ];
13141
13164
  for (const p of projects) {
13142
13165
  if (p && p.name) inputs.push(path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json'));
@@ -13721,7 +13744,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
13721
13744
  }
13722
13745
  }},
13723
13746
 
13724
- { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle canonical contextOnly flag on a tracked PR (public `observe` body param is preserved as the inverse for backward compat)', params: 'host (github|ado), slug, number, observe (boolean)', handler: async (req, res) => {
13747
+ { method: 'POST', path: '/api/pull-requests/observe', desc: 'Toggle canonical contextOnly flag on a tracked PR (legacy `observe` body param is preserved as the inverse for backward compat)', params: 'host (github|ado), slug, number, contextOnly (boolean; legacy observe (boolean) also accepted)', handler: async (req, res) => {
13725
13748
  const body = await readBody(req);
13726
13749
  reloadConfig();
13727
13750
  try {