@echomem/mcp 1.4.7 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hud/web.js CHANGED
@@ -170,6 +170,15 @@ export const HUD_HTML = String.raw `<!doctype html>
170
170
  text-transform: uppercase;
171
171
  color: var(--faint);
172
172
  }
173
+ /* When drifted, the bubble's second line becomes Echo speaking, not a log line. */
174
+ .client.voice {
175
+ font-family: inherit;
176
+ font-size: 11.5px;
177
+ font-weight: 750;
178
+ letter-spacing: 0.01em;
179
+ text-transform: none;
180
+ color: var(--brick);
181
+ }
173
182
  .details {
174
183
  display: none;
175
184
  position: relative;
@@ -206,19 +215,65 @@ export const HUD_HTML = String.raw `<!doctype html>
206
215
  }
207
216
  .tab:hover { background: rgba(0, 0, 0, .08); }
208
217
  .tab.active { border-color: rgba(0, 0, 0, .38); background: rgba(0, 0, 0, .10); color: #111; font-weight: 700; }
218
+ .tab.auto-pick { border-color: rgba(26, 58, 143, .2); background: rgba(26, 58, 143, .045); color: var(--muted); font-weight: 650; }
209
219
  .spin {
210
220
  display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
211
221
  border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
212
222
  animation: echospin .8s linear infinite;
213
223
  }
214
224
  @keyframes echospin { to { transform: rotate(360deg); } }
225
+ /* Mini mode: a small always-on pill instead of the full mascot+bubble. Opening the panel
226
+ temporarily shows the full layout; closing returns to the pill. */
227
+ #miniPill {
228
+ display: none; align-items: center; justify-content: center; gap: 7px; width: calc(100% - 16px); height: 34px;
229
+ margin: 7px 0 0 8px; padding: 0 13px; cursor: pointer; user-select: none;
230
+ border: 1.6px solid rgba(26, 58, 143, 0.75); border-radius: 999px; background: #fdfaf2;
231
+ filter: var(--shadow); font-family: "JetBrains Mono", ui-monospace, monospace;
232
+ font-size: 11.5px; font-weight: 700; color: var(--ink); letter-spacing: 0.02em;
233
+ }
234
+ #miniPill .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
235
+ #miniPill .mdot.amber { background: var(--amber); }
236
+ #miniPill .mdot.red { background: #ff5f57; }
237
+ #miniPill .mdot.idle { background: #c9c5b8; }
238
+ #mtext { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
239
+ #hud.mini:not(.open) .glance { height: 48px; }
240
+ #hud.mini:not(.open) .mascot-stage, #hud.mini:not(.open) .status-bubble, #hud.mini:not(.open) .drag { display: none; }
241
+ #hud.mini:not(.open) #miniPill { display: inline-flex; }
242
+ .cornerbtn {
243
+ position: absolute; top: 7px; right: 10px; z-index: 3;
244
+ width: 20px; height: 20px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
245
+ border: 1px solid rgba(0, 0, 0, .16); background: rgba(255, 255, 255, .7); color: var(--faint);
246
+ cursor: pointer; font-family: inherit;
247
+ }
248
+ .cornerbtn:hover { background: #fff; color: #111; }
215
249
  .headctl { display: flex; align-items: center; gap: 8px; }
216
250
  .switchbtn {
251
+ display: inline-flex; align-items: center; justify-content: center;
217
252
  width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
218
253
  border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
219
254
  cursor: pointer; font-family: inherit;
220
255
  }
221
256
  .switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
257
+ .agent-switch {
258
+ width: auto;
259
+ min-width: 116px;
260
+ max-width: 154px;
261
+ height: 28px;
262
+ gap: 6px;
263
+ padding: 0 10px 0 7px;
264
+ border-radius: 999px;
265
+ background: rgba(255, 255, 255, .78);
266
+ color: #1f1f1f;
267
+ font-size: 10.5px;
268
+ font-weight: 760;
269
+ letter-spacing: 0;
270
+ white-space: nowrap;
271
+ }
272
+ .agent-switch:hover { background: #fff; border-color: rgba(26, 58, 143, .34); }
273
+ .agent-switch[data-target-family="claude"] { border-color: rgba(217, 119, 87, .42); }
274
+ .agent-switch[data-target-family="codex"] { border-color: rgba(57, 65, 255, .34); }
275
+ .switch-icon { width: 16px; height: 16px; flex: 0 0 auto; object-fit: contain; }
276
+ .switch-copy { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
222
277
  .reveal {
223
278
  margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
224
279
  border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
@@ -320,23 +375,44 @@ export const HUD_HTML = String.raw `<!doctype html>
320
375
  background: rgba(199, 55, 47, 0.07);
321
376
  color: #7a241e;
322
377
  }
378
+ /* Breathing CTA: quiet ghost pill while the session is healthy... */
323
379
  button.renew-btn {
324
380
  display: none;
325
- width: 100%;
381
+ width: auto;
326
382
  margin-top: 12px;
327
- border: 1px solid rgba(199, 55, 47, 0.3);
383
+ border: 1px solid rgba(199, 55, 47, 0.28);
328
384
  border-radius: 999px;
329
- padding: 9px 10px;
330
- background: rgba(199, 55, 47, 0.08);
385
+ padding: 6px 16px;
386
+ background: transparent;
331
387
  color: var(--brick);
332
388
  font-weight: 750;
333
- font-size: 13px;
389
+ font-size: 11.5px;
334
390
  cursor: pointer;
335
- transition: background 0.2s ease, opacity 0.2s ease;
391
+ transition: all 0.25s ease;
336
392
  }
337
- button.renew-btn:hover { background: rgba(199, 55, 47, 0.14); }
338
- button.renew-btn:active { background: rgba(199, 55, 47, 0.22); }
339
- button.renew-btn.loading { opacity: 0.6; cursor: wait; }
393
+ /* ...and a full-size primary the moment the session drifts. */
394
+ button.renew-btn.urgent {
395
+ width: 100%;
396
+ padding: 11px 12px;
397
+ border-color: var(--brick);
398
+ background: var(--brick);
399
+ color: #fff7ef;
400
+ font-size: 14px;
401
+ box-shadow: 0 10px 22px -14px rgba(199, 55, 47, 0.7);
402
+ }
403
+ button.renew-btn b { font-weight: 800; }
404
+ button.renew-btn small { display: none; }
405
+ button.renew-btn.urgent small, button.renew-btn.loading small {
406
+ display: block;
407
+ margin-top: 3px;
408
+ font-size: 10.5px;
409
+ font-weight: 600;
410
+ opacity: 0.85;
411
+ white-space: normal;
412
+ }
413
+ button.renew-btn:hover { background: rgba(199, 55, 47, 0.12); }
414
+ button.renew-btn.urgent:hover { background: #b32e27; }
415
+ button.renew-btn.loading { width: 100%; opacity: 0.75; cursor: wait; }
340
416
  .renew-result {
341
417
  display: none;
342
418
  margin-top: 10px;
@@ -427,10 +503,12 @@ export const HUD_HTML = String.raw `<!doctype html>
427
503
  <span class="main" id="main">Checking context...</span>
428
504
  <span class="client" id="client">EchoMem HUD</span>
429
505
  </span>
506
+ <button class="cornerbtn" id="minibtn" title="Shrink to mini pill">&ndash;</button>
430
507
  </div>
508
+ <div id="miniPill" title="EchoMem — click to expand"><span class="mdot" id="mdot"></span><span id="mtext">EchoMem</span></div>
431
509
  </div>
432
510
  <div class="details">
433
- <div class="head"><span class="upd" id="updated"></span><span class="headctl"><span class="turn" id="turn"></span><button class="switchbtn" id="switchbtn" title="Switch agent" style="display:none">⇄</button></span></div>
511
+ <div class="head"><span class="upd" id="updated"></span><span class="headctl"><span class="turn" id="turn"></span><button class="switchbtn agent-switch" id="switchbtn" title="Switch agent" style="display:none" type="button"></button></span></div>
434
512
  <div class="tabs" id="tabs"></div>
435
513
  <div class="state-row">
436
514
  <span class="state-dot" id="stateDot"></span>
@@ -492,9 +570,42 @@ export const HUD_HTML = String.raw `<!doctype html>
492
570
  const hudVersion = "v${MCP_PACKAGE_VERSION}";
493
571
  let prevTurnState = null;
494
572
  let lastCapsule = "";
573
+ let renewResultSessionKey = "";
495
574
  let activityEpoch = null;
496
575
  let lastState = null;
497
576
  let currentSrc = null;
577
+ let renewTimer = null;
578
+ let renewStartMs = 0;
579
+ let renewEta = null;
580
+ let renewEstimateSeq = 0;
581
+ const CHECKPOINT_CACHE_KEY = "echomemHudCheckpointCacheV2";
582
+ const CHECKPOINT_CACHE_LIMIT = 24;
583
+ const CHECKPOINT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
584
+ const CHECKPOINT_STATUS_CACHE_KEY = "echomemHudCheckpointStatusV2";
585
+ const CHECKPOINT_STATUS_CACHE_LIMIT = 48;
586
+ const CHECKPOINT_STATUS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
587
+ const statusFetchInFlight = {};
588
+ // Demo-clean mode: hide only the research-y session VIEWER (raw log / payload). Keep the session
589
+ // tabs + agent-switch — they're the manual override for "we can't detect which session you're
590
+ // viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
591
+ const DEMO_CLEAN = true;
592
+
593
+ // Mini mode: resting state is a small pill; opening the panel shows the full layout, closing
594
+ // returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
595
+ const miniBtn = document.getElementById("minibtn");
596
+ const mdot = document.getElementById("mdot");
597
+ const mtext = document.getElementById("mtext");
598
+ let miniOn = false;
599
+ try { miniOn = localStorage.getItem("echomemHudMini") === "1"; } catch {}
600
+ if (miniOn) hud.classList.add("mini");
601
+ function applyMini(next) {
602
+ miniOn = next;
603
+ try { localStorage.setItem("echomemHudMini", next ? "1" : "0"); } catch {}
604
+ hud.classList.toggle("mini", next);
605
+ if (next) hud.classList.remove("open");
606
+ if (window.echomemHud && typeof window.echomemHud.setMini === "function") window.echomemHud.setMini(next);
607
+ }
608
+ if (miniBtn) miniBtn.addEventListener("click", (e) => { e.stopPropagation(); applyMini(true); });
498
609
  let hudDrag = null;
499
610
  let suppressNextToggle = false;
500
611
  const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
@@ -509,7 +620,8 @@ export const HUD_HTML = String.raw `<!doctype html>
509
620
  if (event.target.closest(dragBlockSelector)) return;
510
621
  if (!window.echomemHud || typeof window.echomemHud.startDrag !== "function") return;
511
622
  hudDrag = { pointerId: event.pointerId, startX: event.screenX, startY: event.screenY, moved: false };
512
- try { hud.setPointerCapture(event.pointerId); } catch {}
623
+ // NOTE: do NOT setPointerCapture here capturing on pointerdown re-targets the click to #hud,
624
+ // so #toggle never gets it and the panel can't expand. Capture only once a real drag starts.
513
625
  window.echomemHud.startDrag(event.screenX, event.screenY);
514
626
  }
515
627
 
@@ -519,6 +631,7 @@ export const HUD_HTML = String.raw `<!doctype html>
519
631
  const dx = event.screenX - hudDrag.startX;
520
632
  const dy = event.screenY - hudDrag.startY;
521
633
  if (!hudDrag.moved && Math.hypot(dx, dy) < 3) return;
634
+ if (!hudDrag.moved) { try { hud.setPointerCapture(hudDrag.pointerId); } catch {} }
522
635
  hudDrag.moved = true;
523
636
  hud.classList.add("dragging");
524
637
  window.echomemHud.moveDrag(event.screenX, event.screenY);
@@ -544,17 +657,11 @@ export const HUD_HTML = String.raw `<!doctype html>
544
657
  fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
545
658
  });
546
659
 
547
- // Circular top-right toggle: jump to the next agent's newest session (Claude Codex).
660
+ // Jump to the other agent family while the tabs stay scoped to the family you're viewing.
548
661
  if (switchBtn) switchBtn.addEventListener("click", (e) => {
549
662
  e.stopPropagation();
550
663
  const st = lastState;
551
- if (!st || !st.recentSessions) return;
552
- const clients = [];
553
- for (const s of st.recentSessions) if (clients.indexOf(s.client) < 0) clients.push(s.client);
554
- if (clients.length < 2) return;
555
- const cur = st.active ? st.active.client : clients[0];
556
- const next = clients[(clients.indexOf(cur) + 1) % clients.length];
557
- const target = st.recentSessions.filter((s) => s.client === next)[0]; // newest of that agent
664
+ const target = switchTarget(st, st && st.active && st.active.client);
558
665
  if (!target) return;
559
666
  fetch("/select?id=" + encodeURIComponent(target.id))
560
667
  .then(() => fetch("/state"))
@@ -582,35 +689,93 @@ export const HUD_HTML = String.raw `<!doctype html>
582
689
  event.stopPropagation();
583
690
  return;
584
691
  }
585
- if (event.target instanceof HTMLElement && event.target.closest(".switchbtn")) return;
692
+ if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
586
693
  hud.classList.toggle("open");
587
694
  syncHeight();
588
695
  });
589
696
 
697
+ // Breathing CTA state: quiet while fresh, primary while drifted. Label carries the payoff so
698
+ // the user knows renewing does not lose their trail.
699
+ let renewUrgent = false;
700
+ function refreshRenewLabel() {
701
+ if (!renewBtn) return;
702
+ renewBtn.classList.toggle("urgent", renewUrgent);
703
+ if (renewStartMs) {
704
+ renderRenewLoadingLabel();
705
+ return;
706
+ }
707
+ renewBtn.innerHTML = renewUrgent
708
+ ? '<b>Renew session</b><small>I pack a checkpoint and carry it into your fresh start</small>'
709
+ : "Renew session";
710
+ }
711
+
590
712
  if (renewBtn) renewBtn.addEventListener("click", async () => {
713
+ if (renewStartMs) return;
714
+ const stateAtRenew = lastState;
715
+ const scoreAtRenew = activeRenewScore();
716
+ const renewSessionKey = sessionKeyFor(stateAtRenew, scoreAtRenew);
591
717
  renewBtn.classList.add("loading");
592
- renewBtn.textContent = "Capturing...";
718
+ clearRenewResult();
719
+ startRenewTimer(scoreAtRenew);
593
720
  try {
594
- const res = await fetch("/capsule");
595
- const data = await res.json();
596
- if (!data.ok) {
597
- renewBtn.textContent = "Renew session";
598
- renewBtn.classList.remove("loading");
721
+ // Cloud extraction (coding checkpoint) produces the real decision-log carryover. If that is
722
+ // unavailable, fall back transparently to a basic local capsule instead of the same artifact.
723
+ let prompt = "";
724
+ let promptKind = "cloud";
725
+ let renewData = null;
726
+ try {
727
+ const res = await fetch("/renew");
728
+ const data = await res.json();
729
+ if (data.ok && data.carryover) {
730
+ prompt = data.carryover;
731
+ renewData = data;
732
+ }
733
+ } catch {}
734
+ if (!prompt) {
735
+ const cap = await (await fetch("/capsule")).json();
736
+ if (cap.ok && cap.capsule) {
737
+ promptKind = "local";
738
+ prompt = localCapsulePrompt(cap.capsule);
739
+ }
740
+ }
741
+ if (!prompt) {
599
742
  return;
600
743
  }
601
- const prompt = "Continue my previous session. Verify anything that references file state before trusting it.\n\n" + data.capsule;
602
- lastCapsule = prompt;
603
- if (renewPre) renewPre.textContent = prompt;
604
- if (renewMsg) renewMsg.textContent = "Open a new session and paste this:";
605
- if (renewResult) renewResult.style.display = "block";
744
+ if (renewSessionKey !== sessionKeyFor(lastState, activeRenewScore())) return;
745
+ const meta = checkpointMetaFor(scoreAtRenew);
746
+ if (renewData) {
747
+ meta.contextId = typeof renewData.contextId === "string" ? renewData.contextId : "";
748
+ meta.sessionMemoryCount = Number.isFinite(Number(renewData.sessionMemoryCount)) ? Number(renewData.sessionMemoryCount) : 0;
749
+ meta.saved = Number.isFinite(Number(renewData.saved)) ? Number(renewData.saved) : 0;
750
+ meta.renewedAt = Date.now();
751
+ }
752
+ saveCachedCheckpoint(renewSessionKey, prompt, promptKind, meta);
753
+ if (promptKind === "cloud") saveCheckpointStatus(renewSessionKey, statusFromRenewData(renewData, meta));
754
+ showRenewResult({
755
+ sessionKey: renewSessionKey,
756
+ prompt,
757
+ message: promptKind === "local"
758
+ ? "Basic local capsule ready. Cloud decision extraction was unavailable; copy it below and paste it into a fresh session."
759
+ : "Decision log ready, copied to your clipboard. Open a fresh session and paste it as the first message.",
760
+ canCopy: true,
761
+ });
606
762
  try {
607
763
  await navigator.clipboard.writeText(prompt);
608
- if (renewMsg) renewMsg.textContent = "Copied. Open a new session and paste:";
609
- } catch {}
610
- } catch {}
611
- renewBtn.textContent = "Renew session";
612
- renewBtn.classList.remove("loading");
613
- syncHeight();
764
+ if (renewMsg) renewMsg.textContent = promptKind === "local"
765
+ ? "Basic local capsule copied. Cloud decision extraction was unavailable; this includes goal, recent instruction, and file anchors, but may miss decisions."
766
+ : "Decision log ready, copied to your clipboard. Open a fresh session and paste it as the first message.";
767
+ } catch {
768
+ if (renewMsg) renewMsg.textContent = promptKind === "local"
769
+ ? "Basic local capsule ready. Cloud decision extraction was unavailable; copy it below and paste it into a fresh session."
770
+ : "Decision log ready. Copy it below, then paste it into a fresh session.";
771
+ }
772
+ } catch {
773
+ } finally {
774
+ stopRenewTimer();
775
+ renewBtn.classList.remove("loading");
776
+ refreshRenewLabel();
777
+ syncHeight();
778
+ }
614
779
  });
615
780
 
616
781
  if (copyBtn) copyBtn.addEventListener("click", async () => {
@@ -622,19 +787,356 @@ export const HUD_HTML = String.raw `<!doctype html>
622
787
  } catch {}
623
788
  });
624
789
 
625
- const events = new EventSource("/events");
626
- events.onmessage = (event) => render(JSON.parse(event.data));
627
- fetch("/state").then((r) => r.json()).then(render).catch(() => {});
790
+ function activeRenewScore() {
791
+ const st = lastState || {};
792
+ return st.active || st;
793
+ }
794
+
795
+ function sessionKeyFor(state, score) {
796
+ if (!score || !score.sourcePath) return "";
797
+ const clientName = String(score.client || "");
798
+ const src = String(score.sourcePath || "");
799
+ const list = (state && state.recentSessions) || [];
800
+ const match = list.filter((s) => s.client === clientName && s.sourcePath === src)[0];
801
+ return clientName + "::" + (match && match.id ? match.id : src);
802
+ }
803
+
804
+ function clearRenewResult() {
805
+ lastCapsule = "";
806
+ renewResultSessionKey = "";
807
+ if (renewPre) {
808
+ renewPre.textContent = "";
809
+ renewPre.style.display = "";
810
+ }
811
+ if (renewMsg) renewMsg.textContent = "";
812
+ if (copyBtn) {
813
+ copyBtn.textContent = "Copy to clipboard";
814
+ copyBtn.style.display = "";
815
+ }
816
+ if (renewResult) renewResult.style.display = "none";
817
+ }
818
+
819
+ function showRenewResult(opts) {
820
+ const prompt = String((opts && opts.prompt) || "");
821
+ const body = String((opts && opts.body) || prompt || "");
822
+ lastCapsule = prompt;
823
+ renewResultSessionKey = String((opts && opts.sessionKey) || "");
824
+ if (renewMsg) renewMsg.textContent = String((opts && opts.message) || "");
825
+ if (renewPre) {
826
+ renewPre.textContent = body;
827
+ renewPre.style.display = body ? "block" : "none";
828
+ }
829
+ if (copyBtn) {
830
+ copyBtn.textContent = "Copy to clipboard";
831
+ copyBtn.style.display = opts && opts.canCopy ? "inline-block" : "none";
832
+ }
833
+ if (renewResult) renewResult.style.display = "block";
834
+ syncHeight();
835
+ }
836
+
837
+ function checkpointMetaFor(score) {
838
+ return {
839
+ turn: Number(score && score.turn) || 0,
840
+ ctTokens: Number(score && score.ctTokens) || 0,
841
+ createdAt: Date.now(),
842
+ };
843
+ }
844
+
845
+ function saveCachedCheckpoint(key, prompt, kind, meta) {
846
+ if (!key || !prompt) return;
847
+ const next = readCheckpointCache().filter((item) => item.key !== key);
848
+ next.unshift({
849
+ key,
850
+ prompt: String(prompt),
851
+ kind: kind === "local" ? "local" : "cloud",
852
+ turn: Number(meta && meta.turn) || 0,
853
+ ctTokens: Number(meta && meta.ctTokens) || 0,
854
+ createdAt: Number(meta && meta.createdAt) || Date.now(),
855
+ renewedAt: Number(meta && meta.renewedAt) || Number(meta && meta.createdAt) || Date.now(),
856
+ contextId: typeof (meta && meta.contextId) === "string" ? meta.contextId : "",
857
+ sessionMemoryCount: Number.isFinite(Number(meta && meta.sessionMemoryCount)) ? Number(meta.sessionMemoryCount) : null,
858
+ saved: Number.isFinite(Number(meta && meta.saved)) ? Number(meta.saved) : null,
859
+ });
860
+ try { localStorage.setItem(CHECKPOINT_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_CACHE_LIMIT))); } catch {}
861
+ }
862
+
863
+ function readCheckpointCache() {
864
+ try {
865
+ const cutoff = Date.now() - CHECKPOINT_CACHE_TTL_MS;
866
+ const raw = JSON.parse(localStorage.getItem(CHECKPOINT_CACHE_KEY) || "[]");
867
+ return Array.isArray(raw)
868
+ ? raw.filter((item) => item && typeof item.key === "string" && typeof item.prompt === "string" && Number(item.createdAt || 0) >= cutoff)
869
+ : [];
870
+ } catch {
871
+ return [];
872
+ }
873
+ }
874
+
875
+ function cachedCheckpointFor(key) {
876
+ return readCheckpointCache().filter((item) => item.key === key)[0] || null;
877
+ }
878
+
879
+ function restoreCachedCheckpointFor(score, key) {
880
+ if (!key || renewStartMs || renewResultSessionKey === key) return false;
881
+ const cached = cachedCheckpointFor(key);
882
+ if (!cached) return false;
883
+ showRenewResult({
884
+ sessionKey: key,
885
+ prompt: cached.prompt,
886
+ message: cachedCheckpointMessage(score, cached),
887
+ canCopy: true,
888
+ });
889
+ return true;
890
+ }
891
+
892
+ function cachedCheckpointMessage(score, cached) {
893
+ const savedTurn = Number(cached && cached.turn) || 0;
894
+ const currentTurn = Number(score && score.turn) || 0;
895
+ const stale = savedTurn > 0 && currentTurn > savedTurn;
896
+ const summary = checkpointSummary(cached);
897
+ if (cached && cached.kind === "local") {
898
+ return stale
899
+ ? "Basic local capsule from turn " + savedTurn + ". Renew again for the latest cloud decision log."
900
+ : "Basic local capsule already ready for this session. Cloud decision extraction was unavailable when it was made.";
901
+ }
902
+ return stale
903
+ ? "Decision log from turn " + savedTurn + ". " + summary + " Copy it below, or Renew again for the latest."
904
+ : "Decision log already ready for this session. " + summary + " Copy it below, or Renew again to replace it.";
905
+ }
906
+
907
+ function statusFromRenewData(data, meta) {
908
+ return {
909
+ fetched: true,
910
+ hasCheckpoint: Boolean(data && data.ok),
911
+ contextId: typeof (data && data.contextId) === "string" ? data.contextId : String((meta && meta.contextId) || ""),
912
+ renewedAt: Number(meta && meta.renewedAt) || Date.now(),
913
+ memoryCount: Number.isFinite(Number(data && data.sessionMemoryCount)) ? Number(data.sessionMemoryCount) : Number(meta && meta.sessionMemoryCount) || 0,
914
+ checkpointMemoryId: "",
915
+ };
916
+ }
917
+
918
+ function checkpointSummary(item) {
919
+ const parts = [];
920
+ const renewedAt = timestampMs(item && item.renewedAt);
921
+ if (renewedAt) parts.push("renewed " + relTime(Date.now() - renewedAt));
922
+ const count = Number(item && (item.memoryCount ?? item.sessionMemoryCount));
923
+ if (Number.isFinite(count) && count > 0) parts.push(count + " " + (count === 1 ? "memory" : "memories") + " saved");
924
+ return parts.length ? parts.join(" · ") + "." : "";
925
+ }
926
+
927
+ function timestampMs(value) {
928
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
929
+ if (typeof value === "string" && value) {
930
+ const ms = Date.parse(value);
931
+ return Number.isFinite(ms) ? ms : 0;
932
+ }
933
+ return 0;
934
+ }
935
+
936
+ function readCheckpointStatusCache() {
937
+ try {
938
+ const cutoff = Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS;
939
+ const raw = JSON.parse(localStorage.getItem(CHECKPOINT_STATUS_CACHE_KEY) || "[]");
940
+ return Array.isArray(raw)
941
+ ? raw.filter((item) => item && typeof item.key === "string" && item.fetched === true && Number(item.fetchedAt || 0) >= cutoff)
942
+ : [];
943
+ } catch {
944
+ return [];
945
+ }
946
+ }
947
+
948
+ function cachedCheckpointStatusFor(key) {
949
+ return readCheckpointStatusCache().filter((item) => item.key === key)[0] || null;
950
+ }
951
+
952
+ function saveCheckpointStatus(key, status) {
953
+ if (!key || !status) return;
954
+ const next = readCheckpointStatusCache().filter((item) => item.key !== key);
955
+ const fetchedAt = status.unavailable ? Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS + 60 * 1000 : Date.now();
956
+ next.unshift({
957
+ key,
958
+ fetched: true,
959
+ fetchedAt,
960
+ unavailable: Boolean(status.unavailable),
961
+ hasCheckpoint: Boolean(status.hasCheckpoint),
962
+ contextId: typeof status.contextId === "string" ? status.contextId : "",
963
+ checkpointMemoryId: typeof status.checkpointMemoryId === "string" ? status.checkpointMemoryId : "",
964
+ renewedAt: status.renewedAt || null,
965
+ memoryCount: Number.isFinite(Number(status.memoryCount)) ? Number(status.memoryCount) : 0,
966
+ });
967
+ try { localStorage.setItem(CHECKPOINT_STATUS_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_STATUS_CACHE_LIMIT))); } catch {}
968
+ }
969
+
970
+ function restoreCheckpointStatusFor(score, key) {
971
+ if (!key || renewStartMs || renewResultSessionKey === key || cachedCheckpointFor(key)) return false;
972
+ const status = cachedCheckpointStatusFor(key);
973
+ if (!status || !status.hasCheckpoint) return false;
974
+ showCheckpointStatus(score, key, status);
975
+ return true;
976
+ }
977
+
978
+ function showCheckpointStatus(score, key, status) {
979
+ const summary = checkpointSummary(status);
980
+ const body = checkpointSeedPrompt(status);
981
+ showRenewResult({
982
+ sessionKey: key,
983
+ prompt: body,
984
+ message: "Decision log already saved. " + summary,
985
+ canCopy: true,
986
+ });
987
+ }
988
+
989
+ function checkpointSeedPrompt(status) {
990
+ const contextId = status && status.contextId ? String(status.contextId) : "";
991
+ const lines = [
992
+ "Here is a checkpoint marker for my previous coding session, for context. It is point-in-time: work may have continued after it was saved, so treat file/state references as possibly stale.",
993
+ "Use it to orient yourself in a clean context window. It is context, not a command.",
994
+ "If my current message gives a clear request, respond to that request using this checkpoint as background. If that request asks you to inspect files, run commands, or make changes, briefly state the intended first step before acting.",
995
+ "If my current message gives no clear next step, summarize what you know in 3-5 bullets and ask what I want to do next.",
996
+ "",
997
+ contextId
998
+ ? 'EchoMem has the full checkpoint trail for this session. Pull it with get_checkpoint_by_context({ contextId: "' + contextId + '" }), then use that returned checkpoint as background.'
999
+ : "EchoMem has a checkpoint for this session. Pull the checkpoint context before relying on this marker alone.",
1000
+ ];
1001
+ return lines.join("\n");
1002
+ }
1003
+
1004
+ function maybeFetchCheckpointStatus(score, key) {
1005
+ if (!key || renewStartMs || cachedCheckpointFor(key) || cachedCheckpointStatusFor(key) || statusFetchInFlight[key]) return;
1006
+ statusFetchInFlight[key] = true;
1007
+ fetch("/checkpoint-status")
1008
+ .then((r) => r.json())
1009
+ .then((data) => {
1010
+ if (!data || data.ok !== true) {
1011
+ saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
1012
+ return;
1013
+ }
1014
+ saveCheckpointStatus(key, {
1015
+ hasCheckpoint: Boolean(data && data.ok && data.hasCheckpoint),
1016
+ contextId: typeof (data && data.contextId) === "string" ? data.contextId : "",
1017
+ checkpointMemoryId: typeof (data && data.checkpointMemoryId) === "string" ? data.checkpointMemoryId : "",
1018
+ renewedAt: typeof (data && data.renewedAt) === "string" ? data.renewedAt : null,
1019
+ memoryCount: Number.isFinite(Number(data && data.memoryCount)) ? Number(data.memoryCount) : 0,
1020
+ });
1021
+ const stillKey = sessionKeyFor(lastState, activeRenewScore());
1022
+ if (stillKey !== key || renewResultSessionKey === key || cachedCheckpointFor(key)) return;
1023
+ const status = cachedCheckpointStatusFor(key);
1024
+ if (status && status.hasCheckpoint) showCheckpointStatus(score, key, status);
1025
+ })
1026
+ .catch(() => {
1027
+ saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
1028
+ })
1029
+ .finally(() => { delete statusFetchInFlight[key]; });
1030
+ }
1031
+
1032
+ function localCapsulePrompt(capsule) {
1033
+ return [
1034
+ "Basic local capsule, not a full EchoMem decision log.",
1035
+ "EchoMem cloud decision extraction was unavailable, so this fallback was built from local session signals such as goal, latest instruction, file anchors, and context-health numbers. It may miss decisions or product nuance from the discussion.",
1036
+ "Use it to orient yourself in a clean context window. The user's current message decides whether to continue, redirect, or only preserve context. Do not inspect files, run commands, or start work until intent is clear.",
1037
+ "",
1038
+ String(capsule || ""),
1039
+ ].join("\n");
1040
+ }
1041
+
1042
+ function startRenewTimer(score) {
1043
+ const seq = ++renewEstimateSeq;
1044
+ renewEta = { ms: fallbackEtaForScore(score || activeRenewScore()) };
1045
+ renewStartMs = Date.now();
1046
+ if (renewTimer) clearInterval(renewTimer);
1047
+ renderRenewLoadingLabel();
1048
+ renewTimer = setInterval(renderRenewLoadingLabel, 1000);
1049
+ fetch("/renew-estimate")
1050
+ .then((r) => r.json())
1051
+ .then((data) => {
1052
+ if (seq !== renewEstimateSeq || !renewStartMs) return;
1053
+ if (data && data.ok && Number.isFinite(Number(data.etaMs))) {
1054
+ renewEta = { ms: Number(data.etaMs) };
1055
+ renderRenewLoadingLabel();
1056
+ }
1057
+ })
1058
+ .catch(() => {});
1059
+ }
1060
+
1061
+ function stopRenewTimer() {
1062
+ if (renewTimer) clearInterval(renewTimer);
1063
+ renewTimer = null;
1064
+ renewStartMs = 0;
1065
+ renewEta = null;
1066
+ renewEstimateSeq += 1;
1067
+ }
1068
+
1069
+ function renderRenewLoadingLabel() {
1070
+ if (!renewBtn || !renewStartMs) return;
1071
+ const elapsed = Date.now() - renewStartMs;
1072
+ const remaining = Math.max(0, Number(renewEta && renewEta.ms) || 20000) - elapsed;
1073
+ const detail = remaining > 0 ? "ETA " + fmtDuration(remaining) + " left" : "ETA any moment";
1074
+ renewBtn.innerHTML = "<b>Packing your checkpoint&hellip;</b><small>" + esc(detail) + "</small>";
1075
+ }
1076
+
1077
+ function fallbackEtaForScore(score) {
1078
+ const n = Number(score && score.ctTokens) || 0;
1079
+ if (n <= 5000) return 10000;
1080
+ if (n <= 10000) return 12000;
1081
+ if (n <= 25000) return 15000;
1082
+ if (n <= 50000) return 18000;
1083
+ if (n <= 100000) return 20000;
1084
+ return 45000;
1085
+ }
1086
+
1087
+ function fmtDuration(ms) {
1088
+ const s = Math.max(0, Math.ceil(Number(ms || 0) / 1000));
1089
+ if (s < 60) return s + "s";
1090
+ const m = Math.floor(s / 60);
1091
+ const r = s % 60;
1092
+ return r ? m + "m " + r + "s" : m + "m";
1093
+ }
1094
+
1095
+ // Dev/design preview: ?preview=green|amber|red renders that health state with sample data and
1096
+ // skips the live feed — same pattern as the extraction page's ?preview hook.
1097
+ const previewColor = new URLSearchParams(location.search).get("preview");
1098
+ if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
1099
+ const sample = {
1100
+ client: "codex",
1101
+ label: "EchoMem-Chrome",
1102
+ sourcePath: "/preview/session.jsonl",
1103
+ usefulPct: previewColor === "red" ? 55 : previewColor === "amber" ? 75 : 95,
1104
+ color: previewColor,
1105
+ ctTokens: previewColor === "red" ? 165000 : previewColor === "amber" ? 110000 : 24000,
1106
+ modelContextWindow: 258000,
1107
+ pollutionPct: previewColor === "red" ? 45 : previewColor === "amber" ? 24 : 4,
1108
+ pollutionTok: previewColor === "red" ? 74000 : previewColor === "amber" ? 26000 : 900,
1109
+ buckets: { range_redundant: { count: previewColor === "red" ? 61 : previewColor === "amber" ? 18 : 0 } },
1110
+ stats: { patchEdits: 90 },
1111
+ turn: 28,
1112
+ lastActiveMs: 6000,
1113
+ ctSource: "measured",
1114
+ };
1115
+ render({
1116
+ active: sample,
1117
+ recentSessions: [
1118
+ { id: "p1", client: "codex", title: "EchoMem-Chrome", sourcePath: "/preview/session.jsonl", live: true },
1119
+ { id: "p2", client: "codex", title: "修复 HUD 切换和图标", sourcePath: "/preview/b.jsonl", live: false },
1120
+ { id: "p3", client: "codex", title: "Fix extraction preview", sourcePath: "/preview/c.jsonl", live: false },
1121
+ { id: "p4", client: "claude-code", title: "DG-Web refactor", sourcePath: "/preview/d.jsonl", live: false },
1122
+ ],
1123
+ });
1124
+ hud.classList.add("open");
1125
+ syncHeight();
1126
+ } else {
1127
+ const events = new EventSource("/events");
1128
+ events.onmessage = (event) => render(JSON.parse(event.data));
1129
+ fetch("/state").then((r) => r.json()).then(render).catch(() => {});
1130
+ }
628
1131
 
629
1132
  function render(state) {
630
1133
  const score = state.active || state;
631
1134
  lastState = state;
632
- renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath) : null);
633
- if (switchBtn) {
634
- const clients = [];
635
- for (const s of (state.recentSessions || [])) if (clients.indexOf(s.client) < 0) clients.push(s.client);
636
- switchBtn.style.display = clients.length >= 2 ? "inline-block" : "none";
637
- }
1135
+ const activeClient = score && score.client ? score.client : state && state.active && state.active.client;
1136
+ const currentSessionKey = sessionKeyFor(state, score);
1137
+ if (renewResultSessionKey && renewResultSessionKey !== currentSessionKey) clearRenewResult();
1138
+ renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null, activeClient);
1139
+ renderSwitchButton(state, activeClient);
638
1140
  if (!score || typeof score.usefulPct !== "number") {
639
1141
  renderIdle(state);
640
1142
  return;
@@ -656,7 +1158,13 @@ export const HUD_HTML = String.raw `<!doctype html>
656
1158
  hud.classList.remove("s-green", "s-amber", "s-red");
657
1159
  if (!stale) hud.classList.add("s-" + color);
658
1160
  main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
659
- client.textContent = sessionIdentity(score, state);
1161
+ if (mdot) mdot.className = "mdot " + shownColor;
1162
+ if (mtext) mtext.textContent = (stale ? "Idle" : stateName) + " · " + fmt(score.ctTokens);
1163
+ // Collapsed bubble: normally identifies the session (trust the auto-follow); when drifted
1164
+ // it becomes Echo's nudge — the bubble itself sells the renew.
1165
+ const urgent = !stale && color === "red";
1166
+ client.textContent = urgent ? "let me carry this forward →" : sessionIdentity(score, state);
1167
+ client.classList.toggle("voice", urgent);
660
1168
  activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
661
1169
  renderUpdated();
662
1170
  turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
@@ -668,16 +1176,28 @@ export const HUD_HTML = String.raw `<!doctype html>
668
1176
  evidence.textContent = evidenceLine(score, oldPct, repeatCount);
669
1177
  usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
670
1178
  oldbar.style.width = oldPct + "%";
671
- metaEl.textContent = ctSourceLabel(score.ctSource) + " - lower-bound signal - " + edits + " edits";
1179
+ metaEl.textContent = ctSourceLabel(score.ctSource) + " · " + edits + " edits";
672
1180
  pathEl.textContent = "";
673
1181
  currentSrc = score.sourcePath || null;
674
- if (revealEl) revealEl.style.display = currentSrc ? "inline-block" : "none";
1182
+ if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
675
1183
  missing.textContent = formatMissing(state.missing);
676
1184
 
677
1185
  renderAlert(score, oldPct, repeatCount);
678
- renderNudge(color, sat);
1186
+ // The old advice box repeated the verdict a third time; its job now lives in leadLine + the
1187
+ // renew button's own subtitle.
1188
+ nudge.style.display = "none";
679
1189
 
680
- if (renewBtn) renewBtn.style.display = "block"; // always available warm-start any session, even fresh
1190
+ // Breathing CTA: quiet ghost while things are fine, full-size primary when drifted.
1191
+ renewUrgent = urgent;
1192
+ if (renewBtn) {
1193
+ renewBtn.style.display = "block"; // always available — warm-start any session, even fresh
1194
+ refreshRenewLabel();
1195
+ }
1196
+ const restoredPrompt = restoreCachedCheckpointFor(score, currentSessionKey);
1197
+ if (!restoredPrompt) {
1198
+ const restoredStatus = restoreCheckpointStatusFor(score, currentSessionKey);
1199
+ if (!restoredStatus) maybeFetchCheckpointStatus(score, currentSessionKey);
1200
+ }
681
1201
  prevTurnState = {
682
1202
  src: score.sourcePath,
683
1203
  turn: score.turn,
@@ -695,6 +1215,8 @@ export const HUD_HTML = String.raw `<!doctype html>
695
1215
  hud.classList.remove("s-green", "s-amber", "s-red");
696
1216
  prevTurnState = null;
697
1217
  main.textContent = "No active session yet";
1218
+ if (mdot) mdot.className = "mdot idle";
1219
+ if (mtext) mtext.textContent = "EchoMem";
698
1220
  client.textContent = "EchoMem HUD - " + hudVersion;
699
1221
  activityEpoch = null;
700
1222
  if (updatedEl) updatedEl.textContent = "";
@@ -711,7 +1233,7 @@ export const HUD_HTML = String.raw `<!doctype html>
711
1233
  alertEl.style.display = "none";
712
1234
  nudge.style.display = "none";
713
1235
  if (renewBtn) renewBtn.style.display = "none";
714
- if (renewResult) renewResult.style.display = "none";
1236
+ clearRenewResult();
715
1237
  metaEl.textContent = "";
716
1238
  pathEl.textContent = "";
717
1239
  missing.textContent = formatMissing(state && state.missing);
@@ -735,12 +1257,8 @@ export const HUD_HTML = String.raw `<!doctype html>
735
1257
  alertEl.style.display = "block";
736
1258
  return;
737
1259
  }
738
- if (score.color === "red") {
739
- alertTitle.textContent = "context has drifted";
740
- alertDetail.textContent = "at least " + oldPct + "% old weight is still riding in the window";
741
- alertEl.style.display = "block";
742
- return;
743
- }
1260
+ // No static "context has drifted" verdict here: the state word, lead line, and bar already
1261
+ // say it. The alert is reserved for genuinely NEW information (a turn that re-read old files).
744
1262
  alertEl.style.display = "none";
745
1263
  }
746
1264
 
@@ -765,15 +1283,17 @@ export const HUD_HTML = String.raw `<!doctype html>
765
1283
  return "Fresh";
766
1284
  }
767
1285
 
1286
+ // Echo's voice: one line that says how it is AND what to do — replaces the old pile of
1287
+ // metaphor headline + callout + advice box that all repeated the same verdict.
768
1288
  function leadLine(color) {
769
- if (color === "red") return "Working from a cluttered desk";
770
- if (color === "amber") return "Old context is building up";
771
- return "Clean and roomy";
1289
+ if (color === "red") return "Old context is crowding the window. Renew and I carry your work over.";
1290
+ if (color === "amber") return "Getting full. Wrap this thought; a fresh start soon keeps it snappy.";
1291
+ return "All clear. I am taking notes as you go.";
772
1292
  }
773
1293
 
774
1294
  function evidenceLine(score, oldPct, repeatCount) {
775
- const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "nothing has been re-read yet";
776
- return "at least " + oldPct + "% old weight (" + fmt(score.pollutionTok) + ") - " + repeat;
1295
+ const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "no re-reads yet";
1296
+ return fmt(score.pollutionTok) + " old context · " + repeat;
777
1297
  }
778
1298
 
779
1299
  function fullnessLine(sat, score) {
@@ -813,25 +1333,69 @@ export const HUD_HTML = String.raw `<!doctype html>
813
1333
  return parts.join(" · ");
814
1334
  }
815
1335
 
816
- // Recent sessions as switchable tabs. "Auto" clears the pin. Hidden when there's only one session.
817
- function renderTabs(state, activeId) {
818
- if (!tabsEl) return;
1336
+ function renderSwitchButton(state, activeClient) {
1337
+ if (!switchBtn) return;
1338
+ const target = switchTarget(state, activeClient);
1339
+ if (!target) {
1340
+ switchBtn.style.display = "none";
1341
+ switchBtn.removeAttribute("data-target-family");
1342
+ switchBtn.removeAttribute("data-target-id");
1343
+ switchBtn.innerHTML = "";
1344
+ return;
1345
+ }
1346
+ const family = agentFamily(target.client);
1347
+ const name = family === "claude" ? "Claude" : "Codex";
1348
+ switchBtn.dataset.targetFamily = family;
1349
+ switchBtn.dataset.targetId = target.id;
1350
+ switchBtn.title = "Switch to " + name;
1351
+ switchBtn.innerHTML = '<img class="switch-icon" src="' + iconForFamily(family) + '" alt="" /><span class="switch-copy">Switch to ' + name + "</span>";
1352
+ switchBtn.style.display = "inline-flex";
1353
+ }
1354
+
1355
+ function switchTarget(state, activeClient) {
819
1356
  const list = (state && state.recentSessions) || [];
1357
+ if (!list.length) return null;
1358
+ const currentFamily = agentFamily(activeClient || (state.active && state.active.client));
1359
+ const targetFamily = currentFamily === "codex" ? "claude" : currentFamily === "claude" ? "codex" : "";
1360
+ if (!targetFamily) return null;
1361
+ return list.filter((s) => agentFamily(s.client) === targetFamily)[0] || null;
1362
+ }
1363
+
1364
+ // Recent sessions as switchable tabs. "Auto" clears the pin. Hidden when there's only one
1365
+ // session for the active agent family, so Codex never recommends Claude sessions or vice versa.
1366
+ function renderTabs(state, activeId, activeClient) {
1367
+ if (!tabsEl) return;
1368
+ const list = visibleRecentSessions(state, activeClient);
820
1369
  if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
1370
+ const pinnedId = state && state.pinnedId ? state.pinnedId : null;
821
1371
  const chips = list.map((s) => {
822
- const cls = s.id === activeId ? "tab active" : "tab";
1372
+ const cls = s.id === pinnedId ? "tab active" : !pinnedId && s.id === activeId ? "tab auto-pick" : "tab";
823
1373
  const dot = s.live ? '<span class="spin"></span>' : "";
824
1374
  const title = s.title || label(s.client);
825
1375
  return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
826
1376
  });
827
- chips.unshift('<button class="tab' + (activeId ? "" : " active") + '" data-id="auto">Auto</button>');
1377
+ chips.unshift('<button class="tab' + (pinnedId ? "" : " active") + '" data-id="auto" title="Auto-select the current foreground agent session">Auto</button>');
828
1378
  tabsEl.innerHTML = chips.join("");
829
1379
  }
830
- function activeIdFor(state, src) {
1380
+ function visibleRecentSessions(state, activeClient) {
831
1381
  const list = (state && state.recentSessions) || [];
1382
+ const family = agentFamily(activeClient);
1383
+ if (family !== "codex" && family !== "claude") return list;
1384
+ return list.filter((s) => agentFamily(s.client) === family);
1385
+ }
1386
+ function activeIdFor(state, src, activeClient) {
1387
+ const list = visibleRecentSessions(state, activeClient);
832
1388
  const match = list.filter((s) => s.sourcePath === src)[0];
833
1389
  return match ? match.id : null;
834
1390
  }
1391
+ function agentFamily(clientName) {
1392
+ if (clientName === "codex") return "codex";
1393
+ if (String(clientName || "").indexOf("claude") === 0) return "claude";
1394
+ return "";
1395
+ }
1396
+ function iconForFamily(family) {
1397
+ return family === "claude" ? "/assets/hud/claude.svg" : "/assets/hud/codex.svg";
1398
+ }
835
1399
  function shortTitle(t) {
836
1400
  t = String(t || "");
837
1401
  return t.length > 22 ? t.slice(0, 21) + "…" : t;