uv-suite 0.28.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.
@@ -0,0 +1,30 @@
1
+ #!/bin/bash
2
+ # UV Suite Hook: SessionStart — when UVS_RESTORE_FROM is set, inject a
3
+ # system-context instruction telling Claude to /restore that session as
4
+ # the first action of the new session.
5
+ #
6
+ # This is how the Watchtower's "Open in new terminal" restore button hooks
7
+ # back into the new uvs session — the env var is set by the spawned terminal
8
+ # command, propagates through uvs/claude, and lands here.
9
+
10
+ if [ -z "$UVS_RESTORE_FROM" ]; then
11
+ exit 0
12
+ fi
13
+
14
+ # Sanity check — sid must look like a UUID prefix or our ad-hoc-<ts>
15
+ case "$UVS_RESTORE_FROM" in
16
+ [a-zA-Z0-9-]*) : ;;
17
+ *)
18
+ echo "[auto-restore] ignoring malformed UVS_RESTORE_FROM" >&2
19
+ exit 0
20
+ ;;
21
+ esac
22
+
23
+ MSG="[uv-suite auto-restore] This session was opened via the Watchtower restore flow. Your FIRST action must be to run \`/restore $UVS_RESTORE_FROM\` to load the prior session's latest checkpoint. After /restore completes, summarize what you picked up in 1-2 sentences, then wait for the user's next instruction."
24
+
25
+ if command -v jq >/dev/null 2>&1; then
26
+ jq -nc --arg ctx "$MSG" '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$ctx}}'
27
+ else
28
+ ESCAPED=$(printf '%s' "$MSG" | sed 's/\\/\\\\/g; s/"/\\"/g')
29
+ printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s"}}' "$ESCAPED"
30
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
5
5
  "author": "Utsav Anand",
6
6
  "license": "MIT",
@@ -70,6 +70,11 @@
70
70
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
71
71
  "timeout": 2,
72
72
  "async": true
73
+ },
74
+ {
75
+ "type": "command",
76
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
77
+ "timeout": 3
73
78
  }
74
79
  ]
75
80
  }
@@ -70,6 +70,11 @@
70
70
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
71
71
  "timeout": 2,
72
72
  "async": true
73
+ },
74
+ {
75
+ "type": "command",
76
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
77
+ "timeout": 3
73
78
  }
74
79
  ]
75
80
  }
@@ -63,6 +63,11 @@
63
63
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
64
64
  "timeout": 2,
65
65
  "async": true
66
+ },
67
+ {
68
+ "type": "command",
69
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
70
+ "timeout": 3
66
71
  }
67
72
  ]
68
73
  }
@@ -52,6 +52,11 @@
52
52
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
53
53
  "timeout": 2,
54
54
  "async": true
55
+ },
56
+ {
57
+ "type": "command",
58
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-restore-on-start.sh",
59
+ "timeout": 3
55
60
  }
56
61
  ]
57
62
  }
@@ -409,6 +409,124 @@
409
409
  font-size: 12px;
410
410
  margin-left: 8px;
411
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
+ }
412
530
  </style>
413
531
  </head>
414
532
  <body>
@@ -417,6 +535,8 @@
417
535
  <h1>UV Suite Watchtower</h1>
418
536
  <div class="meta">
419
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>
420
540
  <button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme" title="Toggle theme">
421
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">
422
542
  <circle cx="12" cy="12" r="4"/>
@@ -459,6 +579,18 @@
459
579
  </div>
460
580
  </div>
461
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
+
462
594
 
463
595
  <script>
464
596
  const timeline = document.getElementById('timeline');
@@ -945,6 +1077,135 @@ mql.addEventListener('change', () => {
945
1077
  if (!localStorage.getItem(THEME_KEY)) applyTheme(resolvedTheme());
946
1078
  });
947
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
+
948
1209
  connect();
949
1210
  </script>
950
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(
@@ -0,0 +1,305 @@
1
+ // UV Suite Watchtower — snapshot + restore.
2
+ //
3
+ // A "snapshot" bundles the manifest of every active session at a moment in
4
+ // time (sid, cwd, persona, latest checkpoint path) plus a copy of the
5
+ // watchtower's recent event store. It also triggers an immediate Tier B
6
+ // auto-checkpoint for each session so the latest summary is captured.
7
+ //
8
+ // "Restore" doesn't restart Claude Code (we can't — processes are dead).
9
+ // On macOS / Linux it spawns a new terminal tab per session via the OS's
10
+ // AppleScript or terminal-emulator command, passing UVS_RESTORE_FROM=<old-sid>.
11
+ // The next `uvs` launch picks that up and auto-runs `/restore <old-sid>` on
12
+ // turn 1 via the SessionStart hook.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+ const { spawn } = require("child_process");
18
+
19
+ const SNAPSHOTS_DIR = path.join(os.homedir(), ".uv-suite", "snapshots");
20
+ const ACTIVE_WINDOW_MS = 60 * 60 * 1000; // sessions with activity in last 1h
21
+
22
+ function ensureSnapshotsDir() {
23
+ fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
24
+ }
25
+
26
+ // Group recent events by uvs_session_id, take the most recent metadata for
27
+ // each. Returns one record per active session.
28
+ function activeSessionsFromEvents(events) {
29
+ const cutoff = Date.now() - ACTIVE_WINDOW_MS;
30
+ const bySession = new Map();
31
+ for (const ev of events) {
32
+ const sid = ev.uvs_session_id || ev.session_id;
33
+ if (!sid) continue;
34
+ if ((ev._ts || 0) < cutoff) continue;
35
+ // Skip if session was explicitly terminated
36
+ if (ev.lifecycle === "terminated") {
37
+ bySession.delete(sid);
38
+ continue;
39
+ }
40
+ const existing = bySession.get(sid) || {
41
+ uvs_session_id: sid,
42
+ session_id: ev.session_id || sid,
43
+ cwd: ev.cwd,
44
+ name: ev.session_name || "",
45
+ kind: ev.session_kind || "",
46
+ purpose: ev.session_purpose || "",
47
+ priority: ev.session_priority || "",
48
+ persona: ev.persona || "",
49
+ first_event_ts: ev._ts,
50
+ last_event_ts: ev._ts,
51
+ event_count: 0,
52
+ };
53
+ existing.last_event_ts = Math.max(existing.last_event_ts || 0, ev._ts || 0);
54
+ existing.first_event_ts = Math.min(
55
+ existing.first_event_ts || Infinity,
56
+ ev._ts || Infinity,
57
+ );
58
+ existing.event_count++;
59
+ // Refresh metadata fields from latest event (in case /session-init relabeled)
60
+ if (ev.session_name) existing.name = ev.session_name;
61
+ if (ev.session_kind) existing.kind = ev.session_kind;
62
+ if (ev.session_purpose) existing.purpose = ev.session_purpose;
63
+ if (ev.session_priority) existing.priority = ev.session_priority;
64
+ if (ev.persona) existing.persona = ev.persona;
65
+ if (ev.session_id) existing.session_id = ev.session_id;
66
+ bySession.set(sid, existing);
67
+ }
68
+ return [...bySession.values()];
69
+ }
70
+
71
+ // Find each session's latest checkpoint file (auto-* or final-*)
72
+ function findLatestCheckpoint(cwd, sid) {
73
+ const dir = path.join(cwd, "uv-out", "checkpoints", sid);
74
+ if (!fs.existsSync(dir)) return null;
75
+ try {
76
+ const files = fs
77
+ .readdirSync(dir)
78
+ .filter((f) => f.endsWith(".md") && f !== "latest.md");
79
+ if (files.length === 0) return null;
80
+ // Sort by mtime, newest first
81
+ files.sort((a, b) => {
82
+ const ma = fs.statSync(path.join(dir, a)).mtimeMs;
83
+ const mb = fs.statSync(path.join(dir, b)).mtimeMs;
84
+ return mb - ma;
85
+ });
86
+ return path.join(dir, files[0]);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Read first few lines of summary section from a checkpoint file for the
93
+ // snapshot's per-session preview.
94
+ function readCheckpointSummary(cpPath) {
95
+ if (!cpPath || !fs.existsSync(cpPath)) return "";
96
+ try {
97
+ const content = fs.readFileSync(cpPath, "utf-8");
98
+ // Strip frontmatter
99
+ const body = content.replace(/^---[\s\S]*?---\s*/m, "");
100
+ // Look for ## Summary section
101
+ const m = body.match(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##\s|\n#\s|$)/);
102
+ if (m) return m[1].trim().slice(0, 600);
103
+ // Fallback: first 400 chars of body
104
+ return body.trim().slice(0, 400);
105
+ } catch {
106
+ return "";
107
+ }
108
+ }
109
+
110
+ async function takeSnapshot({ runner, getEvents, broadcast }) {
111
+ ensureSnapshotsDir();
112
+
113
+ // Trigger an immediate Tier B tick so the snapshot bundles the freshest
114
+ // possible summaries. We don't block on broadcast; just await the tick.
115
+ if (runner && runner.tick) {
116
+ await runner.tick({ getEvents, broadcast });
117
+ }
118
+
119
+ const events = getEvents();
120
+ const sessions = activeSessionsFromEvents(events);
121
+
122
+ const ts = new Date();
123
+ const id = ts
124
+ .toISOString()
125
+ .replace(/[T:]/g, "-")
126
+ .replace(/\.\d+Z$/, "")
127
+ .replace(/-(\d\d)$/, "$1");
128
+ const dir = path.join(SNAPSHOTS_DIR, id);
129
+ fs.mkdirSync(dir, { recursive: true });
130
+
131
+ for (const s of sessions) {
132
+ s.latest_checkpoint = findLatestCheckpoint(s.cwd, s.uvs_session_id);
133
+ s.summary_preview = readCheckpointSummary(s.latest_checkpoint);
134
+ }
135
+
136
+ const manifest = {
137
+ id,
138
+ created_at: ts.toISOString(),
139
+ created_at_epoch: Math.floor(ts.getTime() / 1000),
140
+ sessions,
141
+ event_count: events.length,
142
+ };
143
+
144
+ fs.writeFileSync(
145
+ path.join(dir, "manifest.json"),
146
+ JSON.stringify(manifest, null, 2),
147
+ );
148
+ fs.writeFileSync(
149
+ path.join(dir, "events.json"),
150
+ JSON.stringify(events, null, 2),
151
+ );
152
+
153
+ // Broadcast a SnapshotTaken event so it shows up in the timeline
154
+ const event = {
155
+ event_type: "SnapshotTaken",
156
+ snapshot_id: id,
157
+ session_count: sessions.length,
158
+ _ts: ts.getTime(),
159
+ };
160
+ broadcast(event);
161
+
162
+ return manifest;
163
+ }
164
+
165
+ function listSnapshots() {
166
+ ensureSnapshotsDir();
167
+ const entries = [];
168
+ for (const name of fs.readdirSync(SNAPSHOTS_DIR).sort().reverse()) {
169
+ const manifestPath = path.join(SNAPSHOTS_DIR, name, "manifest.json");
170
+ try {
171
+ const m = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
172
+ entries.push({
173
+ id: m.id,
174
+ created_at: m.created_at,
175
+ session_count: m.sessions?.length || 0,
176
+ });
177
+ } catch {
178
+ // skip unreadable bundles
179
+ }
180
+ }
181
+ return entries;
182
+ }
183
+
184
+ function getSnapshot(id) {
185
+ const manifestPath = path.join(SNAPSHOTS_DIR, id, "manifest.json");
186
+ if (!fs.existsSync(manifestPath)) return null;
187
+ try {
188
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // Spawn a new terminal tab restoring the given session.
195
+ // macOS: iTerm2 if TERM_PROGRAM=iTerm.app (or UVS_TERMINAL_APP=iterm),
196
+ // otherwise Terminal.app. Override either way with UVS_TERMINAL_APP.
197
+ // Linux: prefers gnome-terminal then x-terminal-emulator
198
+ // Other: returns { ok: false, command: <copy-paste string> }
199
+ function buildRestoreCommand(session) {
200
+ const persona = session.persona || "professional";
201
+ const sid = session.uvs_session_id;
202
+ const cwd = session.cwd;
203
+ // The new uvs launch reads UVS_RESTORE_FROM and the SessionStart hook
204
+ // injects "run /restore <sid>" on turn 1.
205
+ return `cd ${shellEscape(cwd)} && UVS_RESTORE_FROM=${shellEscape(sid)} uvs claude ${shellEscape(persona)}`;
206
+ }
207
+
208
+ function shellEscape(s) {
209
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
210
+ }
211
+
212
+ function appleScriptEscape(s) {
213
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
214
+ }
215
+
216
+ // Pick the macOS terminal app. UVS_TERMINAL_APP wins (values: iterm, terminal),
217
+ // else look at $TERM_PROGRAM, else default to Terminal.app.
218
+ function macTerminalApp() {
219
+ const explicit = (process.env.UVS_TERMINAL_APP || "").toLowerCase();
220
+ if (explicit === "iterm" || explicit === "iterm2") return "iterm";
221
+ if (explicit === "terminal") return "terminal";
222
+ const tp = process.env.TERM_PROGRAM || "";
223
+ if (tp === "iTerm.app") return "iterm";
224
+ return "terminal";
225
+ }
226
+
227
+ // iTerm2 AppleScript: opens a new tab in the front window if one exists,
228
+ // otherwise a new window. Both code paths end with `write text "..."`.
229
+ function iTermScript(command) {
230
+ const esc = appleScriptEscape(command);
231
+ return `
232
+ tell application "iTerm"
233
+ activate
234
+ if (count of windows) = 0 then
235
+ create window with default profile
236
+ else
237
+ tell current window to create tab with default profile
238
+ end if
239
+ tell current session of current window to write text "${esc}"
240
+ end tell
241
+ `;
242
+ }
243
+
244
+ function terminalAppScript(command) {
245
+ return `tell app "Terminal" to do script "${appleScriptEscape(command)}"`;
246
+ }
247
+
248
+ function openTerminalForSession(session) {
249
+ const command = buildRestoreCommand(session);
250
+ const platform = process.platform;
251
+
252
+ if (platform === "darwin") {
253
+ const app = macTerminalApp();
254
+ const script =
255
+ app === "iterm" ? iTermScript(command) : terminalAppScript(command);
256
+ const child = spawn("osascript", ["-e", script], {
257
+ stdio: "ignore",
258
+ detached: true,
259
+ });
260
+ child.unref();
261
+ return { ok: true, platform, terminal_app: app, command };
262
+ }
263
+
264
+ if (platform === "linux") {
265
+ // Try gnome-terminal first, fall back to x-terminal-emulator
266
+ const tryEmulator = (cmd, args) => {
267
+ try {
268
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
269
+ child.unref();
270
+ return true;
271
+ } catch {
272
+ return false;
273
+ }
274
+ };
275
+ if (
276
+ tryEmulator("gnome-terminal", [
277
+ "--",
278
+ "bash",
279
+ "-c",
280
+ command + "; exec bash",
281
+ ]) ||
282
+ tryEmulator("x-terminal-emulator", [
283
+ "-e",
284
+ "bash",
285
+ "-c",
286
+ command + "; exec bash",
287
+ ])
288
+ ) {
289
+ return { ok: true, platform, command };
290
+ }
291
+ }
292
+
293
+ // Windows or unsupported — return the command so the dashboard can offer
294
+ // a "copy to clipboard" fallback.
295
+ return { ok: false, platform, command };
296
+ }
297
+
298
+ module.exports = {
299
+ takeSnapshot,
300
+ listSnapshots,
301
+ getSnapshot,
302
+ openTerminalForSession,
303
+ buildRestoreCommand,
304
+ SNAPSHOTS_DIR,
305
+ };