@echomem/mcp 1.4.7 → 1.4.9

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.
Files changed (46) hide show
  1. package/README.md +35 -9
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
package/dist/hud/web.js CHANGED
@@ -1,4 +1,3 @@
1
- import { MCP_PACKAGE_VERSION } from "../package-metadata.js";
2
1
  export const HUD_HTML = String.raw `<!doctype html>
3
2
  <html lang="en">
4
3
  <head>
@@ -39,6 +38,11 @@ export const HUD_HTML = String.raw `<!doctype html>
39
38
  -webkit-font-smoothing: antialiased;
40
39
  cursor: grab;
41
40
  }
41
+ #hud.capture-preview {
42
+ position: fixed;
43
+ top: 0;
44
+ left: 0;
45
+ }
42
46
  #hud.dragging { cursor: grabbing; }
43
47
  .glance {
44
48
  position: relative;
@@ -129,24 +133,31 @@ export const HUD_HTML = String.raw `<!doctype html>
129
133
  min-width: 0;
130
134
  display: grid;
131
135
  grid-template-columns: minmax(0, 1fr);
132
- gap: 4px;
133
- padding: 20px 38px 18px 30px;
136
+ gap: 2px;
137
+ padding: 15px 38px 11px 30px;
134
138
  }
135
139
  .dot {
136
140
  position: absolute;
137
- left: 14px;
138
- top: 23px;
141
+ left: 13px;
142
+ top: 22px;
139
143
  z-index: 3;
140
- width: 10px;
141
- height: 10px;
144
+ width: 12px;
145
+ height: 12px;
142
146
  border: 2px solid #fdfaf2;
143
147
  border-radius: 999px;
144
148
  background: var(--green);
145
149
  box-shadow: 0 0 0 1px rgba(26, 58, 143, 0.14);
146
150
  }
147
151
  .dot.amber { background: var(--amber); }
148
- .dot.red { background: #ff5f57; }
149
- .dot.idle { background: #c9c5b8; }
152
+ .dot.red, .state-dot.red, #miniPill .mdot.red {
153
+ background: #ff5f57;
154
+ animation: red-pulse 1.15s ease-in-out infinite;
155
+ }
156
+ .dot.idle { background: var(--green); }
157
+ @keyframes red-pulse {
158
+ 0%, 100% { box-shadow: 0 0 0 1px rgba(199, 55, 47, 0.18), 0 0 0 0 rgba(255, 95, 87, 0.34); transform: scale(1); }
159
+ 50% { box-shadow: 0 0 0 1px rgba(199, 55, 47, 0.18), 0 0 0 7px rgba(255, 95, 87, 0); transform: scale(1.18); }
160
+ }
150
161
  .main {
151
162
  min-width: 0;
152
163
  overflow: hidden;
@@ -163,20 +174,37 @@ export const HUD_HTML = String.raw `<!doctype html>
163
174
  overflow: hidden;
164
175
  text-overflow: ellipsis;
165
176
  white-space: nowrap;
166
- font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
167
- font-size: 10px;
168
- letter-spacing: 0.12em;
177
+ font-size: 10.5px;
178
+ font-weight: 720;
179
+ letter-spacing: 0;
180
+ line-height: 1;
181
+ color: var(--faint);
182
+ }
183
+ .client-repo {
184
+ min-width: 0;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ font-size: 9.5px;
189
+ font-weight: 620;
169
190
  line-height: 1;
170
- text-transform: uppercase;
171
191
  color: var(--faint);
172
192
  }
193
+ .client.voice {
194
+ font-family: inherit;
195
+ font-size: 11.5px;
196
+ font-weight: 750;
197
+ letter-spacing: 0.01em;
198
+ text-transform: none;
199
+ color: var(--brick);
200
+ }
173
201
  .details {
174
202
  display: none;
175
203
  position: relative;
176
- margin: 2px 8px 0 24px;
204
+ margin: 2px 8px 0 8px;
177
205
  padding: 16px 17px 15px;
178
206
  border: 1px solid var(--line);
179
- border-radius: 16px;
207
+ border-radius: 12px;
180
208
  background: var(--card);
181
209
  color: var(--ink);
182
210
  filter: var(--shadow);
@@ -197,28 +225,139 @@ export const HUD_HTML = String.raw `<!doctype html>
197
225
  font-size: 11px;
198
226
  color: var(--faint);
199
227
  }
200
- .tabs { display: flex; gap: 6px; overflow-x: auto; margin: 2px 0 12px; padding-bottom: 2px; }
201
- .tabs::-webkit-scrollbar { height: 0; }
228
+ .tabs {
229
+ display: grid;
230
+ gap: 6px;
231
+ max-height: 259px;
232
+ margin: 2px 0 12px;
233
+ padding-right: 3px;
234
+ overflow-y: auto;
235
+ overscroll-behavior: contain;
236
+ }
237
+ .tabs:empty { display: none; }
238
+ .tabs::-webkit-scrollbar { width: 4px; }
239
+ .tabs::-webkit-scrollbar-track { background: transparent; }
240
+ .tabs::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(26, 58, 143, .2); }
202
241
  .tab {
203
- flex: 0 0 auto; font-size: 10.5px; line-height: 1; padding: 5px 10px; border-radius: 999px;
242
+ display: grid; grid-template-columns: 18px minmax(0, 1fr) 12px; align-items: center; column-gap: 8px;
243
+ width: 100%; min-height: 36px; font-size: 11px; line-height: 1.15; padding: 6px 9px; border-radius: 8px;
204
244
  border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04);
205
- color: var(--faint); cursor: pointer; white-space: nowrap; font-family: inherit;
245
+ color: var(--faint); cursor: pointer; text-align: left; font-family: inherit;
206
246
  }
207
247
  .tab:hover { background: rgba(0, 0, 0, .08); }
208
- .tab.active { border-color: rgba(0, 0, 0, .38); background: rgba(0, 0, 0, .10); color: #111; font-weight: 700; }
248
+ .tab.viewing {
249
+ border-color: rgba(26, 58, 143, .72);
250
+ background: rgba(26, 58, 143, .11);
251
+ box-shadow: inset 4px 0 0 var(--blue), 0 1px 4px rgba(26, 58, 143, .12);
252
+ color: #111;
253
+ }
254
+ .tab-icon { width: 16px; height: 16px; object-fit: contain; }
255
+ .tab-copy { min-width: 0; display: grid; gap: 2px; }
256
+ .tab-title, .tab-repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
257
+ .tab-title { color: var(--ink); font-weight: 760; }
258
+ .tab-repo { color: var(--faint); font-size: 10px; font-weight: 600; }
259
+ .tab-status {
260
+ width: 10px;
261
+ height: 10px;
262
+ justify-self: end;
263
+ border: 2px solid #fff;
264
+ border-radius: 50%;
265
+ background: var(--green);
266
+ box-shadow: 0 0 0 1px rgba(26, 58, 143, .18);
267
+ }
268
+ .tab-status.amber { background: var(--amber); }
269
+ .tab-status.red { background: #ff5f57; box-shadow: 0 0 0 1px rgba(199, 55, 47, .28); }
270
+ .tab-status.idle { background: var(--faint); }
271
+ .tab.auto-tab {
272
+ position: sticky;
273
+ top: 0;
274
+ z-index: 1;
275
+ grid-template-columns: minmax(0, 1fr) auto;
276
+ min-height: 28px;
277
+ background: #f4f2ed;
278
+ }
279
+ .tab.auto-tab.mode-active {
280
+ border-color: rgba(26, 58, 143, .28);
281
+ background: #f8f8f5;
282
+ }
283
+ .auto-check {
284
+ display: none;
285
+ width: 17px;
286
+ height: 17px;
287
+ align-items: center;
288
+ justify-content: center;
289
+ border-radius: 50%;
290
+ background: var(--blue);
291
+ color: #fff;
292
+ font-size: 10px;
293
+ font-weight: 850;
294
+ }
295
+ .auto-tab.mode-active .auto-check { display: inline-flex; }
209
296
  .spin {
210
- display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
297
+ display: inline-block; width: 8px; height: 8px; margin-left: 4px; vertical-align: -1px;
211
298
  border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
212
299
  animation: echospin .8s linear infinite;
213
300
  }
214
301
  @keyframes echospin { to { transform: rotate(360deg); } }
302
+ /* Mini mode: a small always-on pill instead of the full mascot+bubble. Opening the panel
303
+ temporarily shows the full layout; closing returns to the pill. */
304
+ #miniPill {
305
+ display: none; align-items: center; gap: 8px; width: calc(100% - 16px); min-height: 38px;
306
+ margin: 7px 0 0 8px; padding: 5px 11px; cursor: pointer; user-select: none;
307
+ border: 1.6px solid rgba(26, 58, 143, 0.75); border-radius: 999px; background: #fdfaf2;
308
+ filter: var(--shadow); color: var(--ink); letter-spacing: 0;
309
+ }
310
+ #miniPill .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
311
+ #miniPill .mdot.amber { background: var(--amber); }
312
+ #miniPill .mdot.idle { background: var(--green); }
313
+ .micon { width: 16px; height: 16px; object-fit: contain; flex: 0 0 auto; }
314
+ #mscore {
315
+ flex: 0 0 auto;
316
+ font-family: "JetBrains Mono", ui-monospace, monospace;
317
+ font-size: 12px;
318
+ font-weight: 850;
319
+ }
320
+ .mcopy { min-width: 0; display: grid; gap: 1px; line-height: 1.1; }
321
+ #msession, #mrepo { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
322
+ #msession { font-size: 11.5px; font-weight: 760; color: var(--ink); }
323
+ #mrepo { font-size: 10px; font-weight: 650; color: var(--faint); }
324
+ #hud.mini:not(.open) .glance { height: 52px; }
325
+ #hud.mini:not(.open) .mascot-stage, #hud.mini:not(.open) .status-bubble, #hud.mini:not(.open) .drag { display: none; }
326
+ #hud.mini:not(.open) #miniPill { display: inline-flex; }
327
+ .cornerbtn {
328
+ position: absolute; top: 7px; right: 10px; z-index: 3;
329
+ width: 20px; height: 20px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
330
+ border: 1px solid rgba(0, 0, 0, .16); background: rgba(255, 255, 255, .7); color: var(--faint);
331
+ cursor: pointer; font-family: inherit;
332
+ }
333
+ .cornerbtn:hover { background: #fff; color: #111; }
215
334
  .headctl { display: flex; align-items: center; gap: 8px; }
216
335
  .switchbtn {
336
+ display: inline-flex; align-items: center; justify-content: center;
217
337
  width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
218
338
  border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
219
339
  cursor: pointer; font-family: inherit;
220
340
  }
221
341
  .switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
342
+ .agent-switch {
343
+ width: 28px;
344
+ min-width: 28px;
345
+ max-width: 28px;
346
+ height: 28px;
347
+ padding: 0;
348
+ border-radius: 999px;
349
+ background: rgba(255, 255, 255, .78);
350
+ color: #1f1f1f;
351
+ font-size: 10.5px;
352
+ font-weight: 760;
353
+ letter-spacing: 0;
354
+ white-space: nowrap;
355
+ }
356
+ .agent-switch:hover { background: #fff; border-color: rgba(26, 58, 143, .34); }
357
+ .agent-switch[data-target-family="claude"] { border-color: rgba(217, 119, 87, .42); }
358
+ .agent-switch[data-target-family="codex"] { border-color: rgba(57, 65, 255, .34); }
359
+ .switch-icon { width: 16px; height: 16px; flex: 0 0 auto; object-fit: contain; }
360
+ .switch-copy { display: none; }
222
361
  .reveal {
223
362
  margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
224
363
  border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
@@ -229,87 +368,75 @@ export const HUD_HTML = String.raw `<!doctype html>
229
368
  display: flex;
230
369
  align-items: center;
231
370
  gap: 9px;
232
- margin-bottom: 7px;
371
+ margin-bottom: 9px;
233
372
  }
234
373
  .state-dot {
235
- width: 11px;
236
- height: 11px;
374
+ width: 12px;
375
+ height: 12px;
237
376
  flex: none;
238
377
  border-radius: 999px;
239
378
  background: var(--green);
240
379
  box-shadow: 0 0 0 1px rgba(26, 58, 143, 0.12);
241
380
  }
242
381
  .state-dot.amber { background: var(--amber); }
243
- .state-dot.red { background: #ff5f57; }
244
382
  .state-word {
245
383
  min-width: 0;
246
384
  overflow: hidden;
247
385
  text-overflow: ellipsis;
248
386
  white-space: nowrap;
249
- font-size: 16px;
250
- font-weight: 800;
251
- }
252
- .full {
253
- min-height: 17px;
254
- margin-bottom: 14px;
255
- font-size: 12px;
256
- font-weight: 650;
257
- color: #4a7a3e;
387
+ font-family: "JetBrains Mono", ui-monospace, monospace;
388
+ font-size: 18px;
389
+ font-weight: 850;
258
390
  }
259
- .lead {
260
- margin-bottom: 5px;
261
- font-size: 13.5px;
391
+ .why-btn {
392
+ margin-left: auto;
393
+ border: 1px solid rgba(26, 58, 143, 0.16);
394
+ border-radius: 999px;
395
+ padding: 4px 10px;
396
+ background: rgba(26, 58, 143, 0.04);
397
+ color: var(--muted);
398
+ font-family: inherit;
399
+ font-size: 11px;
262
400
  font-weight: 750;
263
- color: #2f7d4f;
401
+ cursor: pointer;
264
402
  }
265
- .evidence {
266
- min-height: 38px;
267
- margin-bottom: 12px;
268
- color: var(--muted);
403
+ .why-btn:hover { background: rgba(26, 58, 143, 0.09); color: var(--ink); }
404
+ .full {
405
+ min-height: 16px;
406
+ margin-bottom: 3px;
407
+ overflow: hidden;
408
+ text-overflow: ellipsis;
409
+ white-space: nowrap;
269
410
  font-size: 12.5px;
270
- line-height: 1.5;
411
+ font-weight: 800;
412
+ color: var(--ink);
271
413
  }
272
- .oldbar {
273
- display: flex;
274
- height: 9px;
275
- margin: 0;
414
+ .lead {
415
+ margin-bottom: 0;
276
416
  overflow: hidden;
277
- border-radius: 999px;
278
- background: var(--track);
279
- }
280
- .oldbar span {
281
- display: block;
282
- height: 100%;
283
- min-width: 0;
284
- transition: width 0.35s ease;
417
+ text-overflow: ellipsis;
418
+ white-space: nowrap;
419
+ font-size: 11px;
420
+ font-weight: 650;
421
+ color: var(--faint);
285
422
  }
286
- .oldbar .useful { width: 100%; background: var(--blue); }
287
- .oldbar .old { width: 0; background: var(--brick); }
288
- .alert {
423
+ .evidence {
289
424
  display: none;
290
- margin-top: 13px;
291
- padding: 9px 0 9px 12px;
292
- border-left: 3px solid rgba(199, 55, 47, 0.42);
293
- }
294
- .alert-title {
295
- margin-bottom: 4px;
296
- color: var(--brick);
297
- font-size: 12.5px;
298
- font-weight: 750;
299
- }
300
- .alert-detail {
425
+ margin: 11px 0 12px;
426
+ padding: 9px 10px;
427
+ border: 1px solid rgba(26, 58, 143, 0.12);
428
+ border-radius: 8px;
429
+ background: rgba(26, 58, 143, 0.035);
301
430
  color: var(--muted);
302
431
  font-size: 12px;
303
- line-height: 1.45;
304
- }
305
- .nudge {
306
- display: none;
307
- margin-top: 13px;
308
- padding: 10px 12px;
309
- border-radius: 12px;
310
- font-size: 12.5px;
311
- line-height: 1.45;
432
+ line-height: 1.5;
312
433
  }
434
+ #hud.show-why .evidence { display: block; }
435
+ .why-line { display: flex; justify-content: space-between; gap: 12px; }
436
+ .why-line + .why-line { margin-top: 3px; }
437
+ .why-k { color: var(--faint); }
438
+ .why-v { color: var(--ink); font-weight: 750; text-align: right; }
439
+ .nudge { display: none; }
313
440
  .nudge.calm {
314
441
  border: 1px solid rgba(26, 58, 143, 0.14);
315
442
  background: rgba(26, 58, 143, 0.05);
@@ -324,29 +451,48 @@ export const HUD_HTML = String.raw `<!doctype html>
324
451
  display: none;
325
452
  width: 100%;
326
453
  margin-top: 12px;
327
- border: 1px solid rgba(199, 55, 47, 0.3);
454
+ border: 1px solid rgba(26, 58, 143, 0.26);
328
455
  border-radius: 999px;
329
- padding: 9px 10px;
330
- background: rgba(199, 55, 47, 0.08);
331
- color: var(--brick);
456
+ padding: 9px 14px;
457
+ background: rgba(26, 58, 143, 0.07);
458
+ color: var(--blue);
332
459
  font-weight: 750;
333
- font-size: 13px;
460
+ font-size: 12px;
334
461
  cursor: pointer;
335
- transition: background 0.2s ease, opacity 0.2s ease;
462
+ transition: all 0.25s ease;
336
463
  }
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; }
464
+ button.renew-btn.urgent {
465
+ border-color: var(--blue);
466
+ padding: 11px 14px;
467
+ background: var(--blue);
468
+ color: #fff;
469
+ font-size: 13px;
470
+ font-weight: 800;
471
+ box-shadow: 0 4px 12px rgba(26, 58, 143, 0.22);
472
+ }
473
+ button.renew-btn b { font-weight: 800; }
474
+ button.renew-btn small { display: none; }
475
+ button.renew-btn.urgent small, button.renew-btn.loading small {
476
+ display: block;
477
+ margin-top: 3px;
478
+ font-size: 10.5px;
479
+ font-weight: 600;
480
+ opacity: 0.85;
481
+ white-space: normal;
482
+ }
483
+ button.renew-btn:hover { background: rgba(26, 58, 143, 0.12); }
484
+ button.renew-btn.urgent:hover { background: #102d78; }
485
+ button.renew-btn.loading { width: 100%; opacity: 0.75; cursor: wait; }
340
486
  .renew-result {
341
487
  display: none;
342
488
  margin-top: 10px;
343
489
  border: 1px solid rgba(26, 58, 143, 0.18);
344
- border-radius: 12px;
490
+ border-radius: 8px;
345
491
  background: rgba(26, 58, 143, 0.04);
346
492
  padding: 10px 12px;
347
493
  }
348
494
  .renew-result .renew-msg {
349
- margin-bottom: 6px;
495
+ margin-bottom: 0;
350
496
  color: var(--blue);
351
497
  font-size: 11.5px;
352
498
  font-weight: 750;
@@ -383,7 +529,7 @@ export const HUD_HTML = String.raw `<!doctype html>
383
529
  color: var(--faint);
384
530
  font-size: 11px;
385
531
  }
386
- .meta { margin-top: 13px; }
532
+ .meta { display: none; }
387
533
  .path { margin-top: 8px; }
388
534
  .missing { margin-top: 6px; }
389
535
  #hud.s-green .mascot-stage { filter: drop-shadow(0 2px 6px rgba(67, 209, 122, 0.3)); }
@@ -426,33 +572,34 @@ export const HUD_HTML = String.raw `<!doctype html>
426
572
  <span class="copy">
427
573
  <span class="main" id="main">Checking context...</span>
428
574
  <span class="client" id="client">EchoMem HUD</span>
575
+ <span class="client-repo" id="clientrepo"></span>
429
576
  </span>
577
+ <button class="cornerbtn" id="minibtn" title="Shrink to mini pill">&ndash;</button>
578
+ </div>
579
+ <div id="miniPill" title="EchoMem — click to expand">
580
+ <span class="mdot" id="mdot"></span>
581
+ <img class="micon" id="micon" src="/assets/hud/codex.svg" alt="" />
582
+ <span id="mscore">on</span>
583
+ <span class="mcopy"><span id="msession">EchoMem</span><span id="mrepo"></span></span>
430
584
  </div>
431
585
  </div>
432
586
  <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>
587
+ <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
588
  <div class="tabs" id="tabs"></div>
435
589
  <div class="state-row">
436
590
  <span class="state-dot" id="stateDot"></span>
437
- <span class="state-word" id="stateWord">Fresh</span>
591
+ <span class="state-word" id="stateWord">--%</span>
592
+ <button class="why-btn" id="whybtn" type="button" style="display:none">why</button>
438
593
  </div>
439
594
  <div class="full" id="fullness"></div>
440
- <div class="lead" id="lead">Clean and roomy</div>
441
- <div class="evidence" id="evidence">Collecting enough local context to judge the window.</div>
442
- <div class="oldbar" aria-hidden="true">
443
- <span class="useful" id="usefulbar"></span>
444
- <span class="old" id="oldbar"></span>
445
- </div>
446
- <div class="alert" id="alert">
447
- <div class="alert-title" id="alertTitle"></div>
448
- <div class="alert-detail" id="alertDetail"></div>
449
- </div>
595
+ <div class="lead" id="lead"></div>
596
+ <div class="evidence" id="evidence"></div>
450
597
  <div class="nudge" id="nudge"></div>
451
- <button class="renew-btn" id="renewbtn">Renew session</button>
598
+ <button class="renew-btn" id="renewbtn">Start new with context</button>
452
599
  <div class="renew-result" id="renewresult">
453
600
  <div class="renew-msg" id="renewmsg"></div>
454
601
  <pre id="renewpre"></pre>
455
- <button class="copy-btn" id="copybtn">Copy to clipboard</button>
602
+ <button class="copy-btn" id="copybtn">Copy</button>
456
603
  </div>
457
604
  <div class="meta" id="meta"></div>
458
605
  <div class="path" id="path"></div>
@@ -465,17 +612,14 @@ export const HUD_HTML = String.raw `<!doctype html>
465
612
  const dot = document.getElementById("dot");
466
613
  const main = document.getElementById("main");
467
614
  const client = document.getElementById("client");
615
+ const clientRepo = document.getElementById("clientrepo");
468
616
  const turnEl = document.getElementById("turn");
469
617
  const stateDot = document.getElementById("stateDot");
470
618
  const stateWordEl = document.getElementById("stateWord");
619
+ const whyBtn = document.getElementById("whybtn");
471
620
  const fullness = document.getElementById("fullness");
472
621
  const lead = document.getElementById("lead");
473
622
  const evidence = document.getElementById("evidence");
474
- const usefulbar = document.getElementById("usefulbar");
475
- const oldbar = document.getElementById("oldbar");
476
- const alertEl = document.getElementById("alert");
477
- const alertTitle = document.getElementById("alertTitle");
478
- const alertDetail = document.getElementById("alertDetail");
479
623
  const nudge = document.getElementById("nudge");
480
624
  const renewBtn = document.getElementById("renewbtn");
481
625
  const renewResult = document.getElementById("renewresult");
@@ -489,12 +633,55 @@ export const HUD_HTML = String.raw `<!doctype html>
489
633
  const switchBtn = document.getElementById("switchbtn");
490
634
  const revealEl = document.getElementById("reveal");
491
635
  const missing = document.getElementById("missing");
492
- const hudVersion = "v${MCP_PACKAGE_VERSION}";
493
636
  let prevTurnState = null;
494
637
  let lastCapsule = "";
638
+ let renewResultSessionKey = "";
495
639
  let activityEpoch = null;
496
640
  let lastState = null;
497
641
  let currentSrc = null;
642
+ let renewTimer = null;
643
+ let renewStartMs = 0;
644
+ let renewEta = null;
645
+ let renewEstimateSeq = 0;
646
+ const CHECKPOINT_CACHE_KEY = "echomemHudCheckpointCacheV2";
647
+ const CHECKPOINT_CACHE_LIMIT = 24;
648
+ const CHECKPOINT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
649
+ const CHECKPOINT_STATUS_CACHE_KEY = "echomemHudCheckpointStatusV2";
650
+ const CHECKPOINT_STATUS_CACHE_LIMIT = 48;
651
+ const CHECKPOINT_STATUS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
652
+ const statusFetchInFlight = {};
653
+ // Demo-clean mode: hide only the research-y session VIEWER (raw log / payload). Keep the session
654
+ // tabs + agent-switch — they're the manual override for "we can't detect which session you're
655
+ // viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
656
+ const DEMO_CLEAN = true;
657
+
658
+ // Mini mode: resting state is a small pill; opening the panel shows the full layout, closing
659
+ // returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
660
+ const miniBtn = document.getElementById("minibtn");
661
+ const mdot = document.getElementById("mdot");
662
+ const micon = document.getElementById("micon");
663
+ const mscore = document.getElementById("mscore");
664
+ const msession = document.getElementById("msession");
665
+ const mrepo = document.getElementById("mrepo");
666
+ let miniOn = false;
667
+ let whyOpen = false;
668
+ try { miniOn = localStorage.getItem("echomemHudMini") === "1"; } catch {}
669
+ if (miniOn) hud.classList.add("mini");
670
+ function applyMini(next) {
671
+ miniOn = next;
672
+ try { localStorage.setItem("echomemHudMini", next ? "1" : "0"); } catch {}
673
+ hud.classList.toggle("mini", next);
674
+ if (next) hud.classList.remove("open");
675
+ if (window.echomemHud && typeof window.echomemHud.setMini === "function") window.echomemHud.setMini(next);
676
+ }
677
+ if (miniBtn) miniBtn.addEventListener("click", (e) => { e.stopPropagation(); applyMini(true); });
678
+ if (whyBtn) whyBtn.addEventListener("click", (e) => {
679
+ e.stopPropagation();
680
+ whyOpen = !whyOpen;
681
+ hud.classList.toggle("show-why", whyOpen);
682
+ whyBtn.textContent = whyOpen ? "hide" : "why";
683
+ syncHeight();
684
+ });
498
685
  let hudDrag = null;
499
686
  let suppressNextToggle = false;
500
687
  const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
@@ -509,7 +696,8 @@ export const HUD_HTML = String.raw `<!doctype html>
509
696
  if (event.target.closest(dragBlockSelector)) return;
510
697
  if (!window.echomemHud || typeof window.echomemHud.startDrag !== "function") return;
511
698
  hudDrag = { pointerId: event.pointerId, startX: event.screenX, startY: event.screenY, moved: false };
512
- try { hud.setPointerCapture(event.pointerId); } catch {}
699
+ // NOTE: do NOT setPointerCapture here capturing on pointerdown re-targets the click to #hud,
700
+ // so #toggle never gets it and the panel can't expand. Capture only once a real drag starts.
513
701
  window.echomemHud.startDrag(event.screenX, event.screenY);
514
702
  }
515
703
 
@@ -519,6 +707,7 @@ export const HUD_HTML = String.raw `<!doctype html>
519
707
  const dx = event.screenX - hudDrag.startX;
520
708
  const dy = event.screenY - hudDrag.startY;
521
709
  if (!hudDrag.moved && Math.hypot(dx, dy) < 3) return;
710
+ if (!hudDrag.moved) { try { hud.setPointerCapture(hudDrag.pointerId); } catch {} }
522
711
  hudDrag.moved = true;
523
712
  hud.classList.add("dragging");
524
713
  window.echomemHud.moveDrag(event.screenX, event.screenY);
@@ -544,17 +733,11 @@ export const HUD_HTML = String.raw `<!doctype html>
544
733
  fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
545
734
  });
546
735
 
547
- // Circular top-right toggle: jump to the next agent's newest session (Claude Codex).
736
+ // Jump to the other agent family while the tabs stay scoped to the family you're viewing.
548
737
  if (switchBtn) switchBtn.addEventListener("click", (e) => {
549
738
  e.stopPropagation();
550
739
  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
740
+ const target = switchTarget(st, st && st.active && st.active.client);
558
741
  if (!target) return;
559
742
  fetch("/select?id=" + encodeURIComponent(target.id))
560
743
  .then(() => fetch("/state"))
@@ -582,35 +765,83 @@ export const HUD_HTML = String.raw `<!doctype html>
582
765
  event.stopPropagation();
583
766
  return;
584
767
  }
585
- if (event.target instanceof HTMLElement && event.target.closest(".switchbtn")) return;
768
+ if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
586
769
  hud.classList.toggle("open");
587
770
  syncHeight();
588
771
  });
589
772
 
773
+ let renewUrgent = false;
774
+ function refreshRenewLabel() {
775
+ if (!renewBtn) return;
776
+ renewBtn.classList.toggle("urgent", renewUrgent);
777
+ if (renewStartMs) {
778
+ renderRenewLoadingLabel();
779
+ return;
780
+ }
781
+ renewBtn.textContent = "Start new with context";
782
+ }
783
+
590
784
  if (renewBtn) renewBtn.addEventListener("click", async () => {
785
+ if (renewStartMs) return;
786
+ const stateAtRenew = lastState;
787
+ const scoreAtRenew = activeRenewScore();
788
+ const renewSessionKey = sessionKeyFor(stateAtRenew, scoreAtRenew);
591
789
  renewBtn.classList.add("loading");
592
- renewBtn.textContent = "Capturing...";
790
+ clearRenewResult();
791
+ startRenewTimer(scoreAtRenew);
593
792
  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");
793
+ // Cloud extraction (coding checkpoint) produces the real decision-log carryover. If that is
794
+ // unavailable, fall back transparently to a basic local capsule instead of the same artifact.
795
+ let prompt = "";
796
+ let promptKind = "cloud";
797
+ let renewData = null;
798
+ try {
799
+ const res = await fetch("/renew");
800
+ const data = await res.json();
801
+ if (data.ok && data.carryover) {
802
+ prompt = data.carryover;
803
+ renewData = data;
804
+ }
805
+ } catch {}
806
+ if (!prompt) {
807
+ const cap = await (await fetch("/capsule")).json();
808
+ if (cap.ok && cap.capsule) {
809
+ promptKind = "local";
810
+ prompt = localCapsulePrompt(cap.capsule);
811
+ }
812
+ }
813
+ if (!prompt) {
599
814
  return;
600
815
  }
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";
816
+ if (renewSessionKey !== sessionKeyFor(lastState, activeRenewScore())) return;
817
+ const meta = checkpointMetaFor(scoreAtRenew);
818
+ if (renewData) {
819
+ meta.contextId = typeof renewData.contextId === "string" ? renewData.contextId : "";
820
+ meta.sessionMemoryCount = Number.isFinite(Number(renewData.sessionMemoryCount)) ? Number(renewData.sessionMemoryCount) : 0;
821
+ meta.saved = Number.isFinite(Number(renewData.saved)) ? Number(renewData.saved) : 0;
822
+ meta.renewedAt = Date.now();
823
+ }
824
+ saveCachedCheckpoint(renewSessionKey, prompt, promptKind, meta);
825
+ if (promptKind === "cloud") saveCheckpointStatus(renewSessionKey, statusFromRenewData(renewData, meta));
826
+ showRenewResult({
827
+ sessionKey: renewSessionKey,
828
+ prompt,
829
+ message: promptKind === "local" ? "Local capsule ready" : "Decision log ready",
830
+ canCopy: true,
831
+ });
606
832
  try {
607
833
  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();
834
+ if (renewMsg) renewMsg.textContent = promptKind === "local" ? "Local capsule copied" : "Decision log copied";
835
+ } catch {
836
+ if (renewMsg) renewMsg.textContent = promptKind === "local" ? "Local capsule ready" : "Decision log ready";
837
+ }
838
+ } catch {
839
+ } finally {
840
+ stopRenewTimer();
841
+ renewBtn.classList.remove("loading");
842
+ refreshRenewLabel();
843
+ syncHeight();
844
+ }
614
845
  });
615
846
 
616
847
  if (copyBtn) copyBtn.addEventListener("click", async () => {
@@ -618,66 +849,422 @@ export const HUD_HTML = String.raw `<!doctype html>
618
849
  try {
619
850
  await navigator.clipboard.writeText(lastCapsule);
620
851
  copyBtn.textContent = "Copied";
621
- setTimeout(() => { copyBtn.textContent = "Copy to clipboard"; }, 1500);
852
+ setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
622
853
  } catch {}
623
854
  });
624
855
 
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(() => {});
856
+ function activeRenewScore() {
857
+ const st = lastState || {};
858
+ return st.active || st;
859
+ }
860
+
861
+ function sessionKeyFor(state, score) {
862
+ if (!score || !score.sourcePath) return "";
863
+ const clientName = String(score.client || "");
864
+ const src = String(score.sourcePath || "");
865
+ const list = (state && state.recentSessions) || [];
866
+ const match = list.filter((s) => s.client === clientName && s.sourcePath === src)[0];
867
+ return clientName + "::" + (match && match.id ? match.id : src);
868
+ }
869
+
870
+ function clearRenewResult() {
871
+ lastCapsule = "";
872
+ renewResultSessionKey = "";
873
+ if (renewPre) {
874
+ renewPre.textContent = "";
875
+ renewPre.style.display = "none";
876
+ }
877
+ if (renewMsg) renewMsg.textContent = "";
878
+ if (copyBtn) {
879
+ copyBtn.textContent = "Copy";
880
+ copyBtn.style.display = "";
881
+ }
882
+ if (renewResult) renewResult.style.display = "none";
883
+ }
884
+
885
+ function showRenewResult(opts) {
886
+ const prompt = String((opts && opts.prompt) || "");
887
+ const body = String((opts && opts.body) || prompt || "");
888
+ lastCapsule = prompt;
889
+ renewResultSessionKey = String((opts && opts.sessionKey) || "");
890
+ if (renewMsg) renewMsg.textContent = String((opts && opts.message) || "");
891
+ if (renewPre) {
892
+ renewPre.textContent = body;
893
+ renewPre.style.display = "none";
894
+ }
895
+ if (copyBtn) {
896
+ copyBtn.textContent = "Copy";
897
+ copyBtn.style.display = opts && opts.canCopy ? "inline-block" : "none";
898
+ }
899
+ if (renewResult) renewResult.style.display = "block";
900
+ syncHeight();
901
+ }
902
+
903
+ function checkpointMetaFor(score) {
904
+ return {
905
+ turn: Number(score && score.turn) || 0,
906
+ ctTokens: Number(score && score.ctTokens) || 0,
907
+ createdAt: Date.now(),
908
+ };
909
+ }
910
+
911
+ function saveCachedCheckpoint(key, prompt, kind, meta) {
912
+ if (!key || !prompt) return;
913
+ const next = readCheckpointCache().filter((item) => item.key !== key);
914
+ next.unshift({
915
+ key,
916
+ prompt: String(prompt),
917
+ kind: kind === "local" ? "local" : "cloud",
918
+ turn: Number(meta && meta.turn) || 0,
919
+ ctTokens: Number(meta && meta.ctTokens) || 0,
920
+ createdAt: Number(meta && meta.createdAt) || Date.now(),
921
+ renewedAt: Number(meta && meta.renewedAt) || Number(meta && meta.createdAt) || Date.now(),
922
+ contextId: typeof (meta && meta.contextId) === "string" ? meta.contextId : "",
923
+ sessionMemoryCount: Number.isFinite(Number(meta && meta.sessionMemoryCount)) ? Number(meta.sessionMemoryCount) : null,
924
+ saved: Number.isFinite(Number(meta && meta.saved)) ? Number(meta.saved) : null,
925
+ });
926
+ try { localStorage.setItem(CHECKPOINT_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_CACHE_LIMIT))); } catch {}
927
+ }
928
+
929
+ function readCheckpointCache() {
930
+ try {
931
+ const cutoff = Date.now() - CHECKPOINT_CACHE_TTL_MS;
932
+ const raw = JSON.parse(localStorage.getItem(CHECKPOINT_CACHE_KEY) || "[]");
933
+ return Array.isArray(raw)
934
+ ? raw.filter((item) => item && typeof item.key === "string" && typeof item.prompt === "string" && Number(item.createdAt || 0) >= cutoff)
935
+ : [];
936
+ } catch {
937
+ return [];
938
+ }
939
+ }
940
+
941
+ function cachedCheckpointFor(key) {
942
+ return readCheckpointCache().filter((item) => item.key === key)[0] || null;
943
+ }
944
+
945
+ function restoreCachedCheckpointFor(score, key) {
946
+ if (!key || renewStartMs || renewResultSessionKey === key) return false;
947
+ const cached = cachedCheckpointFor(key);
948
+ if (!cached) return false;
949
+ showRenewResult({
950
+ sessionKey: key,
951
+ prompt: cached.prompt,
952
+ message: cachedCheckpointMessage(score, cached),
953
+ canCopy: true,
954
+ });
955
+ return true;
956
+ }
957
+
958
+ function cachedCheckpointMessage(score, cached) {
959
+ const savedTurn = Number(cached && cached.turn) || 0;
960
+ const currentTurn = Number(score && score.turn) || 0;
961
+ const stale = savedTurn > 0 && currentTurn > savedTurn;
962
+ const summary = checkpointSummary(cached);
963
+ if (cached && cached.kind === "local") {
964
+ return savedTurn > 0 ? "Local capsule from turn " + savedTurn + checkpointSuffix(summary) : "Local capsule ready" + checkpointSuffix(summary);
965
+ }
966
+ return savedTurn > 0 && stale
967
+ ? "Decision log from turn " + savedTurn + checkpointSuffix(summary)
968
+ : "Decision log ready" + checkpointSuffix(summary);
969
+ }
970
+
971
+ function statusFromRenewData(data, meta) {
972
+ return {
973
+ fetched: true,
974
+ hasCheckpoint: Boolean(data && data.ok),
975
+ contextId: typeof (data && data.contextId) === "string" ? data.contextId : String((meta && meta.contextId) || ""),
976
+ renewedAt: Number(meta && meta.renewedAt) || Date.now(),
977
+ memoryCount: Number.isFinite(Number(data && data.sessionMemoryCount)) ? Number(data.sessionMemoryCount) : Number(meta && meta.sessionMemoryCount) || 0,
978
+ checkpointMemoryId: "",
979
+ };
980
+ }
981
+
982
+ function checkpointSummary(item) {
983
+ const parts = [];
984
+ const renewedAt = timestampMs(item && item.renewedAt);
985
+ if (renewedAt) parts.push("renewed " + relTime(Date.now() - renewedAt));
986
+ const count = Number(item && (item.memoryCount ?? item.sessionMemoryCount));
987
+ if (Number.isFinite(count) && count > 0) parts.push(count + " " + (count === 1 ? "memory" : "memories") + " saved");
988
+ return parts.join(" · ");
989
+ }
990
+
991
+ function checkpointSuffix(summary) {
992
+ return summary ? " · " + summary : "";
993
+ }
994
+
995
+ function timestampMs(value) {
996
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
997
+ if (typeof value === "string" && value) {
998
+ const ms = Date.parse(value);
999
+ return Number.isFinite(ms) ? ms : 0;
1000
+ }
1001
+ return 0;
1002
+ }
1003
+
1004
+ function readCheckpointStatusCache() {
1005
+ try {
1006
+ const cutoff = Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS;
1007
+ const raw = JSON.parse(localStorage.getItem(CHECKPOINT_STATUS_CACHE_KEY) || "[]");
1008
+ return Array.isArray(raw)
1009
+ ? raw.filter((item) => item && typeof item.key === "string" && item.fetched === true && Number(item.fetchedAt || 0) >= cutoff)
1010
+ : [];
1011
+ } catch {
1012
+ return [];
1013
+ }
1014
+ }
1015
+
1016
+ function cachedCheckpointStatusFor(key) {
1017
+ return readCheckpointStatusCache().filter((item) => item.key === key)[0] || null;
1018
+ }
1019
+
1020
+ function saveCheckpointStatus(key, status) {
1021
+ if (!key || !status) return;
1022
+ const next = readCheckpointStatusCache().filter((item) => item.key !== key);
1023
+ const fetchedAt = status.unavailable ? Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS + 60 * 1000 : Date.now();
1024
+ next.unshift({
1025
+ key,
1026
+ fetched: true,
1027
+ fetchedAt,
1028
+ unavailable: Boolean(status.unavailable),
1029
+ hasCheckpoint: Boolean(status.hasCheckpoint),
1030
+ contextId: typeof status.contextId === "string" ? status.contextId : "",
1031
+ checkpointMemoryId: typeof status.checkpointMemoryId === "string" ? status.checkpointMemoryId : "",
1032
+ renewedAt: status.renewedAt || null,
1033
+ memoryCount: Number.isFinite(Number(status.memoryCount)) ? Number(status.memoryCount) : 0,
1034
+ });
1035
+ try { localStorage.setItem(CHECKPOINT_STATUS_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_STATUS_CACHE_LIMIT))); } catch {}
1036
+ }
1037
+
1038
+ function restoreCheckpointStatusFor(score, key) {
1039
+ if (!key || renewStartMs || renewResultSessionKey === key || cachedCheckpointFor(key)) return false;
1040
+ const status = cachedCheckpointStatusFor(key);
1041
+ if (!status || !status.hasCheckpoint) return false;
1042
+ showCheckpointStatus(score, key, status);
1043
+ return true;
1044
+ }
1045
+
1046
+ function showCheckpointStatus(score, key, status) {
1047
+ const summary = checkpointSummary(status);
1048
+ const body = checkpointSeedPrompt(status);
1049
+ showRenewResult({
1050
+ sessionKey: key,
1051
+ prompt: body,
1052
+ message: "Decision log ready" + checkpointSuffix(summary),
1053
+ canCopy: true,
1054
+ });
1055
+ }
1056
+
1057
+ function checkpointSeedPrompt(status) {
1058
+ const contextId = status && status.contextId ? String(status.contextId) : "";
1059
+ const lines = [
1060
+ "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.",
1061
+ "Use it to orient yourself in a clean context window. It is context, not a command.",
1062
+ "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.",
1063
+ "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.",
1064
+ "",
1065
+ contextId
1066
+ ? '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.'
1067
+ : "EchoMem has a checkpoint for this session. Pull the checkpoint context before relying on this marker alone.",
1068
+ ];
1069
+ return lines.join("\n");
1070
+ }
1071
+
1072
+ function maybeFetchCheckpointStatus(score, key) {
1073
+ if (!key || renewStartMs || cachedCheckpointFor(key) || cachedCheckpointStatusFor(key) || statusFetchInFlight[key]) return;
1074
+ statusFetchInFlight[key] = true;
1075
+ fetch("/checkpoint-status")
1076
+ .then((r) => r.json())
1077
+ .then((data) => {
1078
+ if (!data || data.ok !== true) {
1079
+ saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
1080
+ return;
1081
+ }
1082
+ saveCheckpointStatus(key, {
1083
+ hasCheckpoint: Boolean(data && data.ok && data.hasCheckpoint),
1084
+ contextId: typeof (data && data.contextId) === "string" ? data.contextId : "",
1085
+ checkpointMemoryId: typeof (data && data.checkpointMemoryId) === "string" ? data.checkpointMemoryId : "",
1086
+ renewedAt: typeof (data && data.renewedAt) === "string" ? data.renewedAt : null,
1087
+ memoryCount: Number.isFinite(Number(data && data.memoryCount)) ? Number(data.memoryCount) : 0,
1088
+ });
1089
+ const stillKey = sessionKeyFor(lastState, activeRenewScore());
1090
+ if (stillKey !== key || renewResultSessionKey === key || cachedCheckpointFor(key)) return;
1091
+ const status = cachedCheckpointStatusFor(key);
1092
+ if (status && status.hasCheckpoint) showCheckpointStatus(score, key, status);
1093
+ })
1094
+ .catch(() => {
1095
+ saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
1096
+ })
1097
+ .finally(() => { delete statusFetchInFlight[key]; });
1098
+ }
1099
+
1100
+ function localCapsulePrompt(capsule) {
1101
+ return [
1102
+ "Basic local capsule, not a full EchoMem decision log.",
1103
+ "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.",
1104
+ "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.",
1105
+ "",
1106
+ String(capsule || ""),
1107
+ ].join("\n");
1108
+ }
1109
+
1110
+ function startRenewTimer(score) {
1111
+ const seq = ++renewEstimateSeq;
1112
+ renewEta = { ms: fallbackEtaForScore(score || activeRenewScore()) };
1113
+ renewStartMs = Date.now();
1114
+ if (renewTimer) clearInterval(renewTimer);
1115
+ renderRenewLoadingLabel();
1116
+ renewTimer = setInterval(renderRenewLoadingLabel, 1000);
1117
+ fetch("/renew-estimate")
1118
+ .then((r) => r.json())
1119
+ .then((data) => {
1120
+ if (seq !== renewEstimateSeq || !renewStartMs) return;
1121
+ if (data && data.ok && Number.isFinite(Number(data.etaMs))) {
1122
+ renewEta = { ms: Number(data.etaMs) };
1123
+ renderRenewLoadingLabel();
1124
+ }
1125
+ })
1126
+ .catch(() => {});
1127
+ }
1128
+
1129
+ function stopRenewTimer() {
1130
+ if (renewTimer) clearInterval(renewTimer);
1131
+ renewTimer = null;
1132
+ renewStartMs = 0;
1133
+ renewEta = null;
1134
+ renewEstimateSeq += 1;
1135
+ }
1136
+
1137
+ function renderRenewLoadingLabel() {
1138
+ if (!renewBtn || !renewStartMs) return;
1139
+ const elapsed = Date.now() - renewStartMs;
1140
+ const remaining = Math.max(0, Number(renewEta && renewEta.ms) || 20000) - elapsed;
1141
+ const detail = remaining > 0 ? "ETA " + fmtDuration(remaining) + " left" : "ETA any moment";
1142
+ renewBtn.innerHTML = "<b>Preparing context&hellip;</b><small>" + esc(detail) + "</small>";
1143
+ }
1144
+
1145
+ function fallbackEtaForScore(score) {
1146
+ const n = Number(score && score.ctTokens) || 0;
1147
+ if (n <= 5000) return 10000;
1148
+ if (n <= 10000) return 12000;
1149
+ if (n <= 25000) return 15000;
1150
+ if (n <= 50000) return 18000;
1151
+ if (n <= 100000) return 20000;
1152
+ return 45000;
1153
+ }
1154
+
1155
+ function fmtDuration(ms) {
1156
+ const s = Math.max(0, Math.ceil(Number(ms || 0) / 1000));
1157
+ if (s < 60) return s + "s";
1158
+ const m = Math.floor(s / 60);
1159
+ const r = s % 60;
1160
+ return r ? m + "m " + r + "s" : m + "m";
1161
+ }
1162
+
1163
+ // Dev/design preview: ?preview=green|amber|red renders that health state with sample data and
1164
+ // skips the live feed — same pattern as the extraction page's ?preview hook.
1165
+ const previewParams = new URLSearchParams(location.search);
1166
+ const previewColor = previewParams.get("preview");
1167
+ const previewPinned = previewParams.get("pinned") === "1";
1168
+ if (previewParams.get("capture") === "1") hud.classList.add("capture-preview");
1169
+ if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
1170
+ const sample = {
1171
+ client: "codex",
1172
+ label: "EchoMem-Chrome",
1173
+ sourcePath: previewPinned ? "/preview/b.jsonl" : "/preview/session.jsonl",
1174
+ usefulPct: previewColor === "red" ? 55 : previewColor === "amber" ? 75 : 95,
1175
+ healthScorePct: previewColor === "red" ? 48 : previewColor === "amber" ? 72 : 96,
1176
+ color: previewColor,
1177
+ ctTokens: previewColor === "red" ? 165000 : previewColor === "amber" ? 110000 : 24000,
1178
+ modelContextWindow: 258000,
1179
+ pollutionPct: previewColor === "red" ? 45 : previewColor === "amber" ? 24 : 4,
1180
+ pollutionTok: previewColor === "red" ? 74000 : previewColor === "amber" ? 26000 : 900,
1181
+ buckets: { range_redundant: { count: previewColor === "red" ? 61 : previewColor === "amber" ? 18 : 0 } },
1182
+ stats: { patchEdits: 90 },
1183
+ turn: 28,
1184
+ lastActiveMs: 6000,
1185
+ ctSource: "measured",
1186
+ };
1187
+ render({
1188
+ active: sample,
1189
+ pinnedId: previewPinned ? "p2" : null,
1190
+ recentSessions: [
1191
+ { id: "p1", client: "codex", title: "Simplify HUD session display", label: "EchoMem-Chrome", sourcePath: "/preview/session.jsonl", live: true },
1192
+ { id: "p2", client: "codex", title: "修复 HUD 切换和图标", label: "EchoMem-Chrome", sourcePath: "/preview/b.jsonl", live: false },
1193
+ { id: "p3", client: "codex", title: "Fix extraction preview", label: "EchoMem-Chrome", sourcePath: "/preview/c.jsonl", live: false },
1194
+ { id: "p4", client: "codex", title: "Port Claude work", label: "EchoMem-Chrome", sourcePath: "/preview/d.jsonl", live: false },
1195
+ { id: "p5", client: "codex", title: "Stamp fixes with Echo mascot", label: "EchoMem-Chrome", sourcePath: "/preview/e.jsonl", live: false },
1196
+ { id: "p6", client: "codex", title: "Review migration checks", label: "EchoMem-Chrome", sourcePath: "/preview/f.jsonl", live: false },
1197
+ { id: "p7", client: "codex", title: "Polish setup story", label: "EchoMem-Chrome", sourcePath: "/preview/g.jsonl", live: false },
1198
+ { id: "p8", client: "claude-code", title: "DG-Web refactor", label: "DG-Web", sourcePath: "/preview/h.jsonl", live: false },
1199
+ ],
1200
+ });
1201
+ hud.classList.add("open");
1202
+ syncHeight();
1203
+ } else {
1204
+ const events = new EventSource("/events");
1205
+ events.onmessage = (event) => render(JSON.parse(event.data));
1206
+ fetch("/state").then((r) => r.json()).then(render).catch(() => {});
1207
+ }
628
1208
 
629
1209
  function render(state) {
630
1210
  const score = state.active || state;
631
1211
  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
- }
1212
+ const activeClient = score && score.client ? score.client : state && state.active && state.active.client;
1213
+ const currentSessionKey = sessionKeyFor(state, score);
1214
+ const viewColor = score && typeof score.usefulPct === "number" ? (score.color || "green") : "idle";
1215
+ if (renewResultSessionKey && renewResultSessionKey !== currentSessionKey) clearRenewResult();
1216
+ const hasSessionSwitcher = renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null, activeClient, viewColor);
1217
+ renderSwitchButton(state, activeClient);
638
1218
  if (!score || typeof score.usefulPct !== "number") {
639
1219
  renderIdle(state);
640
1220
  return;
641
1221
  }
642
1222
 
643
1223
  const color = score.color || "green";
644
- const stateName = stateWord(color);
645
1224
  const sat = fullnessPct(score);
646
1225
  const oldPct = clamp(score.pollutionPct || 0, 0, 95);
1226
+ const health = healthScore(score);
647
1227
  const repeatCount = score.buckets && score.buckets.range_redundant ? score.buckets.range_redundant.count || 0 : 0;
648
- const edits = score.stats && score.stats.patchEdits ? Number(score.stats.patchEdits) : 0;
649
-
650
- // A session idle for >10 min shouldn't wear the cheerful "Fresh/clean" look — its quality
651
- // numbers are stale. Show it muted as Idle so a 47h-old pinned session never reads as current.
652
- const stale = typeof score.lastActiveMs === "number" && score.lastActiveMs > 600000;
653
- const shownColor = stale ? "idle" : color;
654
- dot.className = "dot " + shownColor;
655
- stateDot.className = stale ? "state-dot" : "state-dot " + color;
1228
+ const info = activeSessionInfo(score, state);
1229
+
1230
+ dot.className = "dot " + color;
1231
+ stateDot.className = "state-dot " + color;
656
1232
  hud.classList.remove("s-green", "s-amber", "s-red");
657
- if (!stale) hud.classList.add("s-" + color);
658
- main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
659
- client.textContent = sessionIdentity(score, state);
1233
+ hud.classList.add("s-" + color);
1234
+ main.textContent = health + "%";
1235
+ renderMini(color, health + "%", info);
1236
+ client.textContent = info.title || info.repo;
1237
+ if (clientRepo) clientRepo.textContent = info.repo && info.repo !== info.title ? info.repo : "";
1238
+ client.classList.remove("voice");
660
1239
  activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
661
1240
  renderUpdated();
662
1241
  turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
663
- stateWordEl.textContent = stale ? "Idle" : stateName;
664
- fullness.textContent = fullnessLine(sat, score);
665
- fullness.style.color = stale ? "#6b675d" : tone(color);
666
- lead.textContent = stale ? "This session is idle - showing its last state" : leadLine(color);
667
- lead.style.color = stale ? "#6b675d" : tone(color);
668
- evidence.textContent = evidenceLine(score, oldPct, repeatCount);
669
- usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
670
- oldbar.style.width = oldPct + "%";
671
- metaEl.textContent = ctSourceLabel(score.ctSource) + " - lower-bound signal - " + edits + " edits";
1242
+ stateWordEl.textContent = health + "%";
1243
+ fullness.textContent = hasSessionSwitcher ? "" : info.title;
1244
+ fullness.style.display = hasSessionSwitcher ? "none" : "block";
1245
+ fullness.style.color = "";
1246
+ lead.textContent = hasSessionSwitcher ? "" : (info.repo && info.repo !== info.title ? info.repo : "");
1247
+ lead.style.display = hasSessionSwitcher || !lead.textContent ? "none" : "block";
1248
+ lead.style.color = "";
1249
+ renderWhy(score, oldPct, repeatCount, sat, health, color);
1250
+ metaEl.textContent = "";
672
1251
  pathEl.textContent = "";
673
1252
  currentSrc = score.sourcePath || null;
674
- if (revealEl) revealEl.style.display = currentSrc ? "inline-block" : "none";
1253
+ if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
675
1254
  missing.textContent = formatMissing(state.missing);
676
1255
 
677
- renderAlert(score, oldPct, repeatCount);
678
- renderNudge(color, sat);
1256
+ nudge.style.display = "none";
679
1257
 
680
- if (renewBtn) renewBtn.style.display = "block"; // always available — warm-start any session, even fresh
1258
+ renewUrgent = color === "red";
1259
+ if (renewBtn) {
1260
+ renewBtn.style.display = "block";
1261
+ refreshRenewLabel();
1262
+ }
1263
+ const restoredPrompt = restoreCachedCheckpointFor(score, currentSessionKey);
1264
+ if (!restoredPrompt) {
1265
+ const restoredStatus = restoreCheckpointStatusFor(score, currentSessionKey);
1266
+ if (!restoredStatus) maybeFetchCheckpointStatus(score, currentSessionKey);
1267
+ }
681
1268
  prevTurnState = {
682
1269
  src: score.sourcePath,
683
1270
  turn: score.turn,
@@ -694,92 +1281,62 @@ export const HUD_HTML = String.raw `<!doctype html>
694
1281
  stateDot.className = "state-dot";
695
1282
  hud.classList.remove("s-green", "s-amber", "s-red");
696
1283
  prevTurnState = null;
697
- main.textContent = "No active session yet";
698
- client.textContent = "EchoMem HUD - " + hudVersion;
1284
+ main.textContent = "MCP on";
1285
+ renderMini("idle", "on", { client: "EchoMem", title: "MCP", repo: "" });
1286
+ client.textContent = "Waiting for Codex or Claude";
1287
+ if (clientRepo) clientRepo.textContent = "";
1288
+ client.classList.remove("voice");
699
1289
  activityEpoch = null;
700
1290
  if (updatedEl) updatedEl.textContent = "";
701
1291
  currentSrc = null;
702
1292
  if (revealEl) revealEl.style.display = "none";
703
1293
  turnEl.textContent = "";
704
- stateWordEl.textContent = "Fresh";
705
- fullness.textContent = "";
706
- lead.textContent = "Waiting for a live Codex or Claude session";
1294
+ stateWordEl.textContent = "on";
1295
+ fullness.textContent = "EchoMem MCP";
1296
+ fullness.style.display = "block";
1297
+ lead.textContent = "Waiting for Codex or Claude";
1298
+ lead.style.display = "block";
707
1299
  lead.style.color = "#6b675d";
708
- evidence.textContent = "Start working and EchoMem will show when the context is still clean, drifting, or ready to renew.";
709
- usefulbar.style.width = "100%";
710
- oldbar.style.width = "0";
711
- alertEl.style.display = "none";
1300
+ evidence.textContent = "";
1301
+ if (whyBtn) whyBtn.style.display = "none";
1302
+ whyOpen = false;
1303
+ hud.classList.remove("show-why");
712
1304
  nudge.style.display = "none";
713
1305
  if (renewBtn) renewBtn.style.display = "none";
714
- if (renewResult) renewResult.style.display = "none";
1306
+ clearRenewResult();
715
1307
  metaEl.textContent = "";
716
1308
  pathEl.textContent = "";
717
1309
  missing.textContent = formatMissing(state && state.missing);
718
1310
  syncHeight();
719
1311
  }
720
1312
 
721
- function renderAlert(score, oldPct, repeatCount) {
722
- if (!alertEl) return;
723
- const turnChanged = prevTurnState && prevTurnState.src === score.sourcePath && score.turn !== prevTurnState.turn;
724
- const dCt = turnChanged ? (score.ctTokens || 0) - prevTurnState.ct : 0;
725
- const dReads = turnChanged ? (score.reads || 0) - prevTurnState.reads : 0;
726
- const dRep = turnChanged ? repeatCount - prevTurnState.repeat : 0;
727
- const dOld = turnChanged ? (score.pollutionTok || 0) - prevTurnState.oldTok : 0;
728
- if (dRep > 0 || dOld > 2000) {
729
- alertTitle.textContent = "this turn re-read what it already had";
730
- alertDetail.textContent = joinParts([
731
- dOld > 0 ? "+" + fmt(dOld) + " old weight" : "",
732
- dReads > 0 ? "+" + dReads + " read" + (dReads === 1 ? "" : "s") : "0 new files",
733
- dCt !== 0 ? signedFmt(dCt) + " ctx" : "",
734
- ]);
735
- alertEl.style.display = "block";
736
- return;
737
- }
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
- }
744
- alertEl.style.display = "none";
745
- }
746
-
747
- function renderNudge(color, sat) {
748
- let text = "";
749
- let cls = "nudge";
750
- if (color === "red") {
751
- text = "A fresh start now beats pushing past this - keep the goal, drop the clutter.";
752
- cls += " red";
753
- } else if (color === "amber" || sat >= 75) {
754
- text = "Still fine - a fresh start soon will keep this snappy.";
755
- cls += " calm";
756
- }
757
- nudge.textContent = text;
758
- nudge.className = cls;
759
- nudge.style.display = text ? "block" : "none";
760
- }
761
-
762
- function stateWord(color) {
763
- if (color === "red") return "Drifted";
764
- if (color === "amber") return "Drifting";
765
- return "Fresh";
766
- }
767
-
768
- 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";
772
- }
773
-
774
- 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;
1313
+ function renderMini(color, scoreText, info) {
1314
+ if (mdot) mdot.className = "mdot " + color;
1315
+ if (micon) micon.src = iconForFamily(agentFamily(info && info.clientRaw) || "codex");
1316
+ if (mscore) mscore.textContent = scoreText;
1317
+ if (msession) msession.textContent = miniTitle(info);
1318
+ if (mrepo) mrepo.textContent = info && info.repo ? info.repo : "";
777
1319
  }
778
1320
 
779
- function fullnessLine(sat, score) {
780
- if (!score.modelContextWindow) return fmt(score.ctTokens) + " context tokens";
781
- const room = sat >= 85 ? " - compaction near" : sat <= 30 ? " - plenty of room" : "";
782
- return sat + "% full - " + fmt(score.ctTokens) + " / " + fmt(score.modelContextWindow) + room;
1321
+ function renderWhy(score, oldPct, repeatCount, sat, health, color) {
1322
+ const rows = [];
1323
+ rows.push(["score", health + "%"]);
1324
+ if (sat > 0) rows.push(["context", score.modelContextWindow ? sat + "% full" : fmt(score.ctTokens) + " tokens"]);
1325
+ if (oldPct > 0 || score.pollutionTok > 0) rows.push(["old context", fmt(score.pollutionTok || 0) + " (" + Math.round(oldPct) + "%)"]);
1326
+ if (repeatCount > 0) rows.push(["repeated reads", String(repeatCount)]);
1327
+ rows.push(["source", ctSourceLabel(score.ctSource)]);
1328
+ evidence.innerHTML = rows.map((row) => '<div class="why-line"><span class="why-k">' + esc(row[0]) + '</span><span class="why-v">' + esc(row[1]) + "</span></div>").join("");
1329
+ const useful = color !== "green" || oldPct >= 10 || repeatCount > 0 || sat >= 80;
1330
+ if (whyBtn) {
1331
+ whyBtn.style.display = useful ? "inline-flex" : "none";
1332
+ whyBtn.textContent = whyOpen ? "hide" : "why";
1333
+ }
1334
+ if (!useful) {
1335
+ whyOpen = false;
1336
+ hud.classList.remove("show-why");
1337
+ } else {
1338
+ hud.classList.toggle("show-why", whyOpen);
1339
+ }
783
1340
  }
784
1341
 
785
1342
  function fullnessPct(score) {
@@ -788,10 +1345,10 @@ export const HUD_HTML = String.raw `<!doctype html>
788
1345
  return 0;
789
1346
  }
790
1347
 
791
- function tone(color) {
792
- if (color === "red") return "#c7372f";
793
- if (color === "amber") return "#8a6a12";
794
- return "#2f7d4f";
1348
+ function healthScore(score) {
1349
+ if (typeof score.healthScorePct === "number" && isFinite(score.healthScorePct)) return clamp(score.healthScorePct, 0, 100);
1350
+ if (typeof score.usefulPct === "number" && isFinite(score.usefulPct)) return clamp(score.usefulPct, 0, 100);
1351
+ return 0;
795
1352
  }
796
1353
 
797
1354
  function ctSourceLabel(src) {
@@ -804,34 +1361,95 @@ export const HUD_HTML = String.raw `<!doctype html>
804
1361
  return "Not seeing " + list.map(label).join(", ") + " yet";
805
1362
  }
806
1363
 
807
- // Subtitle: which session this is (client + project) + version. The "other clients working"
808
- // signal now lives in the live-spinner tabs, so it's dropped here to keep the line short.
809
- function sessionIdentity(score) {
810
- const parts = [label(score.client)];
811
- if (score.label) parts.push(score.label);
812
- parts.push(hudVersion);
813
- return parts.join(" · ");
1364
+ function activeSessionInfo(score, state) {
1365
+ const list = (state && state.recentSessions) || [];
1366
+ const match = list.filter((s) => s.client === score.client && s.sourcePath === score.sourcePath)[0];
1367
+ const repo = String((match && match.label) || score.label || "");
1368
+ const title = String((match && match.title) || repo || label(score.client));
1369
+ return {
1370
+ client: label(score.client),
1371
+ clientRaw: score.client,
1372
+ title,
1373
+ repo,
1374
+ };
814
1375
  }
815
1376
 
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;
1377
+ function miniTitle(info) {
1378
+ if (!info) return "EchoMem";
1379
+ const title = info.title && info.title !== info.client ? info.title : info.repo;
1380
+ return title || info.client;
1381
+ }
1382
+
1383
+ function renderSwitchButton(state, activeClient) {
1384
+ if (!switchBtn) return;
1385
+ const target = switchTarget(state, activeClient);
1386
+ if (!target) {
1387
+ switchBtn.style.display = "none";
1388
+ switchBtn.removeAttribute("data-target-family");
1389
+ switchBtn.removeAttribute("data-target-id");
1390
+ switchBtn.innerHTML = "";
1391
+ return;
1392
+ }
1393
+ const family = agentFamily(target.client);
1394
+ const name = family === "claude" ? "Claude" : "Codex";
1395
+ switchBtn.dataset.targetFamily = family;
1396
+ switchBtn.dataset.targetId = target.id;
1397
+ switchBtn.title = "Switch to " + name;
1398
+ switchBtn.innerHTML = '<img class="switch-icon" src="' + iconForFamily(family) + '" alt="" /><span class="switch-copy">Switch to ' + name + "</span>";
1399
+ switchBtn.style.display = "inline-flex";
1400
+ }
1401
+
1402
+ function switchTarget(state, activeClient) {
819
1403
  const list = (state && state.recentSessions) || [];
820
- if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
1404
+ if (!list.length) return null;
1405
+ const currentFamily = agentFamily(activeClient || (state.active && state.active.client));
1406
+ const targetFamily = currentFamily === "codex" ? "claude" : currentFamily === "claude" ? "codex" : "";
1407
+ if (!targetFamily) return null;
1408
+ return list.filter((s) => agentFamily(s.client) === targetFamily)[0] || null;
1409
+ }
1410
+
1411
+ // Recent sessions as switchable rows. "Auto" clears the pin. Hidden when there's only one
1412
+ // session for the active agent family, so Codex never recommends Claude sessions or vice versa.
1413
+ function renderTabs(state, activeId, activeClient, viewColor) {
1414
+ if (!tabsEl) return false;
1415
+ const list = visibleRecentSessions(state, activeClient);
1416
+ if (list.length <= 1) { tabsEl.innerHTML = ""; return false; }
1417
+ const pinnedId = state && state.pinnedId ? state.pinnedId : null;
821
1418
  const chips = list.map((s) => {
822
- const cls = s.id === activeId ? "tab active" : "tab";
823
- const dot = s.live ? '<span class="spin"></span>' : "";
1419
+ const viewing = s.id === activeId;
1420
+ const cls = "tab" + (viewing ? " viewing" : "") + (s.id === pinnedId ? " pinned" : "");
1421
+ const activity = s.live && !viewing ? '<span class="spin"></span>' : "";
1422
+ const status = viewing ? '<span class="tab-status ' + esc(viewColor || "idle") + '" aria-label="Current session health"></span>' : "";
824
1423
  const title = s.title || label(s.client);
825
- return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
1424
+ const repo = s.label && s.label !== title ? s.label : "";
1425
+ const family = agentFamily(s.client);
1426
+ return '<button class="' + cls + '" data-id="' + esc(s.id) + '"' + (viewing ? ' aria-current="true"' : "") + ' title="' + esc((viewing ? "Currently viewing · " : "") + label(s.client) + " · " + title + (repo ? " · " + repo : "")) + '">'
1427
+ + '<img class="tab-icon" src="' + iconForFamily(family) + '" alt="" />'
1428
+ + '<span class="tab-copy"><span class="tab-title">' + esc(title) + activity + '</span><span class="tab-repo">' + esc(repo || label(s.client)) + "</span></span>" + status + "</button>";
826
1429
  });
827
- chips.unshift('<button class="tab' + (activeId ? "" : " active") + '" data-id="auto">Auto</button>');
1430
+ chips.unshift('<button class="tab auto-tab' + (pinnedId ? "" : " mode-active") + '" data-id="auto" title="' + (pinnedId ? "Use Auto selection" : "Auto selection is on") + '"><span class="tab-title">Auto</span><span class="auto-check" aria-hidden="true">&#10003;</span></button>');
828
1431
  tabsEl.innerHTML = chips.join("");
1432
+ return true;
829
1433
  }
830
- function activeIdFor(state, src) {
1434
+ function visibleRecentSessions(state, activeClient) {
831
1435
  const list = (state && state.recentSessions) || [];
1436
+ const family = agentFamily(activeClient);
1437
+ if (family !== "codex" && family !== "claude") return list;
1438
+ return list.filter((s) => agentFamily(s.client) === family);
1439
+ }
1440
+ function activeIdFor(state, src, activeClient) {
1441
+ const list = visibleRecentSessions(state, activeClient);
832
1442
  const match = list.filter((s) => s.sourcePath === src)[0];
833
1443
  return match ? match.id : null;
834
1444
  }
1445
+ function agentFamily(clientName) {
1446
+ if (clientName === "codex") return "codex";
1447
+ if (String(clientName || "").indexOf("claude") === 0) return "claude";
1448
+ return "";
1449
+ }
1450
+ function iconForFamily(family) {
1451
+ return family === "claude" ? "/assets/hud/claude.svg" : "/assets/hud/codex.svg";
1452
+ }
835
1453
  function shortTitle(t) {
836
1454
  t = String(t || "");
837
1455
  return t.length > 22 ? t.slice(0, 21) + "…" : t;
@@ -868,14 +1486,6 @@ export const HUD_HTML = String.raw `<!doctype html>
868
1486
  return String(Math.round(n));
869
1487
  }
870
1488
 
871
- function signedFmt(value) {
872
- return (value >= 0 ? "+" : "-") + fmt(Math.abs(value));
873
- }
874
-
875
- function joinParts(parts) {
876
- return parts.filter(Boolean).join(" - ");
877
- }
878
-
879
1489
  function clamp(value, min, max) {
880
1490
  return Math.max(min, Math.min(max, Number(value) || 0));
881
1491
  }