@yemi33/minions 0.1.2238 → 0.1.2240

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.
@@ -1144,6 +1144,14 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1144
1144
  // render-utils.js ccSegments* helpers.
1145
1145
  var segments = [];
1146
1146
  if (activeTab) activeTab._segments = segments;
1147
+ // Set when a stream interruption triggers a reconnect. The server replays the
1148
+ // full accumulated snapshot (all tools + the whole text as one chunk) on
1149
+ // reconnect, so the already-rendered segments must be cleared to avoid
1150
+ // duplication — but we DEFER that clear until the replay's first event
1151
+ // actually arrives (see _handleEvent) instead of blanking the bubble at
1152
+ // reconnect time. Eager clearing produced a visible disappear/reappear of the
1153
+ // streamed response during the >1s reconnect wait (health check + backoff).
1154
+ var _ccReplayPending = false;
1147
1155
 
1148
1156
  // Get active tab's sessionId to send with request
1149
1157
  var tabSessionId = activeTab ? activeTab.sessionId : null;
@@ -1258,6 +1266,17 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1258
1266
  var pendingEventName = '';
1259
1267
 
1260
1268
  async function _handleEvent(evt) {
1269
+ // First event after a reconnect: the server replays the full accumulated
1270
+ // snapshot (all prior tools, then the whole text as one chunk — see the
1271
+ // reconnect branch in dashboard.js). Swap the pre-reconnect segments out
1272
+ // atomically HERE, the instant the replay starts arriving, instead of
1273
+ // blanking them at reconnect time. This keeps the streamed response on
1274
+ // screen continuously across a reconnect (no disappear/reappear flicker).
1275
+ if (_ccReplayPending && (evt.type === 'chunk' || evt.type === 'tool' || evt.type === 'tool-update' || evt.type === 'done')) {
1276
+ segments.length = 0; // clear in place — activeTab._segments shares this ref
1277
+ if (activeTab) activeTab._segments = segments;
1278
+ _ccReplayPending = false;
1279
+ }
1261
1280
  if (evt.type === 'chunk') {
1262
1281
  // evt.segmentId (from engine/llm.js) marks distinct assistant text
1263
1282
  // blocks; ccSegmentsApplyChunk falls back to a heuristic when it's
@@ -1450,8 +1469,12 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1450
1469
  break;
1451
1470
  }
1452
1471
  reconnectAttempts++;
1453
- segments = [];
1454
- if (activeTab) activeTab._segments = segments;
1472
+ // Don't blank the already-streamed content here. Defer clearing `segments`
1473
+ // until the reconnect's replay actually starts delivering (see the
1474
+ // _ccReplayPending guard in _handleEvent), so the bubble keeps showing the
1475
+ // prior content (with the "reattaching" note below it) instead of going
1476
+ // empty for the >1s reconnect window and then re-filling.
1477
+ _ccReplayPending = true;
1455
1478
  streamStatusNote = 'Connection interrupted — reattaching to the live response...';
1456
1479
  updateStreamDiv();
1457
1480
  await new Promise(function(r) { setTimeout(r, 1000 * reconnectAttempts); });
@@ -151,7 +151,9 @@ const RENDER_VERSIONS = {
151
151
  skills: 1,
152
152
  commands: 1,
153
153
  mcpServers: 1,
154
- harnessDiag: 1,
154
+ // Bumped to 2 by W-mqptgmcn000k5c90: renderHarnessDiagnostics now stamps each
155
+ // <details> with a stable data-hkey and the render is gated behind _changed().
156
+ harnessDiag: 2,
155
157
  schedules: 1,
156
158
  watches: 3,
157
159
  meetings: 1,
@@ -875,10 +877,23 @@ function _processStatusUpdate(data, opts) {
875
877
  .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
876
878
  .then(function (fresh) {
877
879
  window._lastHarnessDiag = fresh;
878
- _safeRender('harnessDiag', function() { renderHarnessDiagnostics(fresh); });
880
+ // renderHarnessDiagnostics rewrites #harness-diag innerHTML, which
881
+ // destroys the native <details> open/closed state the operator just
882
+ // clicked. The diagnostic changes rarely, so only re-render when the
883
+ // freshly-fetched payload differs from the last-rendered one (mirrors
884
+ // the _changed() cache-bust used for mcpServers/commands). Steady-state
885
+ // polls bail out here and leave the user's expanded sections intact.
886
+ if (_changed('harnessDiag', fresh)) {
887
+ _safeRender('harnessDiag', function() { renderHarnessDiagnostics(fresh); });
888
+ }
879
889
  })
880
890
  .catch(function () {
881
- if (window._lastHarnessDiag) _safeRender('harnessDiag', function() { renderHarnessDiagnostics(window._lastHarnessDiag); });
891
+ // Fetch failed only paint the cached diagnostic if it has not already
892
+ // been rendered (first-load failure recovery). Re-painting an
893
+ // already-rendered payload would needlessly collapse open sections.
894
+ if (window._lastHarnessDiag && _changed('harnessDiag', window._lastHarnessDiag)) {
895
+ _safeRender('harnessDiag', function() { renderHarnessDiagnostics(window._lastHarnessDiag); });
896
+ }
882
897
  });
883
898
  });
884
899
  // Schedule definitions stay on /api/status (config-derived), but the
@@ -663,7 +663,7 @@ function renderHarnessDiagnostics(diag) {
663
663
 
664
664
  // Per-runtime adapter rows
665
665
  for (const r of diag.runtimes || []) {
666
- parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Runtime: ' + escHtml(r.name) + '</summary>' +
666
+ parts.push('<details data-hkey="rt:' + escHtml(r.name) + '" style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Runtime: ' + escHtml(r.name) + '</summary>' +
667
667
  _harnessRowsBlock('User asset dirs (--add-dir)', r.userAssetDirs) +
668
668
  _harnessRowsBlock('Skill roots (CLI native discovery)', r.skillRoots) +
669
669
  _harnessRowsBlock('Skill write targets', r.skillWriteTargets) +
@@ -674,7 +674,7 @@ function renderHarnessDiagnostics(diag) {
674
674
 
675
675
  // --add-dir snapshot for fleet default
676
676
  const snap = diag.addDirSnapshot || [];
677
- parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">--add-dir snapshot for ' + escHtml(diag.fleetDefaultCli || 'fleet default') + ' <span style="color:var(--muted);font-weight:400">(' + snap.length + ' dir' + (snap.length === 1 ? '' : 's') + ')</span></summary>' +
677
+ parts.push('<details data-hkey="addDirSnapshot" open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">--add-dir snapshot for ' + escHtml(diag.fleetDefaultCli || 'fleet default') + ' <span style="color:var(--muted);font-weight:400">(' + snap.length + ' dir' + (snap.length === 1 ? '' : 's') + ')</span></summary>' +
678
678
  (snap.length === 0
679
679
  ? '<div style="font-size:var(--text-sm);color:var(--muted);margin:6px 0 0 12px"><em>(no dirs attached — engine/spawn-agent.js unavailable or adapter has no asset dirs)</em></div>'
680
680
  : snap.map(d =>
@@ -686,7 +686,7 @@ function renderHarnessDiagnostics(diag) {
686
686
  // Suppressed assets
687
687
  const suppressed = diag.suppressed || [];
688
688
  if (suppressed.length > 0) {
689
- parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Suppressed assets <span style="color:var(--muted);font-weight:400">(' + suppressed.length + ')</span></summary>' +
689
+ parts.push('<details data-hkey="suppressed" open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Suppressed assets <span style="color:var(--muted);font-weight:400">(' + suppressed.length + ')</span></summary>' +
690
690
  suppressed.map(s =>
691
691
  '<div style="margin:6px 0 0 12px"><code style="color:var(--orange)">' + escHtml(s.flag) + '</code> <span style="color:var(--muted);font-size:var(--text-sm)">(' + escHtml(String(s.value)) + ')</span>' +
692
692
  '<div style="font-size:var(--text-sm);color:var(--muted);margin-left:18px">' + escHtml(s.effect || '') + '</div></div>'
@@ -697,7 +697,7 @@ function renderHarnessDiagnostics(diag) {
697
697
  // Project-local-on-main footgun
698
698
  const footgun = (diag.projectLocalOnMain || []).filter(e => e && e.uncommittedAssets && e.uncommittedAssets.length > 0);
699
699
  if (footgun.length > 0) {
700
- parts.push('<details open style="margin:6px 0;border:1px solid var(--orange);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer;color:var(--orange)">⚠ Project-local assets on main checkout (won\'t propagate to worktree)</summary>' +
700
+ parts.push('<details data-hkey="footgun" open style="margin:6px 0;border:1px solid var(--orange);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer;color:var(--orange)">⚠ Project-local assets on main checkout (won\'t propagate to worktree)</summary>' +
701
701
  footgun.map(entry =>
702
702
  '<div style="margin:8px 0 0 12px">' +
703
703
  '<div style="font-weight:600">' + escHtml(entry.project) + ' <span style="font-family:monospace;color:var(--muted);font-weight:400">' + escHtml(entry.localPath) + '</span></div>' +
@@ -713,7 +713,7 @@ function renderHarnessDiagnostics(diag) {
713
713
  // Missing dirs summary (aggregate)
714
714
  const missing = diag.missingDirs || [];
715
715
  if (missing.length > 0) {
716
- parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Missing on-disk dirs <span style="color:var(--muted);font-weight:400">(' + missing.length + ')</span></summary>' +
716
+ parts.push('<details data-hkey="missingDirs" style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Missing on-disk dirs <span style="color:var(--muted);font-weight:400">(' + missing.length + ')</span></summary>' +
717
717
  '<div style="font-size:var(--text-sm);color:var(--muted);margin:4px 0 6px 12px">Adapters declare these paths but they do not exist on this host. Warnings, not failures — create them if the runtime should look there.</div>' +
718
718
  missing.map(m =>
719
719
  '<div style="font-family:monospace;font-size:var(--text-sm);margin-left:18px"><span style="color:var(--orange)">⚠</span> ' + escHtml(m.path) + ' <span style="color:var(--muted)">[' + escHtml(m.scope) + ' · ' + escHtml(m.kind) + ' · ' + escHtml(m.runtime) + ']</span></div>'
@@ -721,8 +721,22 @@ function renderHarnessDiagnostics(diag) {
721
721
  '</details>');
722
722
  }
723
723
 
724
+ // Native details open/closed is DOM state, not data — a full innerHTML
725
+ // rewrite would reset every section the operator expanded. Snapshot the
726
+ // current open/closed state keyed by the stable data-hkey before the rewrite,
727
+ // then re-apply it afterward so a genuine content change doesn't collapse the
728
+ // user's expanded sections. (Steady-state no-op polls are already short-
729
+ // circuited upstream in refresh.js via _changed('harnessDiag', …).)
730
+ const prevOpen = {};
731
+ for (const d of el.querySelectorAll('details[data-hkey]')) {
732
+ prevOpen[d.getAttribute('data-hkey')] = d.open;
733
+ }
724
734
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runtime name, path, scope, flag, effect, project name, localPath, file, kind)
725
735
  el.innerHTML = parts.join('');
736
+ for (const d of el.querySelectorAll('details[data-hkey]')) {
737
+ const k = d.getAttribute('data-hkey');
738
+ if (Object.prototype.hasOwnProperty.call(prevOpen, k)) d.open = prevOpen[k];
739
+ }
726
740
  }
727
741
 
728
742
  window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderCommands, renderHarnessDiagnostics, renderMetrics, renderLlmPerf, renderTokenUsage, _aggregateEngineUsageForTokenTile, openScanProjectsModal };
@@ -7,12 +7,14 @@ function _plansPrev() { if (_plansPage > 0) { _plansPage--; refresh(); } }
7
7
  function _plansNext() { _plansPage++; refresh(); }
8
8
 
9
9
  function openCreatePlanModal() {
10
- const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
11
- '<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--text-md);color:var(--text)">' +
12
- '<input type="checkbox" class="plan-new-project-cb" value="' + escapeHtml(p) + '" onchange="_updatePlanProjectHint()">' +
13
- escapeHtml(p) +
14
- '</label>'
15
- ).join('');
10
+ const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
11
+ const name = typeof p === 'object' ? p.name : p;
12
+ const label = (typeof p === 'object' && (p.displayName || p.name)) || name;
13
+ return '<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--text-md);color:var(--text)">' +
14
+ '<input type="checkbox" class="plan-new-project-cb" value="' + escapeHtml(name) + '" onchange="_updatePlanProjectHint()">' +
15
+ escapeHtml(label) +
16
+ '</label>';
17
+ }).join('');
16
18
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
17
19
 
18
20
  document.getElementById('modal-title').textContent = 'Create Plan';
@@ -579,9 +579,11 @@ function openCreateWorkItemModal() {
579
579
  const agentOpts = (typeof cmdAgents !== 'undefined' ? cmdAgents : []).map(a =>
580
580
  '<option value="' + escapeHtml(a.id) + '">' + escapeHtml(a.name) + '</option>'
581
581
  ).join('');
582
- const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
583
- '<option value="' + escapeHtml(p) + '">' + escapeHtml(p) + '</option>'
584
- ).join('');
582
+ const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
583
+ const name = typeof p === 'object' ? p.name : p;
584
+ const label = (typeof p === 'object' && (p.displayName || p.name)) || name;
585
+ return '<option value="' + escapeHtml(name) + '">' + escapeHtml(label) + '</option>';
586
+ }).join('');
585
587
  const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
586
588
 
587
589
  document.getElementById('modal-title').textContent = 'Create Work Item';
@@ -180,6 +180,29 @@ function pushModalFrame(frame) {
180
180
  // The current visible (if any) is snapshotted and pushed onto _modalStack as
181
181
  // a breadcrumb; scrollY is captured here so popping restores the user's
182
182
  // place in the parent view.
183
+ //
184
+ // Direct-open promotion: a modal opened by a direct row-click opener
185
+ // (openWorkItemDetail, openModal, openAllWorkItems, viewAgentOutput, …) paints
186
+ // the body and adds .open WITHOUT routing through pushModalFrame, so it sets
187
+ // neither _currentVisibleArtifact nor a history entry of its own. If we only
188
+ // snapshotted it as an opaque breadcrumb (the old `else if` path) the parent
189
+ // never got a baseline depth-1 history entry, so a later Back diffed straight
190
+ // past it to depth 0 and tore the WHOLE modal down. Fix: FIRST promote the
191
+ // direct-opened parent to a tracked opaque '_direct' frame and push its own
192
+ // baseline depth-1 history entry, THEN fall through to the normal breadcrumb
193
+ // snapshot + depth-2 child push below. Net history: [page] -> [parent _direct
194
+ // depth 1] -> [child depth 2], so Back lands on the parent (depth 1) and only
195
+ // a second Back closes the modal.
196
+ if (!_currentVisibleArtifact && _modalIsOpen()) {
197
+ _currentVisibleArtifact = {
198
+ type: '_direct',
199
+ id: '_direct_' + (++_legacyFrameSeq),
200
+ openFn: null,
201
+ openArgs: null,
202
+ };
203
+ _updateModalChrome();
204
+ _pushModalHistoryState();
205
+ }
183
206
  if (_currentVisibleArtifact) {
184
207
  var snap = _captureModalSnapshot();
185
208
  _modalStack.push({
@@ -191,19 +214,6 @@ function pushModalFrame(frame) {
191
214
  openFn: _currentVisibleArtifact.openFn || null,
192
215
  openArgs: _currentVisibleArtifact.openArgs || null,
193
216
  });
194
- } else if (_modalIsOpen()) {
195
- // Modal was direct-opened (row click) without going through openArtifact.
196
- // Snapshot it as an opaque _direct breadcrumb so Back still restores it.
197
- var snap2 = _captureModalSnapshot();
198
- _modalStack.push({
199
- type: '_direct',
200
- id: '_direct_' + (++_legacyFrameSeq),
201
- titleHtml: snap2.titleHtml,
202
- bodyHtml: snap2.bodyHtml,
203
- scrollY: snap2.scrollY,
204
- openFn: null,
205
- openArgs: null,
206
- });
207
217
  }
208
218
  _currentVisibleArtifact = {
209
219
  type: frame.type,
package/dashboard.js CHANGED
@@ -4036,7 +4036,7 @@ function buildCCStatePreamble() {
4036
4036
  const pending = (dq.pending || []).length;
4037
4037
 
4038
4038
  const prCount = getPullRequests().length;
4039
- const wiCount = getWorkItems().length;
4039
+ const wiCount = getWorkItems(null, { enrich: false }).length;
4040
4040
 
4041
4041
  const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
4042
4042
 
@@ -4131,7 +4131,7 @@ function buildCCStateRefresh() {
4131
4131
  const active = activeLines ? activeLines + overflow : '(none)';
4132
4132
  const pending = (dq.pending || []).length;
4133
4133
  const prCount = getPullRequests().length;
4134
- const wiCount = getWorkItems().length;
4134
+ const wiCount = getWorkItems(null, { enrich: false }).length;
4135
4135
  activitySection = `
4136
4136
 
4137
4137
  **Active dispatch:**
@@ -127,15 +127,16 @@ evaluation pass) can see what tooling drove a dispatch:
127
127
  rely on the work-item modal surface (#3) instead — they write no inbox note
128
128
  at all (#308 removed the standalone harness-usage digest to cut note spam).
129
129
  3. **Work-item detail modal** — the dashboard work-item modal shows the
130
- grounded harness list alongside the completion artifacts, with the
131
- `grounded: false` entries visually distinguished (P-d5a6f7c4). On
130
+ harness list alongside the completion artifacts (P-d5a6f7c4). On
132
131
  completion, `engine/lifecycle.js promoteCompletionArtifacts` persists the
133
132
  grounded `structuredCompletion.harnessUsed` onto the work item as
134
133
  `_harnessUsed` (it survives the slim `/api/work-items` list payload and
135
134
  modal hydration). `dashboard/js/render-work-items.js _wiRenderDetail` renders
136
- the "Repo harnesses used" section from `item._harnessUsed`: one pill per
137
- skill / MCP / command / doc, with `grounded: false` entries shown on a dashed
138
- yellow border and a marker so the human can adjudicate the discrepancy.
135
+ the "Repo harnesses used" section from `item._harnessUsed`: one plain solid
136
+ pill per skill / MCP / command / doc. The grounding flag is **not** surfaced
137
+ in this modal every entry renders identically (the per-pill dashed-border /
138
+ ⚠ marker distinction was dropped in PR #296). Grounding is surfaced only on
139
+ the PR-comment surface above.
139
140
 
140
141
  ## Why it matters
141
142
 
@@ -18,9 +18,9 @@ The default `worktree` mode spawns each dispatch inside its own git worktree und
18
18
 
19
19
  For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
20
20
 
21
- ## Contract (four guarantees)
21
+ ## Contract (five guarantees)
22
22
 
23
- Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has four guarantees; the engine enforces all four and fails dispatches that would violate them.
23
+ Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has five guarantees; the engine enforces all five and fails dispatches that would violate them.
24
24
 
25
25
  ### 1. Per-project mutating-concurrency cap of 1
26
26
 
@@ -30,15 +30,19 @@ Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dis
30
30
 
31
31
  ### 2. Refuse-on-dirty (engine never resets the operator tree)
32
32
 
33
- Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) refuses the dispatch immediately:
33
+ Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result (it does NOT throw), which refuses the dispatch immediately:
34
34
 
35
- - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically).
35
+ - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305).
36
36
  - Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
37
37
  - Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
38
38
  - Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
39
39
 
40
40
  The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. Cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`).
41
41
 
42
+ #### 2a. Thrown pre-spawn failures are retryable, NOT dirty (#305)
43
+
44
+ A thrown error from `prepareLiveCheckout` — a required-arg/ref-validation guard, or a transient `git status`/`rev-parse`/`checkout`/`symbolic-ref` failure — is **not** proof that the operator tree is dirty. `spawnAgent`'s `catch` block therefore completes the dispatch with the **separate** `FAILURE_CLASS.LIVE_CHECKOUT_FAILED` (`'live-checkout-failed'`), which is deliberately **excluded** from `engine/dispatch.js`'s `neverRetry` set. The dispatcher auto-retries with bounded backoff up to `ENGINE_DEFAULTS.maxRetries`, so racy branch-lock handoff, a just-finished sibling dispatch, or transient git state recovers on the next attempt **without a manual `/api/work-items/retry`**. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in `isRetryableFailureReason`. No dirty-files inbox alert is written and no `_pendingReason: 'live_checkout_dirty'` stamp is applied for this path — those belong to the confirmed-dirty result above.
45
+
42
46
  ### 3. No auto-pull, no `--force`, no fast-forward
43
47
 
44
48
  After the clean-tree check, `prepareLiveCheckout`:
@@ -57,6 +61,26 @@ Live mode shares one checkout per project. There is no pool to recycle, no quara
57
61
 
58
62
  Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate on `worktreePath` truthiness and `!liveMode`, so the borrow / return / orphan-GC paths execute zero git commands when the project is live.
59
63
 
64
+ ### 5. Refuse on mid-operation / detached HEAD (engine never aborts the operator's in-progress op)
65
+
66
+ `prepareLiveCheckout` runs a second preflight *between* the dirty-tree check (Guarantee 2) and branch resolution (Guarantee 3). Even on a **clean** tree, the branch switch/create is refused when the operator checkout is mid-operation or sitting on a detached HEAD:
67
+
68
+ - **In-progress git operation.** The git dir is resolved robustly via `git rev-parse --git-dir` (so submodule / gitdir-file / `repo`-managed trees — the setups that motivate live mode — are covered), then sentinel paths under it are probed: `MERGE_HEAD` → merge, `rebase-merge/` & `rebase-apply/` → rebase, `CHERRY_PICK_HEAD` → cherry-pick, `REVERT_HEAD` → revert. First hit returns `{ ok:false, reason:'mid-operation', op, details }`.
69
+ - **Detached HEAD.** `git symbolic-ref -q HEAD` exiting non-zero returns `{ ok:false, reason:'detached-head', sha }` (sha from `git rev-parse HEAD`). Branching off a detached HEAD would strand the operator's anonymous commits.
70
+
71
+ Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
72
+
73
+ ## Dispatch-end auto-restore
74
+
75
+ Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on **every** terminal result — success, failure, timeout, crash — and also on the engine-restart re-attach completion path (`engine/cli.js`), so a restart mid-dispatch does not strand the checkout on the agent's branch.
76
+
77
+ - **Original-ref capture.** `prepareLiveCheckout` records the operator's starting ref *before* the first checkout: `git symbolic-ref --short HEAD` → `{ originalRef:<branch>, originalRefType:'branch' }`, falling back to `git rev-parse HEAD` → `{ originalRef:<sha>, originalRefType:'detached' }`. `spawnAgent` persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch`, so the restore survives an engine restart, where the in-memory spawn closure is gone and only the persisted record remains.
78
+ - **AUTO-RESTORE (best-effort, never `--force`).** At dispatch-end `restoreLiveCheckoutAtDispatchEnd` issues a **plain** `git checkout <originalRef>` — no `--force`, no `-B`, no reset, no clean, no stash. It no-ops when there is nothing to restore: no captured `originalRef`, the agent branch *is* the original ref, or HEAD already sits on the original ref (matched against the branch name *or* the raw sha so the detached-HEAD case is recognized). It is strictly best-effort: every error is swallowed and logged, and a restore never alters the dispatch result.
79
+ - **Fallback notify (only when a safe switch is impossible).** If git declines the plain checkout — most likely because the agent left uncommitted changes a checkout would overwrite — the refusal is **honored**: the tree is left exactly as the agent left it and a deduped `live-checkout-branch-<dispatchId>` inbox alert tells the operator how to switch back manually (`git -C <localPath> checkout <originalRef>`). The engine never forces the switch.
80
+ - **Terminal-failure alert.** When the dispatch ends in a non-success terminal state, a deduped `live-checkout-failed-<dispatchId>` inbox alert is written so the operator knows a live-mode run failed inside their own checkout (where any partial work is visible). This is independent of the restore and fires even when the restore itself succeeds.
81
+
82
+ The core invariant holds end-to-end through restore: **the engine only ever switches branches — it never `git reset`s, `git clean`s, or `git stash`es the operator's tree, and never passes `--force`.**
83
+
60
84
  ## Operator workflow
61
85
 
62
86
  ### Enabling live mode (dashboard)
@@ -127,7 +151,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
127
151
  - **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
128
152
  - **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
129
153
  - **No auto-pull / no fast-forward on existing branches.** See Guarantee 3. If a PR branch is checked out locally at a different SHA than `origin/<branch>`, the operator resolves it manually.
130
- - **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree on kill.
154
+ - **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree to deliver the kill. (Dispatch-end auto-restore still runs afterward — a plain branch switch back to the operator's original ref, never a reset/clean/stash; see [Dispatch-end auto-restore](#dispatch-end-auto-restore).)
131
155
 
132
156
  ## Related code
133
157
 
@@ -135,12 +159,18 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
135
159
  |---|---|
136
160
  | `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
137
161
  | `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
138
- | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, branch resolution from HEAD (no fetch, no `origin/<mainRef>` — issue #226) (P-a3f9b203). |
162
+ | `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight, original-ref capture, branch resolution from HEAD (no fetch, no `origin/<mainRef>` — issue #226) (P-a3f9b203; preflight + capture P-b2e8d4a6). |
163
+ | `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (P-d9e6b2c4). |
139
164
  | `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
165
+ | `engine.js` — `spawnAgent` mid-op / detached-HEAD refusal block | Emits `LIVE_CHECKOUT_MID_OPERATION`, writes `live-checkout-blocked-<wi-id>` alert, stamps `_pendingReason: 'live_checkout_mid_operation'` / `'live_checkout_detached_head'` (P-c5a1f3b8). |
166
+ | `engine.js` — `spawnAgent` originalRef persistence | Persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch` so restore survives an engine restart (P-c5a1f3b8). |
167
+ | `engine.js` — `onAgentClose` live-mode restore wiring | Calls `restoreLiveCheckoutAtDispatchEnd` on every terminal result (P-d9e6b2c4). |
168
+ | `engine/cli.js` — orphan / reattach restore | Fires the same dispatch-end restore from the persisted record on the engine-restart completion path (P-d9e6b2c4). |
140
169
  | `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
141
170
  | `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
142
171
  | `dashboard/js/settings.js` — checkoutMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
143
172
  | `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
144
173
  | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
145
- | `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` from mechanical retry. |
174
+ | `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
175
+ | `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` and `LIVE_CHECKOUT_MID_OPERATION` from mechanical retry. |
146
176
  | `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
package/engine/cli.js CHANGED
@@ -895,6 +895,32 @@ const commands = {
895
895
  );
896
896
  } catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
897
897
 
898
+ // P-d9e6b2c4 — live-mode dispatch-end auto-restore + terminal-failure
899
+ // notify on the engine-restart reattach completion path. originalRef
900
+ // is only persisted for live-mode dispatches (P-c5a1f3b8), so its
901
+ // presence is the liveMode signal here — the slimmed meta.project
902
+ // carries no checkoutMode. Mirrors the in-process onAgentClose wiring
903
+ // (engine.js). Fire-and-forget + best-effort: the helper swallows its
904
+ // own errors and only ever issues a plain `git checkout <originalRef>`
905
+ // (never --force/reset/clean/stash) against the operator tree.
906
+ if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
907
+ try {
908
+ require('./live-checkout').restoreLiveCheckoutAtDispatchEnd({
909
+ localPath: item.meta.project.localPath,
910
+ branchName: item.meta.branch,
911
+ originalRef: item.originalRef,
912
+ originalRefType: item.originalRefType || 'branch',
913
+ dispatchId: item.id,
914
+ projectName: item.meta.project.name,
915
+ isTerminalFailure: !isSuccess,
916
+ resultLabel: result,
917
+ gitOpts: { env: shared.gitEnv(), windowsHide: true, timeout: 30000 },
918
+ log: (lvl, msg) => e.log(lvl, msg),
919
+ writeInboxAlert: dispatchModule().writeInboxAlert,
920
+ }).catch(() => {});
921
+ } catch (err) { e.log('warn', `Orphan live-checkout restore: ${err.message}`); }
922
+ }
923
+
898
924
  // Check plan completion
899
925
  if (isSuccess && item.meta?.item?.sourcePlan) {
900
926
  try { lifecycle.checkPlanCompletion(item.meta, config); } catch (err) { e.log('warn', `Orphan plan completion: ${err.message}`); }
@@ -64,7 +64,7 @@ const _BADGE_RE = /!\[.*\]\(https?:\/\/.*badge/i;
64
64
  const _PREVIEW_HEADING_RE =
65
65
  /^#{1,3}\s*(Firebase(?:\s+App\s+Distribution)?|Appetize|Preview|Deploy(?:ment)?|Status)\b/i;
66
66
 
67
- const _FIREBASE_BRAND_RE = /\bFirebase\s+App\s+Distribution\b/i;
67
+ const _FIREBASE_BRAND_RE = /\bFirebase\s+(?:App\s+Distribution|Hosting)\b/i;
68
68
  const _FIREBASE_URL_RE = /\bappdistribution\.firebase\b/i;
69
69
  const _APPETIZE_URL_RE = /\bappetize\.io\b/i;
70
70