codehere 0.6.0-alpha.1 → 0.6.1

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.
@@ -1460,6 +1460,14 @@ ${AGENT_BADGE_JS}
1460
1460
  feed.insertBefore(li, feed.firstChild);
1461
1461
  applyFilterToRow(li);
1462
1462
  empty.style.display = 'none';
1463
+ // v0.6 — cap DOM rows so a long-open /history tab doesn't accumulate
1464
+ // thousands of <li>s (~50 events/min × hours = browser slowdown).
1465
+ // Same cap-pattern as /threads (turns) and dashboard (sessions).
1466
+ // Newest-first prepend means the OLDEST is the LAST child; trim that.
1467
+ const MAX_FEED_ROWS = 500;
1468
+ while (feed.children.length > MAX_FEED_ROWS) {
1469
+ feed.removeChild(feed.lastChild);
1470
+ }
1463
1471
  }
1464
1472
 
1465
1473
  // Bug A5 — filter state. Active outcome filter and active tool filter
@@ -1587,6 +1595,12 @@ ${AGENT_BADGE_JS}
1587
1595
  const entry = JSON.parse(ev.data);
1588
1596
  entry._ts = parseTs(entry);
1589
1597
  events.push(entry);
1598
+ // v0.6 — paired with the DOM cap above. Stats only need recent
1599
+ // events; trimming the head keeps recomputeStats O(MAX) not O(forever).
1600
+ const MAX_EVENTS_IN_MEMORY = 500;
1601
+ if (events.length > MAX_EVENTS_IN_MEMORY) {
1602
+ events.splice(0, events.length - MAX_EVENTS_IN_MEMORY);
1603
+ }
1590
1604
  render(entry);
1591
1605
  recomputeStats();
1592
1606
  } catch { /* skip malformed */ }
@@ -1831,6 +1845,33 @@ ${SHARED_STYLES}
1831
1845
  }
1832
1846
  .readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
1833
1847
 
1848
+ /* v0.6.x P0 — failover offer banner. Amber stripe: "Agent A rate-limited,
1849
+ switch to Agent B?" with one-click dismiss + accept buttons. */
1850
+ .failover-banner {
1851
+ margin: 14px 0 0; padding: 14px 20px;
1852
+ border: 1px solid rgba(254,188,46,0.3);
1853
+ background: rgba(254,188,46,0.06);
1854
+ border-radius: 10px;
1855
+ font-size: 13px; line-height: 1.5;
1856
+ display: flex; align-items: center; justify-content: space-between;
1857
+ gap: 12px; flex-wrap: wrap;
1858
+ }
1859
+ .failover-banner .fb-msg { flex: 1; color: var(--fg-70); min-width: 200px; }
1860
+ .failover-banner .fb-msg b { color: var(--fg); font-weight: 600; }
1861
+ .failover-banner .fb-actions { display: flex; gap: 8px; flex-shrink: 0; }
1862
+ .failover-banner .fb-actions button {
1863
+ font-family: var(--mono); font-size: 11px;
1864
+ padding: 5px 14px; border-radius: 6px; cursor: pointer;
1865
+ border: 1px solid var(--border); background: transparent;
1866
+ color: var(--fg-70); transition: color 0.15s, border-color 0.15s;
1867
+ }
1868
+ .failover-banner .fb-actions button:hover { color: var(--fg); border-color: var(--fg-30); }
1869
+ .failover-banner .fb-actions button.fb-accept {
1870
+ background: rgba(254,188,46,0.12); border-color: rgba(254,188,46,0.4);
1871
+ color: var(--warn); font-weight: 600;
1872
+ }
1873
+ .failover-banner .fb-actions button.fb-accept:hover { background: rgba(254,188,46,0.2); }
1874
+
1834
1875
  /* v0.6 S2 — first-time onboarding card. Quiet authority: peach
1835
1876
  accent border + subtle glass, doesn't compete with the textarea
1836
1877
  below. Removed once dismissed OR once any session exists. */
@@ -2171,18 +2212,23 @@ ${SHARED_STYLES}
2171
2212
  .session.observed { display: none; }
2172
2213
  body.advanced-on .session.observed { display: block; }
2173
2214
 
2174
- /* claim_user_persona — session list limit. Default shows the 5
2175
- most-recent cards; "Show all" reveals the rest. .show-all-on body
2176
- class lifts the cap. CSS-only via :nth-child so attachSession
2177
- prepend semantics stay simple. */
2178
- #sessions > .session:nth-child(n+6) { display: none; }
2179
- body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
2215
+ /* claim_user_persona — session list limit. Default shows the 5
2216
+ most-recent cards; "Show all" reveals the rest. .show-all-on body
2217
+ class lifts the cap. CSS-only via :nth-child so attachSession
2218
+ prepend semantics stay simple.
2219
+ CRITICAL: running sessions are NEVER capped — a user must always
2220
+ see their actively-working session, even if it's the 10th card. */
2221
+ #sessions > .session:nth-child(n+6):not(.status-running) { display: none; }
2222
+ body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
2180
2223
  /* When advanced is also off, observed rows beyond cap stay hidden. */
2181
2224
  body.show-all-sessions #sessions > .session.observed { display: none; }
2182
2225
  body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
2226
+ .sessions-actions {
2227
+ display: flex; gap: 10px; justify-content: center; align-items: center;
2228
+ margin: 14px 0 0;
2229
+ }
2183
2230
  .show-more-sessions {
2184
2231
  display: none; /* shown via JS when count > 5 */
2185
- margin: 14px auto;
2186
2232
  padding: 8px 16px;
2187
2233
  background: transparent;
2188
2234
  color: var(--fg-50);
@@ -2194,6 +2240,17 @@ ${SHARED_STYLES}
2194
2240
  }
2195
2241
  .show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
2196
2242
  body.show-all-sessions .show-more-sessions { display: none; }
2243
+ .stop-all-sessions {
2244
+ padding: 8px 16px;
2245
+ background: transparent;
2246
+ color: var(--block);
2247
+ border: 1px solid rgba(255,95,87,0.3);
2248
+ border-radius: 999px;
2249
+ font-family: var(--mono); font-size: 11px;
2250
+ cursor: pointer;
2251
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
2252
+ }
2253
+ .stop-all-sessions:hover { color: var(--fg); border-color: var(--block); background: rgba(255,95,87,0.08); }
2197
2254
 
2198
2255
  .session-controls { display: flex; gap: 8px; }
2199
2256
  .session-controls button {
@@ -2226,6 +2283,39 @@ ${SHARED_STYLES}
2226
2283
 
2227
2284
  .session-body { padding: 0 20px 16px; }
2228
2285
 
2286
+ /* Session threading — visual link between original and failover sessions.
2287
+ The original card shows "↻ continued as [agent]" with a link to the new card.
2288
+ The new card shows "Continued from [agent]" at the top. */
2289
+ .session-continued-as {
2290
+ margin: 0 20px 12px;
2291
+ padding: 8px 12px;
2292
+ border: 1px dashed rgba(255,154,118,0.3);
2293
+ border-radius: 8px;
2294
+ background: rgba(255,154,118,0.04);
2295
+ font-family: var(--mono);
2296
+ font-size: 11px;
2297
+ color: var(--fg-70);
2298
+ }
2299
+ .session-continued-as a {
2300
+ color: var(--accent);
2301
+ text-decoration: none;
2302
+ font-weight: 500;
2303
+ }
2304
+ .session-continued-as a:hover { text-decoration: underline; }
2305
+ .session-continued-from {
2306
+ margin: 0 0 8px;
2307
+ padding: 6px 12px;
2308
+ border-left: 2px solid rgba(255,154,118,0.3);
2309
+ font-family: var(--mono);
2310
+ font-size: 10.5px;
2311
+ color: var(--fg-50);
2312
+ }
2313
+ .session-continued-from a {
2314
+ color: var(--accent);
2315
+ text-decoration: none;
2316
+ }
2317
+ .session-continued-from a:hover { text-decoration: underline; }
2318
+
2229
2319
  /* P18 slice 4 — permission gate panel. Renders inside the session
2230
2320
  card when the per-tool hook blocked something. Per the dogfood-
2231
2321
  learning + overconfidence-guard: framed as "Codehere noticed",
@@ -2551,6 +2641,8 @@ ${SHARED_STYLES}
2551
2641
  </div>
2552
2642
 
2553
2643
  <div class="intake">
2644
+ <h1>Delegate coding tasks to any AI agent.</h1>
2645
+ <p class="sub">Type what needs doing — codehere picks the right agent, watches every action, and reports back.</p>
2554
2646
  <textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
2555
2647
  <!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
2556
2648
  dropped the cm-cwd pill — the cwd input below is the authoritative
@@ -2592,6 +2684,12 @@ ${SHARED_STYLES}
2592
2684
  Goal: textarea is the focal point above the fold; chrome subordinates. -->
2593
2685
  <div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
2594
2686
 
2687
+ <!-- v0.6.x P0 — failover offer banner. Surfaces when an adapter hits
2688
+ rate-limit / quota so the user can manually switch agents instead
2689
+ of waiting for auto-failover. Hidden by default; shown via JS when
2690
+ offers arrive from /api/failover-offers. -->
2691
+ <div class="failover-banner" id="failover-banner" style="display:none"></div>
2692
+
2595
2693
  <!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
2596
2694
  The prior 4-stat row mixed two definitions of "agents" (the AGENTS
2597
2695
  right-pane shows installed-and-ready agents; "agents on machine" stat
@@ -2611,9 +2709,12 @@ ${SHARED_STYLES}
2611
2709
  divider here. The .section-label "Sessions" below already provides
2612
2710
  visual separation; an extra 1px gradient line was decoration not
2613
2711
  information. -->
2614
- <div class="section-label" style="position:relative;z-index:1">Sessions</div>
2615
- <div id="sessions" style="position:relative;z-index:1"></div>
2616
- <button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
2712
+ <div class="section-label" style="position:relative;z-index:1">Sessions</div>
2713
+ <div id="sessions" style="position:relative;z-index:1"></div>
2714
+ <div class="sessions-actions" id="sessions-actions">
2715
+ <button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
2716
+ <button class="stop-all-sessions" id="stop-all-sessions" type="button" style="display:none">Stop all running</button>
2717
+ </div>
2617
2718
  <div id="empty">
2618
2719
  <div class="pill">No sessions yet. Assign one above.</div>
2619
2720
  </div>
@@ -3468,6 +3569,7 @@ ${AGENT_BADGE_JS}
3468
3569
  const card = document.createElement('div');
3469
3570
  card.className = 'session';
3470
3571
  card.dataset.sessionId = session.sessionId;
3572
+ card.dataset.status = session.status;
3471
3573
  // C3 · Persisted sessions are from a prior daemon run — no live stream, no
3472
3574
  // kill. Still useful: user sees their previous Final reports across restarts.
3473
3575
  const isPersisted = session.provenance === 'persisted';
@@ -3503,6 +3605,8 @@ ${AGENT_BADGE_JS}
3503
3605
  '</div>' +
3504
3606
  '</div>' +
3505
3607
  '<div class="session-body" data-view="thread">' +
3608
+ // Session threading — "Continued from" indicator for failover sessions
3609
+ (session.continuedFrom ? '<div class="session-continued-from">Continued from <a href="#' + escape(session.continuedFrom) + '">' + escape(agentLabel(session.agent)) + '</a> session</div>' : '') +
3506
3610
  // Order: thread messages (chronological) → final report (closing
3507
3611
  // summary, rendered as a block when artifacts arrive) → reply input.
3508
3612
  // Apr 29 bug fix: previously final-report was BEFORE thread-messages
@@ -3555,6 +3659,16 @@ ${AGENT_BADGE_JS}
3555
3659
  // storage, but for the typical flow this is "I'm using it now".
3556
3660
  dismissOnboarding();
3557
3661
 
3662
+ // Session threading — "continued as" indicator for sessions that already
3663
+ // have a failover successor (e.g., after page refresh).
3664
+ if (session.continuedAs) {
3665
+ var continuedAsEl = document.createElement('div');
3666
+ continuedAsEl.className = 'session-continued-as';
3667
+ continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(session.continuedAs) + '">new session</a> · session ' + escape(session.continuedAs.slice(0, 8));
3668
+ var headEl2 = card.querySelector('.session-head');
3669
+ if (headEl2 && headEl2.parentNode) headEl2.parentNode.insertBefore(continuedAsEl, headEl2.nextSibling);
3670
+ }
3671
+
3558
3672
  const logEl = card.querySelector('.event-log');
3559
3673
  const pillEl = card.querySelector('.status-pill');
3560
3674
  const killBtn = card.querySelector('.kill');
@@ -3832,6 +3946,7 @@ ${AGENT_BADGE_JS}
3832
3946
  const replyStatusEl = card.querySelector('.reply-status');
3833
3947
  if (!session.canAppendMessage) {
3834
3948
  threadInputEl.classList.add('disabled');
3949
+ threadInputEl.title = 'Replies require a live session — re-spawn to continue this thread';
3835
3950
  // All adapters wire appendMessage as of v0.6.0 Slices 1-3. This branch
3836
3951
  // now only fires for sessions evicted from in-memory tracking (the
3837
3952
  // canAppendMessage flag persists from spawn but the API endpoint
@@ -4100,21 +4215,28 @@ ${AGENT_BADGE_JS}
4100
4215
  // Pattern 13: tool-prefixed verb takes over while a step is active.
4101
4216
  // Most-recent active step's label becomes the micro-activity text;
4102
4217
  // when no step is active we fall back to whimsy rotation.
4103
- // Pattern 17: if the active step's elapsed > 5s, append a "· Ns"
4104
- // suffix so the user can see how long this single tool has been
4105
- // running. Simple slice — doesn't yet upgrade to the bar / ESC hint
4106
- // at 15-30s thresholds; that's deferred until the affordance
4107
- // demands it in real dogfood.
4218
+ // Pattern 17: 3-bucket long-op indicator — 5s timer → 15s bar → 30s
4219
+ // still-running + ESC hint.
4108
4220
  const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
4109
4221
  if (active && session.status === 'running') {
4110
4222
  let label = String(active.label || 'Working on it…');
4111
4223
  const startedAt = Number(active.startedAt || 0);
4112
4224
  if (startedAt > 0) {
4113
- const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
4225
+ const elapsedMs = Date.now() - startedAt;
4226
+ const elapsedSec = Math.floor(elapsedMs / 1000);
4114
4227
  if (elapsedSec >= 5) {
4115
- // Use fmtElapsed for consistency with the chip's elapsed format.
4116
- label += ' · ' + fmtElapsed(Date.now() - startedAt);
4117
- if (elapsedSec >= 30) label += ' · ESC to cancel';
4228
+ const timer = fmtElapsed(elapsedMs);
4229
+ // 15s threshold: add indeterminate progress bar
4230
+ if (elapsedSec >= 15) {
4231
+ const barFill = Math.floor((elapsedSec % 15) / 15 * 8);
4232
+ const barEmpty = 8 - barFill;
4233
+ const bar = '▮'.repeat(barFill) + '░'.repeat(barEmpty);
4234
+ label += ' · ' + timer + ' ' + bar;
4235
+ } else {
4236
+ // 5–15s: elapsed timer only
4237
+ label += ' · ' + timer;
4238
+ }
4239
+ if (elapsedSec >= 30) label += ' · still running · ESC to cancel';
4118
4240
  }
4119
4241
  }
4120
4242
  setMicroState('tool', label);
@@ -4136,6 +4258,7 @@ ${AGENT_BADGE_JS}
4136
4258
  // completed task is still generating its report (placeholder state).
4137
4259
  const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
4138
4260
  const hasContent = artifacts || finalReport;
4261
+ const reportFailed = session.failedReport === true;
4139
4262
  // v0.6.x — bound the "Generating report…" placeholder to the actual
4140
4263
  // generation window. Final Report typically returns within 30s; after
4141
4264
  // 90s post-completion with no report, the report either failed silently
@@ -4143,10 +4266,10 @@ ${AGENT_BADGE_JS}
4143
4266
  // wiped). Either way, "Generating report…" forever is dishonest.
4144
4267
  const REPORT_WAIT_BUDGET_MS = 90_000;
4145
4268
  const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
4146
- const stillWaitingForReport = isCompleted && !finalReport && sinceCompleted < REPORT_WAIT_BUDGET_MS;
4147
- const reportTimedOut = isCompleted && !finalReport && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
4269
+ const stillWaitingForReport = isCompleted && !finalReport && !reportFailed && sinceCompleted < REPORT_WAIT_BUDGET_MS;
4270
+ const reportTimedOut = isCompleted && !finalReport && !reportFailed && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
4148
4271
  const isAwaitingReport = stillWaitingForReport;
4149
- if (!hasContent && !isAwaitingReport && !reportTimedOut) { finalReportEl.style.display = 'none'; return; }
4272
+ if (!hasContent && !isAwaitingReport && !reportTimedOut && !reportFailed) { finalReportEl.style.display = 'none'; return; }
4150
4273
  finalReportEl.style.display = 'block';
4151
4274
  const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
4152
4275
  const tests = artifacts?.tests;
@@ -4162,6 +4285,8 @@ ${AGENT_BADGE_JS}
4162
4285
  '</div>';
4163
4286
  } else if (isAwaitingReport) {
4164
4287
  html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
4288
+ } else if (reportFailed) {
4289
+ html += '<div class="fr-summary fr-summary-pending"><p>Final Report generation failed. The agent finished but the summary couldn\\'t be created. Re-spawn to get a fresh report.</p></div>';
4165
4290
  } else if (reportTimedOut) {
4166
4291
  // Honest message instead of perpetual "Generating report…". Most
4167
4292
  // common cause: daemon restarted (Final Report is in-memory only,
@@ -4265,29 +4390,39 @@ ${AGENT_BADGE_JS}
4265
4390
  });
4266
4391
  built.el.querySelector('.ra-primary').addEventListener('click', async function() {
4267
4392
  if (!built.copy.canFailover) {
4268
- // Auth/setup path — open the agents pane (already in DOM)
4269
- // instead of spawning a new session with the same broken auth.
4270
4393
  built.el.remove();
4271
4394
  return;
4272
4395
  }
4273
4396
  var primary = built.el.querySelector('.ra-primary');
4274
4397
  primary.disabled = true;
4398
+ primary.textContent = 'Switching…';
4275
4399
  try {
4276
- await fetch('/api/task', {
4400
+ var r = await fetch('/api/failover-accept', {
4277
4401
  method: 'POST',
4278
4402
  headers: { 'Content-Type': 'application/json' },
4279
- body: JSON.stringify({
4280
- prompt: session.prompt,
4281
- agent: nextAgent,
4282
- // Reuse the current intake cwd — the failover spawns
4283
- // against whichever repo the user is currently scoped
4284
- // to. Acceptable trade-off vs. a per-session lookup.
4285
- cwd: (cwdInput && cwdInput.value && cwdInput.value.trim()) || ''
4286
- })
4403
+ body: JSON.stringify({ sessionId: session.sessionId, targetAgent: nextAgent })
4287
4404
  });
4288
- built.el.remove();
4405
+ var d = await r.json();
4406
+ if (d.error || (!d.ok || !d.newSessionId)) {
4407
+ built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> ' + escape(d.detail || 'Target agent not ready');
4408
+ primary.style.display = 'none';
4409
+ built.el.querySelector('.ra-secondary').textContent = 'Dismiss';
4410
+ return;
4411
+ }
4412
+ // Session threading: add "continued as" link on the original card
4413
+ var continuedAsEl = document.createElement('div');
4414
+ continuedAsEl.className = 'session-continued-as';
4415
+ continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(nextAgent) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
4416
+ // Insert after session-head
4417
+ var headEl = card.querySelector('.session-head');
4418
+ if (headEl && headEl.parentNode) headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
4419
+ built.el.querySelector('.ra-msg').innerHTML = '↻ Continued as <b>' + escape(nextAgent) + '</b>';
4420
+ primary.style.display = 'none';
4421
+ built.el.querySelector('.ra-secondary').textContent = 'OK';
4289
4422
  } catch {
4423
+ built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> Network error';
4290
4424
  primary.disabled = false;
4425
+ primary.textContent = 'Retry';
4291
4426
  }
4292
4427
  });
4293
4428
  }
@@ -4663,6 +4798,7 @@ ${AGENT_BADGE_JS}
4663
4798
  pruneStaleAgents(seen);
4664
4799
  updateBanner(d);
4665
4800
  updateShowMoreButton();
4801
+ updateStopAllButton();
4666
4802
  }).catch(() => { /* fine */ });
4667
4803
  }
4668
4804
  // claim_user_persona — show "Show all sessions (N more)" button only
@@ -4684,6 +4820,24 @@ ${AGENT_BADGE_JS}
4684
4820
  document.body.classList.add('show-all-sessions');
4685
4821
  updateShowMoreButton();
4686
4822
  });
4823
+
4824
+ // Stop all running sessions — one click kills every active session.
4825
+ const stopAllBtn = document.getElementById('stop-all-sessions');
4826
+ function updateStopAllButton() {
4827
+ if (!stopAllBtn) return;
4828
+ const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
4829
+ stopAllBtn.style.display = runningCards.length > 1 ? 'block' : 'none';
4830
+ stopAllBtn.textContent = 'Stop all running (' + runningCards.length + ')';
4831
+ }
4832
+ if (stopAllBtn) stopAllBtn.addEventListener('click', function() {
4833
+ if (!confirm('Stop all running sessions?')) return;
4834
+ const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
4835
+ runningCards.forEach(function(card) {
4836
+ const killBtn = card.querySelector('.session-controls button.kill');
4837
+ if (killBtn && !killBtn.disabled) killBtn.click();
4838
+ });
4839
+ updateStopAllButton();
4840
+ });
4687
4841
  loadSessions();
4688
4842
  // Refresh detected-agents list + banner every 5s. Iter 12 — also patches
4689
4843
  // existing session cards with new finalReport prose when it arrives async.
@@ -4753,6 +4907,88 @@ ${AGENT_BADGE_JS}
4753
4907
  }
4754
4908
 
4755
4909
  checkReadiness();
4910
+
4911
+ // ── Failover offer banner (P0) ──────────────────────────────────
4912
+ // Polls /api/failover-offers every 5s. When offers exist, shows a
4913
+ // banner: "Claude rate-limited — switch to Codex?" with one-click
4914
+ // accept (re-spawns on the target agent) and dismiss buttons.
4915
+ const fbEl = document.getElementById('failover-banner');
4916
+ let fbDismissed = false;
4917
+
4918
+ function renderFailoverOffers(offers) {
4919
+ if (!offers || offers.length === 0 || fbDismissed) {
4920
+ fbEl.style.display = 'none';
4921
+ return;
4922
+ }
4923
+ fbEl.style.display = 'flex';
4924
+ const parts = offers.map(function(o) {
4925
+ var msg = '<b>' + escape(o.signal.fromAgent) + '</b> hit ' + escape(o.signal.errorCode) +
4926
+ ' — switch to <b>' + escape(o.target) + '</b>?';
4927
+ var actions = '<div class="fb-actions">' +
4928
+ '<button class="fb-accept" data-session="' + escape(o.sessionId) + '" data-target="' + escape(o.target) + '">Switch</button>' +
4929
+ '<button class="fb-dismiss" data-session="' + escape(o.sessionId) + '">Dismiss</button>' +
4930
+ '</div>';
4931
+ return '<div class="fb-msg">' + msg + '</div>' + actions;
4932
+ });
4933
+ fbEl.innerHTML = parts.join('');
4934
+
4935
+ // Wire accept buttons
4936
+ fbEl.querySelectorAll('.fb-accept').forEach(function(btn) {
4937
+ btn.addEventListener('click', function() {
4938
+ var sessionId = btn.dataset.session;
4939
+ var target = btn.dataset.target;
4940
+ btn.disabled = true;
4941
+ btn.textContent = 'Switching…';
4942
+ fetch('/api/failover-accept', {
4943
+ method: 'POST',
4944
+ headers: { 'Content-Type': 'application/json' },
4945
+ body: JSON.stringify({ sessionId: sessionId, targetAgent: target }),
4946
+ }).then(function(r) { return r.json(); }).then(function(d) {
4947
+ if (d.error) {
4948
+ fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> ' + escape(d.error) + (d.detail ? ' — ' + escape(d.detail) : '') + '</div>';
4949
+ return;
4950
+ }
4951
+ if (!d.ok || !d.newSessionId) {
4952
+ fbEl.innerHTML = '<div class="fb-msg"><b>Failover unavailable:</b> ' + escape(d.detail || 'Target agent not ready') + '</div>';
4953
+ return;
4954
+ }
4955
+ // Session threading: add "continued as" link on the original card
4956
+ var origCard = document.querySelector('[data-session-id="' + escape(sessionId) + '"]');
4957
+ if (origCard && !origCard.querySelector('.session-continued-as')) {
4958
+ var headEl = origCard.querySelector('.session-head');
4959
+ if (headEl && headEl.parentNode) {
4960
+ var continuedAsEl = document.createElement('div');
4961
+ continuedAsEl.className = 'session-continued-as';
4962
+ continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(target) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
4963
+ headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
4964
+ }
4965
+ }
4966
+ fbEl.innerHTML = '<div class="fb-msg">↻ Switched to <b>' + escape(target) + '</b>. New session: <b>' + escape(d.newSessionId.slice(0, 8)) + '</b></div>' +
4967
+ '<div class="fb-actions"><button class="fb-dismiss" id="fb-success-dismiss">OK</button></div>';
4968
+ document.getElementById('fb-success-dismiss').addEventListener('click', function() {
4969
+ fbDismissed = true;
4970
+ fbEl.style.display = 'none';
4971
+ });
4972
+ }).catch(function() {
4973
+ fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> Network error. Check if the daemon is running.</div>';
4974
+ });
4975
+ });
4976
+ });
4977
+
4978
+ // Wire dismiss buttons (initial render)
4979
+ fbEl.querySelectorAll('.fb-dismiss').forEach(function(btn) {
4980
+ btn.addEventListener('click', function() {
4981
+ fbDismissed = true;
4982
+ fbEl.style.display = 'none';
4983
+ });
4984
+ });
4985
+ }
4986
+
4987
+ setInterval(function() {
4988
+ fetch('/api/failover-offers').then(function(r) { return r.json(); }).then(function(d) {
4989
+ renderFailoverOffers(d.offers);
4990
+ }).catch(function() { /* fine */ });
4991
+ }, 5000);
4756
4992
  })();
4757
4993
  </script>
4758
4994
  </body>
@@ -4948,6 +5184,26 @@ ${SHARED_STYLES}
4948
5184
  .task .steps .step { padding: 2px 0; }
4949
5185
  .task .steps .step.done::before { content: '✓ '; color: var(--allow); }
4950
5186
 
5187
+ /* Pattern 21 · Buffered reveal — cascade animation for completed steps */
5188
+ .task .steps.buffered-reveal .step {
5189
+ opacity: 0;
5190
+ transform: translateY(4px);
5191
+ animation: br-cascade 80ms ease-out forwards;
5192
+ }
5193
+ .task .steps.buffered-reveal .step:nth-child(1) { animation-delay: 0ms; }
5194
+ .task .steps.buffered-reveal .step:nth-child(2) { animation-delay: 80ms; }
5195
+ .task .steps.buffered-reveal .step:nth-child(3) { animation-delay: 160ms; }
5196
+ .task .steps.buffered-reveal .step:nth-child(4) { animation-delay: 240ms; }
5197
+ .task .steps.buffered-reveal .step:nth-child(5) { animation-delay: 320ms; }
5198
+ .task .steps.buffered-reveal .step:nth-child(6) { animation-delay: 400ms; }
5199
+ .task .steps.buffered-reveal .step:nth-child(7) { animation-delay: 480ms; }
5200
+ .task .steps.buffered-reveal .step:nth-child(8) { animation-delay: 560ms; }
5201
+ .task .steps.buffered-reveal .step:nth-child(9) { animation-delay: 640ms; }
5202
+ .task .steps.buffered-reveal .step:nth-child(10) { animation-delay: 720ms; }
5203
+ @keyframes br-cascade {
5204
+ to { opacity: 1; transform: translateY(0); }
5205
+ }
5206
+
4951
5207
  .empty {
4952
5208
  text-align: center; padding: 60px 24px; color: var(--fg-50); font-family: var(--mono); font-size: 13px;
4953
5209
  }
@@ -5192,9 +5448,19 @@ ${AGENT_BADGE_JS}
5192
5448
 
5193
5449
  for (const t of renderedTurns) {
5194
5450
  const status = (t.status || '').toLowerCase();
5195
- const stepsHtml = (t.steps && t.steps.length)
5196
- ? '<div class="steps">' + t.steps.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') + '</div>'
5197
- : '';
5451
+ // v0.6 — cap per-task steps at last 5 (mirrors dashboard renderSteps
5452
+ // tail-3 pattern). A long task with 100+ steps × 25 turns rendered
5453
+ // = 2500 step DOM nodes. Show last 5 + "+N more" pill.
5454
+ let stepsHtml = '';
5455
+ if (t.steps && t.steps.length) {
5456
+ const STEP_TAIL = 5;
5457
+ const stepTail = t.steps.slice(-STEP_TAIL);
5458
+ const stepHidden = t.steps.length - stepTail.length;
5459
+ stepsHtml = '<div class="steps">' +
5460
+ stepTail.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') +
5461
+ (stepHidden > 0 ? '<div class="step muted">+' + stepHidden + ' earlier step' + (stepHidden === 1 ? '' : 's') + '</div>' : '') +
5462
+ '</div>';
5463
+ }
5198
5464
  const durHtml = (t.durationMs != null)
5199
5465
  ? '<div class="duration">Duration: <b>' + fmtDuration(t.durationMs) + '</b></div>'
5200
5466
  : (status === 'running' ? '<div class="duration">Running…</div>' : '');
@@ -478,21 +478,31 @@ export function mirrorCompletedToThread(rec) {
478
478
  artifacts: rec.artifacts,
479
479
  });
480
480
  }
481
- /**
482
- * v0.6.x — mirror a successful appendMessage() reply to the project thread.
483
- *
484
- * Pre-fix: only initial spawn + completion wrote to thread.jsonl. Reply turns
485
- * (the `/api/task/:id/message` endpoint, the dashboard reply box, and the new
486
- * /threads reply bar) updated only the in-memory session. Result: /threads
487
- * showed the original spawn but every follow-up turn was invisible. Founder
488
- * flagged it 2026-05-03 as a v0.6.x cleanup item.
489
- *
490
- * Schema choice: write a single complete `{type: 'task', sessionId: <new>,
491
- * parentSessionId: <orig>, ...}` entry rather than splitting spawn+complete.
492
- * appendMessage is synchronous-from-caller's-POV (no streaming), so the whole
493
- * turn is known when this fires. The reader's sessionId merge logic surfaces
494
- * each reply as its own chronological row on /threads.
495
- */
481
+ /** Label map for raw error codes → user-facing text. */
482
+ const FAILOVER_LABELS = {
483
+ rate_limit: 'hit a rate limit',
484
+ quota_exceeded: 'exhausted its quota',
485
+ credit_low: 'ran low on credits',
486
+ error: 'encountered an error',
487
+ };
488
+ export function mirrorFailoverToThread(rec) {
489
+ if (isTempDir(rec.cwd))
490
+ return;
491
+ const slug = slugForCwd(rec.cwd);
492
+ const reasonLabel = FAILOVER_LABELS[rec.reason] ?? 'became unavailable';
493
+ const fromLabel = rec.fromAgent;
494
+ const toLabel = rec.toAgent;
495
+ appendThreadEntry(slug, {
496
+ type: 'task',
497
+ sessionId: rec.sessionId,
498
+ agent: 'system',
499
+ prompt: `↻ Switched from ${fromLabel} to ${toLabel} (${reasonLabel})`,
500
+ status: 'completed',
501
+ startedAt: Date.now(),
502
+ completedAt: Date.now(),
503
+ durationMs: 0,
504
+ });
505
+ }
496
506
  export function mirrorReplyToThread(rec) {
497
507
  if (isTempDir(rec.cwd))
498
508
  return;
@@ -692,6 +692,35 @@ function renderEvent(event) {
692
692
  colors.dim(' · ' + (data.reason ?? '')));
693
693
  return null;
694
694
  }
695
+ case 'codehere:steps': {
696
+ const data = event.data;
697
+ const steps = data?.steps;
698
+ if (!steps || steps.length === 0)
699
+ return null;
700
+ const lastStep = steps[steps.length - 1];
701
+ if (!lastStep)
702
+ return null;
703
+ const state = lastStep.state || 'pending';
704
+ const label = String(lastStep.label || '');
705
+ if (state === 'active') {
706
+ console.log(colors.dim(' │ ') + colors.cyan('●') + ' ' + label);
707
+ return label;
708
+ }
709
+ if (state === 'done' || state === 'failed') {
710
+ const icon = state === 'done' ? colors.green('✓') : colors.red('✗');
711
+ console.log(colors.dim(' │ ') + icon + ' ' + label);
712
+ // P22 / Track B #8 — render last 3 lines of step output for done/failed
713
+ if (lastStep.output && String(lastStep.output).trim()) {
714
+ const outLines = String(lastStep.output).split(String.fromCharCode(10)).filter(l => l.trim());
715
+ const preview = outLines.slice(-3);
716
+ for (const ln of preview) {
717
+ console.log(colors.dim(' │ ') + colors.gray(ln));
718
+ }
719
+ }
720
+ return label;
721
+ }
722
+ return null;
723
+ }
695
724
  case 'stdout':
696
725
  case 'stderr': {
697
726
  const data = event.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.6.0-alpha.1",
3
+ "version": "0.6.1",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -21,6 +21,7 @@
21
21
  "build": "tsc",
22
22
  "postbuild": "chmod +x dist/index.js",
23
23
  "repomap": "node scripts/gen-repo-map.mjs",
24
+ "pattern-counts": "node scripts/print-pattern-counts.mjs",
24
25
  "prepublishOnly": "npm run build && find dist -name '*.map' -delete && find dist -name '*.d.ts' -delete && find dist -name '*.d.ts.map' -delete",
25
26
  "start": "node dist/index.js",
26
27
  "dev": "tsx src/index.ts",
@@ -59,6 +60,7 @@
59
60
  },
60
61
  "homepage": "https://codehere.uk",
61
62
  "dependencies": {
63
+ "@mohammednagy/graphify-ts": "^0.22.3",
62
64
  "boxen": "^8.0.1",
63
65
  "chalk": "^5.3.0",
64
66
  "cli-progress": "^3.12.0",