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.
- package/hooks/auto-restore-on-start.sh +30 -0
- package/hooks/session-end-helper.sh +56 -0
- package/package.json +1 -1
- package/personas/auto.json +5 -0
- package/personas/professional.json +5 -0
- package/personas/spike.json +5 -0
- package/personas/sport.json +5 -0
- package/skills/session-end/SKILL.md +100 -0
- package/watchtower/auto-checkpoint-prompt.md +23 -32
- package/watchtower/auto-checkpoint-runner.js +258 -38
- package/watchtower/dashboard.html +305 -4
- package/watchtower/events.json +74 -1
- package/watchtower/server.js +63 -0
- package/watchtower/snapshot-manager.js +305 -0
|
@@ -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
|
-
//
|
|
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:
|
|
777
|
-
// recent activity
|
|
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>
|
package/watchtower/events.json
CHANGED
|
@@ -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
|
+
]
|
package/watchtower/server.js
CHANGED
|
@@ -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(
|