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.
- package/hooks/auto-restore-on-start.sh +30 -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/watchtower/dashboard.html +261 -0
- package/watchtower/events.json +74 -1
- package/watchtower/server.js +63 -0
- package/watchtower/snapshot-manager.js +305 -0
|
@@ -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.
|
|
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",
|
package/personas/auto.json
CHANGED
|
@@ -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
|
}
|
package/personas/spike.json
CHANGED
|
@@ -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
|
}
|
package/personas/sport.json
CHANGED
|
@@ -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>
|
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(
|
|
@@ -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
|
+
};
|