uv-suite 0.27.0 → 0.29.0

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.
@@ -305,7 +305,12 @@
305
305
  .pill.priority-high { background: rgba(255, 69, 58, 0.18); color: var(--danger); }
306
306
  .pill.kind-long-running { background: rgba(100, 210, 255, 0.18); color: var(--info); }
307
307
  .pill.kind-outcome { background: rgba(255, 105, 97, 0.18); color: var(--peach); }
308
+ .pill.lifecycle-active { background: rgba(48, 209, 88, 0.18); color: var(--success); }
309
+ .pill.lifecycle-idle { background: rgba(154, 154, 163, 0.18); color: var(--text-muted); }
310
+ .pill.lifecycle-terminated { background: rgba(255, 69, 58, 0.16); color: var(--danger); }
308
311
 
312
+ .session-tag.lifecycle-terminated { opacity: 0.55; }
313
+ .session-tag.lifecycle-terminated strong { text-decoration: line-through; }
309
314
  .session-tag.priority-low { opacity: 0.6; }
310
315
  .session-tag .meta-line {
311
316
  display: block;
@@ -404,6 +409,124 @@
404
409
  font-size: 12px;
405
410
  margin-left: 8px;
406
411
  }
412
+
413
+ /* Header buttons (snapshot, snapshots-list) */
414
+ .header-btn {
415
+ background: transparent;
416
+ color: var(--text-muted);
417
+ border: 1px solid var(--border);
418
+ border-radius: 7px;
419
+ height: 30px;
420
+ padding: 0 12px;
421
+ font-size: 13px;
422
+ font-family: inherit;
423
+ cursor: pointer;
424
+ transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
425
+ }
426
+ .header-btn:hover { color: var(--text); border-color: var(--text-dim); background: var(--surface); }
427
+ .header-btn.primary { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
428
+ .header-btn.primary:hover { opacity: 0.9; }
429
+ .header-btn:disabled { opacity: 0.5; cursor: not-allowed; }
430
+
431
+ /* Snapshots modal overlay */
432
+ .modal-overlay {
433
+ position: fixed;
434
+ inset: 0;
435
+ background: rgba(0,0,0,0.45);
436
+ display: none;
437
+ align-items: center;
438
+ justify-content: center;
439
+ z-index: 100;
440
+ }
441
+ .modal-overlay.open { display: flex; }
442
+ .modal {
443
+ background: var(--bg);
444
+ border: 1px solid var(--border);
445
+ border-radius: 10px;
446
+ width: 720px;
447
+ max-width: 92vw;
448
+ max-height: 80vh;
449
+ overflow: hidden;
450
+ display: flex;
451
+ flex-direction: column;
452
+ }
453
+ .modal-header {
454
+ padding: 16px 20px;
455
+ border-bottom: 1px solid var(--border-subtle);
456
+ display: flex;
457
+ align-items: center;
458
+ justify-content: space-between;
459
+ }
460
+ .modal-header h2 { font-size: 16px; font-weight: 600; }
461
+ .modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
462
+ .modal-close {
463
+ background: transparent;
464
+ color: var(--text-dim);
465
+ border: none;
466
+ font-size: 22px;
467
+ cursor: pointer;
468
+ line-height: 1;
469
+ }
470
+ .modal-close:hover { color: var(--text); }
471
+
472
+ .snapshot-list { display: flex; flex-direction: column; gap: 8px; }
473
+ .snapshot-row {
474
+ padding: 12px 14px;
475
+ border: 1px solid var(--border-subtle);
476
+ border-radius: 7px;
477
+ cursor: pointer;
478
+ transition: background-color 0.15s ease, border-color 0.15s ease;
479
+ }
480
+ .snapshot-row:hover { background: var(--surface); border-color: var(--text-dim); }
481
+ .snapshot-row .id { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-muted); }
482
+ .snapshot-row .meta { display: flex; justify-content: space-between; align-items: center; }
483
+
484
+ .snapshot-detail .sess-row {
485
+ margin-bottom: 14px;
486
+ padding: 12px 14px;
487
+ border: 1px solid var(--border-subtle);
488
+ border-radius: 7px;
489
+ }
490
+ .snapshot-detail .sess-row .head {
491
+ display: flex;
492
+ align-items: center;
493
+ justify-content: space-between;
494
+ margin-bottom: 6px;
495
+ gap: 8px;
496
+ }
497
+ .snapshot-detail .sess-row .name { font-weight: 600; }
498
+ .snapshot-detail .sess-row .preview {
499
+ color: var(--text-muted);
500
+ font-size: 12.5px;
501
+ line-height: 1.55;
502
+ white-space: pre-wrap;
503
+ max-height: 100px;
504
+ overflow-y: auto;
505
+ }
506
+ .snapshot-toast {
507
+ position: fixed;
508
+ bottom: 20px;
509
+ right: 20px;
510
+ background: var(--surface);
511
+ border: 1px solid var(--border);
512
+ color: var(--text);
513
+ padding: 10px 14px;
514
+ border-radius: 7px;
515
+ font-size: 13px;
516
+ box-shadow: 0 4px 12px rgba(0,0,0,0.25);
517
+ display: none;
518
+ z-index: 200;
519
+ max-width: 480px;
520
+ }
521
+ .snapshot-toast.show { display: block; }
522
+ .snapshot-toast pre {
523
+ margin-top: 6px;
524
+ padding: 8px;
525
+ background: var(--bg);
526
+ border-radius: 4px;
527
+ font-size: 11.5px;
528
+ overflow-x: auto;
529
+ }
407
530
  </style>
408
531
  </head>
409
532
  <body>
@@ -412,6 +535,8 @@
412
535
  <h1>UV Suite Watchtower</h1>
413
536
  <div class="meta">
414
537
  <div class="status"><span class="dot" id="statusDot"></span><span id="statusText">Connecting...</span></div>
538
+ <button class="header-btn primary" id="btnSnapshot" type="button" title="Snapshot every active session now">Snapshot all</button>
539
+ <button class="header-btn" id="btnSnapshotsList" type="button" title="Open the list of past snapshots">Snapshots…</button>
415
540
  <button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme" title="Toggle theme">
416
541
  <svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
417
542
  <circle cx="12" cy="12" r="4"/>
@@ -454,6 +579,18 @@
454
579
  </div>
455
580
  </div>
456
581
 
582
+ <div class="modal-overlay" id="snapshotsModal">
583
+ <div class="modal">
584
+ <div class="modal-header">
585
+ <h2 id="snapshotsModalTitle">Snapshots</h2>
586
+ <button class="modal-close" id="snapshotsModalClose" type="button" aria-label="Close">×</button>
587
+ </div>
588
+ <div class="modal-body" id="snapshotsModalBody"></div>
589
+ </div>
590
+ </div>
591
+
592
+ <div class="snapshot-toast" id="snapshotToast"></div>
593
+
457
594
 
458
595
  <script>
459
596
  const timeline = document.getElementById('timeline');
@@ -487,9 +624,10 @@ function sessionColor(id) {
487
624
  if (!sessions[id]) {
488
625
  sessions[id] = {
489
626
  color: palette[colorIdx++ % palette.length],
490
- count: 0, lastEvent: null,
627
+ count: 0, lastEvent: null, lastEventTs: 0,
491
628
  name: '', kind: '', purpose: '', priority: '', persona: '',
492
629
  app: null, label: null,
630
+ terminated: false, terminatedAt: 0,
493
631
  };
494
632
  updateSessionBar();
495
633
  updateFilterSession();
@@ -505,6 +643,7 @@ function updateSessionLabel(sid, ev) {
505
643
  let changed = false;
506
644
 
507
645
  if (!s.app && ev.source_app) { s.app = ev.source_app; changed = true; }
646
+ if (ev._ts && ev._ts > (s.lastEventTs || 0)) s.lastEventTs = ev._ts;
508
647
 
509
648
  // Configured metadata wins over heuristics. Update on every event so a
510
649
  // mid-session /session-init relabel is reflected without a refresh.
@@ -519,8 +658,17 @@ function updateSessionLabel(sid, ev) {
519
658
  }
520
659
  }
521
660
 
522
- // Fall back to first UserPromptSubmit if no configured name yet
661
+ // Lifecycle: a session is Terminated when we receive Stop / SessionEnd
662
+ // (Claude Code's natural exit signal) OR when ev.lifecycle === 'terminated'
663
+ // (the /session-end slash command). Time-idleness alone does NOT terminate.
523
664
  const type = ev.event_type || ev.hook_event_name || '';
665
+ if (!s.terminated && (type === 'Stop' || type === 'SessionEnd' || ev.lifecycle === 'terminated')) {
666
+ s.terminated = true;
667
+ s.terminatedAt = ev._ts || Date.now();
668
+ changed = true;
669
+ }
670
+
671
+ // Fall back to first UserPromptSubmit if no configured name yet
524
672
  if (!s.name && !s.label && type === 'UserPromptSubmit') {
525
673
  const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
526
674
  if (prompt.length > 0) {
@@ -537,6 +685,16 @@ function updateSessionLabel(sid, ev) {
537
685
  }
538
686
  }
539
687
 
688
+ // Active = event in last ACTIVE_WINDOW_MS; Terminated = explicit signal seen;
689
+ // Idle = anything else (including long-running sessions sitting dormant).
690
+ const ACTIVE_WINDOW_MS = 5 * 60 * 1000;
691
+ function sessionStatus(s) {
692
+ if (!s) return 'idle';
693
+ if (s.terminated) return 'terminated';
694
+ if ((Date.now() - (s.lastEventTs || 0)) <= ACTIVE_WINDOW_MS) return 'active';
695
+ return 'idle';
696
+ }
697
+
540
698
  function sessionDisplayName(id) {
541
699
  const s = sessions[id];
542
700
  if (!s) return shortId(id);
@@ -744,6 +902,10 @@ setInterval(() => {
744
902
  if (events.length > 0) updateWaitingText(events[events.length - 1]);
745
903
  }, 5000);
746
904
 
905
+ // Refresh the session bar periodically so Active→Idle transitions show up
906
+ // without requiring a new event to arrive.
907
+ setInterval(updateSessionBar, 30000);
908
+
747
909
  function updateStats() {
748
910
  document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
749
911
  document.getElementById('eventCount').textContent = events.length;
@@ -771,11 +933,18 @@ function pill(cls, label) {
771
933
  return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
772
934
  }
773
935
 
936
+ // Lifecycle sort: active first, then idle, terminated last.
937
+ const LIFECYCLE_ORDER = { active: 0, idle: 1, terminated: 2 };
938
+
774
939
  function updateSessionBar() {
775
940
  sessionBar.innerHTML = '';
776
- // Sort: high priority first, then med/unset, then low; within a tier, most
777
- // recent activity wins so the dashboard surfaces what's happening now.
941
+ // Sort: lifecycle (active idle terminated) trumps priority (high → low),
942
+ // then most-recent activity. So a high-priority terminated session sits
943
+ // below a low-priority active one — what's running now matters more.
778
944
  const ids = Object.keys(sessions).sort((a, b) => {
945
+ const la = LIFECYCLE_ORDER[sessionStatus(sessions[a])];
946
+ const lb = LIFECYCLE_ORDER[sessionStatus(sessions[b])];
947
+ if (la !== lb) return la - lb;
779
948
  const pa = PRIORITY_ORDER[sessions[a].priority] ?? PRIORITY_ORDER[''];
780
949
  const pb = PRIORITY_ORDER[sessions[b].priority] ?? PRIORITY_ORDER[''];
781
950
  if (pa !== pb) return pa - pb;
@@ -784,16 +953,19 @@ function updateSessionBar() {
784
953
 
785
954
  for (const id of ids) {
786
955
  const s = sessions[id];
956
+ const lifecycle = sessionStatus(s);
787
957
  const tag = document.createElement('span');
788
958
  let cls = 'session-tag';
789
959
  if (selectedSession === id) cls += ' active';
790
960
  if (s.priority === 'low') cls += ' priority-low';
961
+ cls += ' lifecycle-' + lifecycle;
791
962
  tag.className = cls;
792
963
  tag.style.background = s.color + '22';
793
964
  tag.style.color = s.color;
794
965
  tag.title = `${id}${s.purpose ? '\n' + s.purpose : ''}`;
795
966
 
796
967
  const pills = [];
968
+ pills.push(pill('lifecycle-' + lifecycle, lifecycle));
797
969
  if (s.persona) pills.push(pill('persona-' + s.persona, s.persona));
798
970
  if (s.priority) pills.push(pill('priority-' + s.priority, 'P:' + s.priority));
799
971
  if (s.kind) pills.push(pill('kind-' + s.kind, s.kind));
@@ -905,6 +1077,135 @@ mql.addEventListener('change', () => {
905
1077
  if (!localStorage.getItem(THEME_KEY)) applyTheme(resolvedTheme());
906
1078
  });
907
1079
 
1080
+ // --- Snapshot + Restore UI ---
1081
+ const btnSnapshot = document.getElementById('btnSnapshot');
1082
+ const btnSnapshotsList = document.getElementById('btnSnapshotsList');
1083
+ const snapshotsModal = document.getElementById('snapshotsModal');
1084
+ const snapshotsModalTitle = document.getElementById('snapshotsModalTitle');
1085
+ const snapshotsModalBody = document.getElementById('snapshotsModalBody');
1086
+ const snapshotsModalClose = document.getElementById('snapshotsModalClose');
1087
+ const snapshotToast = document.getElementById('snapshotToast');
1088
+
1089
+ function showToast(html, ms = 5000) {
1090
+ snapshotToast.innerHTML = html;
1091
+ snapshotToast.classList.add('show');
1092
+ clearTimeout(showToast._t);
1093
+ showToast._t = setTimeout(() => snapshotToast.classList.remove('show'), ms);
1094
+ }
1095
+
1096
+ function openModal() { snapshotsModal.classList.add('open'); }
1097
+ function closeModal() { snapshotsModal.classList.remove('open'); }
1098
+ snapshotsModalClose.onclick = closeModal;
1099
+ snapshotsModal.onclick = (e) => { if (e.target === snapshotsModal) closeModal(); };
1100
+
1101
+ btnSnapshot.onclick = async () => {
1102
+ btnSnapshot.disabled = true;
1103
+ btnSnapshot.textContent = 'Snapshotting…';
1104
+ try {
1105
+ const r = await fetch('/snapshots', { method: 'POST' });
1106
+ if (!r.ok) throw new Error('snapshot request failed: ' + r.status);
1107
+ const manifest = await r.json();
1108
+ showToast(`Snapshot <strong>${escapeHtml(manifest.id)}</strong> captured: ${manifest.sessions.length} active session${manifest.sessions.length === 1 ? '' : 's'}.`);
1109
+ } catch (err) {
1110
+ showToast(`Snapshot failed: ${escapeHtml(err.message)}`);
1111
+ } finally {
1112
+ btnSnapshot.disabled = false;
1113
+ btnSnapshot.textContent = 'Snapshot all';
1114
+ }
1115
+ };
1116
+
1117
+ btnSnapshotsList.onclick = async () => {
1118
+ snapshotsModalTitle.textContent = 'Snapshots';
1119
+ snapshotsModalBody.innerHTML = '<p style="color:var(--text-muted)">Loading…</p>';
1120
+ openModal();
1121
+ try {
1122
+ const r = await fetch('/snapshots');
1123
+ const list = await r.json();
1124
+ if (!list.length) {
1125
+ snapshotsModalBody.innerHTML = '<p style="color:var(--text-muted)">No snapshots yet. Click "Snapshot all" in the header to take one.</p>';
1126
+ return;
1127
+ }
1128
+ const rows = list.map(s => `
1129
+ <div class="snapshot-row" data-id="${escapeHtml(s.id)}">
1130
+ <div class="meta">
1131
+ <span class="id">${escapeHtml(s.id)}</span>
1132
+ <span style="color:var(--text-muted); font-size:12.5px">${s.session_count} session${s.session_count === 1 ? '' : 's'}</span>
1133
+ </div>
1134
+ <div style="color:var(--text-dim); font-size:12px; margin-top:4px">${new Date(s.created_at).toLocaleString()}</div>
1135
+ </div>
1136
+ `).join('');
1137
+ snapshotsModalBody.innerHTML = `<div class="snapshot-list">${rows}</div>`;
1138
+ snapshotsModalBody.querySelectorAll('.snapshot-row').forEach(row => {
1139
+ row.onclick = () => openSnapshotDetail(row.dataset.id);
1140
+ });
1141
+ } catch (err) {
1142
+ snapshotsModalBody.innerHTML = `<p style="color:var(--danger)">Failed to load snapshots: ${escapeHtml(err.message)}</p>`;
1143
+ }
1144
+ };
1145
+
1146
+ async function openSnapshotDetail(id) {
1147
+ snapshotsModalTitle.textContent = 'Snapshot ' + id;
1148
+ snapshotsModalBody.innerHTML = '<p style="color:var(--text-muted)">Loading…</p>';
1149
+ try {
1150
+ const r = await fetch('/snapshots/' + encodeURIComponent(id));
1151
+ if (!r.ok) throw new Error('snapshot not found');
1152
+ const m = await r.json();
1153
+ if (!m.sessions || m.sessions.length === 0) {
1154
+ snapshotsModalBody.innerHTML = '<p style="color:var(--text-muted)">No active sessions were captured in this snapshot.</p>';
1155
+ return;
1156
+ }
1157
+ const restoreAll = `<button class="header-btn primary" id="btnRestoreAll" type="button">Restore all (${m.sessions.length})</button>`;
1158
+ const back = `<button class="header-btn" id="btnBackToSnapshots" type="button">← Back to list</button>`;
1159
+ const sessions = m.sessions.map(s => `
1160
+ <div class="sess-row" data-sid="${escapeHtml(s.uvs_session_id)}">
1161
+ <div class="head">
1162
+ <div>
1163
+ <span class="name">${escapeHtml(s.name || '(unlabeled)')}</span>
1164
+ <span style="color:var(--text-dim); font-size:12px; margin-left:8px">${escapeHtml((s.uvs_session_id || '').slice(0,8))}</span>
1165
+ <span class="pill persona-${escapeHtml(s.persona || '')}" style="margin-left:6px">${escapeHtml(s.persona || '?')}</span>
1166
+ ${s.priority ? `<span class="pill priority-${escapeHtml(s.priority)}">P:${escapeHtml(s.priority)}</span>` : ''}
1167
+ </div>
1168
+ <button class="header-btn restore-btn" type="button">Open in new terminal</button>
1169
+ </div>
1170
+ <div style="color:var(--text-dim); font-size:12px; margin-bottom:6px">${escapeHtml(s.cwd || '')}${s.purpose ? ' — ' + escapeHtml(s.purpose) : ''}</div>
1171
+ <div class="preview">${s.summary_preview ? escapeHtml(s.summary_preview) : '<em style="color:var(--text-dim)">(no checkpoint summary yet)</em>'}</div>
1172
+ </div>
1173
+ `).join('');
1174
+ snapshotsModalBody.innerHTML = `
1175
+ <div style="display:flex; justify-content:space-between; margin-bottom:14px">${back}${restoreAll}</div>
1176
+ <div class="snapshot-detail">${sessions}</div>
1177
+ `;
1178
+
1179
+ document.getElementById('btnBackToSnapshots').onclick = () => btnSnapshotsList.onclick();
1180
+ document.getElementById('btnRestoreAll').onclick = () => {
1181
+ m.sessions.forEach(s => restoreSession(id, s.uvs_session_id));
1182
+ };
1183
+ snapshotsModalBody.querySelectorAll('.sess-row').forEach(row => {
1184
+ row.querySelector('.restore-btn').onclick = () => restoreSession(id, row.dataset.sid);
1185
+ });
1186
+ } catch (err) {
1187
+ snapshotsModalBody.innerHTML = `<p style="color:var(--danger)">Failed to load: ${escapeHtml(err.message)}</p>`;
1188
+ }
1189
+ }
1190
+
1191
+ async function restoreSession(snapshotId, sid) {
1192
+ try {
1193
+ const r = await fetch(`/snapshots/${encodeURIComponent(snapshotId)}/sessions/${encodeURIComponent(sid)}/restore`, { method: 'POST' });
1194
+ const result = await r.json();
1195
+ if (result.ok) {
1196
+ showToast(`Opened new terminal for <strong>${escapeHtml(sid.slice(0,8))}</strong>.`);
1197
+ } else {
1198
+ // Fallback: show the copy-paste command
1199
+ showToast(
1200
+ `Couldn't auto-open a terminal on this platform (<code>${escapeHtml(result.platform)}</code>). Copy and run:<pre>${escapeHtml(result.command || '')}</pre>`,
1201
+ 12000,
1202
+ );
1203
+ }
1204
+ } catch (err) {
1205
+ showToast(`Restore failed: ${escapeHtml(err.message)}`);
1206
+ }
1207
+ }
1208
+
908
1209
  connect();
909
1210
  </script>
910
1211
  </body>
@@ -1 +1,74 @@
1
- []
1
+ [
2
+ {
3
+ "event_type": "PostToolUse",
4
+ "uvs_session_id": "snap-test-A",
5
+ "session_id": "cc-A",
6
+ "cwd": "/tmp/uvs-tierb-test",
7
+ "session_name": "snapshot test A",
8
+ "session_kind": "outcome",
9
+ "session_priority": "high",
10
+ "persona": "auto",
11
+ "tool_name": "Edit",
12
+ "_ts": 1778537691312,
13
+ "_id": "95a2a933-1f9e-4e0b-95fd-9e43fa8050a1"
14
+ },
15
+ {
16
+ "event_type": "PostToolUse",
17
+ "uvs_session_id": "snap-test-B",
18
+ "session_id": "cc-B",
19
+ "cwd": "/tmp/uvs-tierb-test",
20
+ "session_name": "snapshot test B",
21
+ "session_kind": "long-running",
22
+ "persona": "sport",
23
+ "tool_name": "Read",
24
+ "_ts": 1778537691332,
25
+ "_id": "0424ce0d-f8b5-4f7e-8ca2-60b23d8ebc11"
26
+ },
27
+ {
28
+ "event_type": "AutoCheckpoint",
29
+ "source_app": "uvs-tierb-test",
30
+ "cwd": "/tmp/uvs-tierb-test",
31
+ "uvs_session_id": "snap-test-A",
32
+ "session_id": "snap-test-A",
33
+ "session_name": "snapshot test A",
34
+ "session_kind": "outcome",
35
+ "session_priority": "high",
36
+ "persona": "auto",
37
+ "checkpoint_kind": "auto-semantic",
38
+ "checkpoint_path": "/tmp/uvs-tierb-test/uv-out/checkpoints/snap-test-A/auto-2026-05-11-2214-semantic.md",
39
+ "checkpoint_summary": "_(no conversation transcript available; only mechanical activity below)_",
40
+ "checkpoint_preview": "---\nuvs_session_id: snap-test-A\nsession_name: snapshot test A\nsession_kind: outcome\nsession_purpose: \nsession_priority: high\npersona: auto\ncheckpoint_at: 2026-05-11T22:14:51.348Z\ncheckpoint_kind: auto-semantic\ntranscript_messages: 0\ntool_calls_in_window: 1\n---\n# Auto-checkpoint (semantic): 2026-05-11T22:14:51.348Z\n\n## Summary\n\n_(no conversation transcript available; only mechanical activity below)_\n\n## Conversation\n\n_(no transcript content found; only mechanical activity captured below)_\n\n## Mechanical\n\n### Tool calls\n- 1× Edit\n\n## Git\n\n**Branch:** main\n\n**Status:**\n```\n?? .uv-suite-state/\n?? fakebin/\n?? parser-test.js\n?? tierB-test.js\n?? uv-out/\n```\n\n**Recent commits:**\n```\n09105d2 initial\n```\n",
41
+ "interval_minutes": 1,
42
+ "tool_calls_in_window": 1,
43
+ "transcript_messages": 0,
44
+ "_ts": 1778537691348,
45
+ "_id": "a5b91efb-8286-45eb-a98a-f6e51da816f4"
46
+ },
47
+ {
48
+ "event_type": "AutoCheckpoint",
49
+ "source_app": "uvs-tierb-test",
50
+ "cwd": "/tmp/uvs-tierb-test",
51
+ "uvs_session_id": "snap-test-B",
52
+ "session_id": "snap-test-B",
53
+ "session_name": "snapshot test B",
54
+ "session_kind": "long-running",
55
+ "session_priority": "",
56
+ "persona": "sport",
57
+ "checkpoint_kind": "auto-semantic",
58
+ "checkpoint_path": "/tmp/uvs-tierb-test/uv-out/checkpoints/snap-test-B/auto-2026-05-11-2214-semantic.md",
59
+ "checkpoint_summary": "_(no conversation transcript available; only mechanical activity below)_",
60
+ "checkpoint_preview": "---\nuvs_session_id: snap-test-B\nsession_name: snapshot test B\nsession_kind: long-running\nsession_purpose: \nsession_priority: \npersona: sport\ncheckpoint_at: 2026-05-11T22:14:51.451Z\ncheckpoint_kind: auto-semantic\ntranscript_messages: 0\ntool_calls_in_window: 1\n---\n# Auto-checkpoint (semantic): 2026-05-11T22:14:51.451Z\n\n## Summary\n\n_(no conversation transcript available; only mechanical activity below)_\n\n## Conversation\n\n_(no transcript content found; only mechanical activity captured below)_\n\n## Mechanical\n\n### Tool calls\n- 1× Read\n\n## Git\n\n**Branch:** main\n\n**Status:**\n```\n?? .uv-suite-state/\n?? fakebin/\n?? parser-test.js\n?? tierB-test.js\n?? uv-out/\n```\n\n**Recent commits:**\n```\n09105d2 initial\n```\n",
61
+ "interval_minutes": 1,
62
+ "tool_calls_in_window": 1,
63
+ "transcript_messages": 0,
64
+ "_ts": 1778537691451,
65
+ "_id": "6f09091b-1bf1-4bc8-bb02-2d6ab7badbb1"
66
+ },
67
+ {
68
+ "event_type": "SnapshotTaken",
69
+ "snapshot_id": "2026-05-11-22-1451",
70
+ "session_count": 2,
71
+ "_ts": 1778537691524,
72
+ "_id": "e2a54e27-92de-4f18-aa2c-6044d1adde7e"
73
+ }
74
+ ]
@@ -9,6 +9,7 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const crypto = require("crypto");
11
11
  const autoCheckpointRunner = require("./auto-checkpoint-runner");
12
+ const snapshotManager = require("./snapshot-manager");
12
13
 
13
14
  const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
14
15
  const DATA_FILE = path.join(__dirname, "events.json");
@@ -120,6 +121,68 @@ const server = http.createServer((req, res) => {
120
121
  return;
121
122
  }
122
123
 
124
+ // POST /snapshots — take a snapshot of every active session
125
+ if (req.method === "POST" && req.url === "/snapshots") {
126
+ (async () => {
127
+ try {
128
+ const manifest = await snapshotManager.takeSnapshot({
129
+ runner: autoCheckpointRunner,
130
+ getEvents: () => events,
131
+ broadcast: (ev) => {
132
+ ev._ts = ev._ts || Date.now();
133
+ ev._id = crypto.randomUUID();
134
+ events.push(ev);
135
+ broadcast(ev);
136
+ saveEvents();
137
+ },
138
+ });
139
+ res.writeHead(200, { "Content-Type": "application/json" });
140
+ res.end(JSON.stringify(manifest));
141
+ } catch (err) {
142
+ res.writeHead(500, { "Content-Type": "application/json" });
143
+ res.end(JSON.stringify({ error: err.message }));
144
+ }
145
+ })();
146
+ return;
147
+ }
148
+
149
+ // GET /snapshots — list bundles
150
+ if (req.method === "GET" && req.url === "/snapshots") {
151
+ res.writeHead(200, { "Content-Type": "application/json" });
152
+ res.end(JSON.stringify(snapshotManager.listSnapshots()));
153
+ return;
154
+ }
155
+
156
+ // GET /snapshots/<id> — get manifest
157
+ const snapshotMatch = req.url.match(/^\/snapshots\/([^/]+)$/);
158
+ if (req.method === "GET" && snapshotMatch) {
159
+ const m = snapshotManager.getSnapshot(snapshotMatch[1]);
160
+ if (!m) {
161
+ res.writeHead(404);
162
+ return res.end("not found");
163
+ }
164
+ res.writeHead(200, { "Content-Type": "application/json" });
165
+ return res.end(JSON.stringify(m));
166
+ }
167
+
168
+ // POST /snapshots/<id>/sessions/<sid>/restore — open a new terminal tab
169
+ // that restores the given session.
170
+ const restoreMatch = req.url.match(
171
+ /^\/snapshots\/([^/]+)\/sessions\/([^/]+)\/restore$/,
172
+ );
173
+ if (req.method === "POST" && restoreMatch) {
174
+ const [, snapId, sid] = restoreMatch;
175
+ const m = snapshotManager.getSnapshot(snapId);
176
+ const session = m?.sessions?.find((s) => s.uvs_session_id === sid);
177
+ if (!session) {
178
+ res.writeHead(404);
179
+ return res.end(JSON.stringify({ error: "session not in snapshot" }));
180
+ }
181
+ const result = snapshotManager.openTerminalForSession(session);
182
+ res.writeHead(200, { "Content-Type": "application/json" });
183
+ return res.end(JSON.stringify(result));
184
+ }
185
+
123
186
  // GET / — serve dashboard
124
187
  if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
125
188
  const html = fs.readFileSync(