uv-suite 0.26.5 → 0.28.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-checkpoint-helper.sh +73 -0
- package/hooks/auto-checkpoint.sh +184 -0
- package/hooks/confirm-helper.sh +45 -0
- package/hooks/session-end-helper.sh +56 -0
- package/package.json +1 -1
- package/personas/auto.json +16 -2
- package/personas/professional.json +18 -2
- package/personas/spike.json +28 -7
- package/personas/sport.json +25 -1
- package/skills/auto-checkpoint/SKILL.md +47 -0
- package/skills/confirm/SKILL.md +4 -7
- package/skills/session-end/SKILL.md +100 -0
- package/watchtower/auto-checkpoint-prompt.md +42 -0
- package/watchtower/auto-checkpoint-runner.js +505 -0
- package/watchtower/dashboard.html +120 -12
- package/watchtower/server.js +21 -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;
|
|
@@ -357,6 +362,53 @@
|
|
|
357
362
|
.type-Notification { color: var(--warning); }
|
|
358
363
|
.type-PermissionRequest { color: var(--danger-soft); }
|
|
359
364
|
.type-PreCompact { color: var(--text-muted); }
|
|
365
|
+
.type-AutoCheckpoint { color: var(--success); font-weight: 700; }
|
|
366
|
+
|
|
367
|
+
/* Auto-checkpoint rows are full-width and expandable */
|
|
368
|
+
.event.checkpoint {
|
|
369
|
+
background: rgba(48, 209, 88, 0.06);
|
|
370
|
+
border-left: 3px solid var(--success);
|
|
371
|
+
padding-left: 25px;
|
|
372
|
+
opacity: 1;
|
|
373
|
+
}
|
|
374
|
+
.event.checkpoint .detail { cursor: pointer; }
|
|
375
|
+
.event.checkpoint .checkpoint-summary {
|
|
376
|
+
color: var(--text);
|
|
377
|
+
font-weight: 500;
|
|
378
|
+
}
|
|
379
|
+
.event.checkpoint .checkpoint-kind {
|
|
380
|
+
display: inline-block;
|
|
381
|
+
margin-left: 8px;
|
|
382
|
+
padding: 1px 6px;
|
|
383
|
+
font-size: 10.5px;
|
|
384
|
+
font-weight: 600;
|
|
385
|
+
letter-spacing: 0.04em;
|
|
386
|
+
text-transform: uppercase;
|
|
387
|
+
border-radius: 4px;
|
|
388
|
+
background: var(--success-soft);
|
|
389
|
+
color: var(--success);
|
|
390
|
+
vertical-align: 1px;
|
|
391
|
+
}
|
|
392
|
+
.event.checkpoint .checkpoint-body {
|
|
393
|
+
display: none;
|
|
394
|
+
margin-top: 10px;
|
|
395
|
+
padding: 12px 14px;
|
|
396
|
+
background: var(--surface);
|
|
397
|
+
border-radius: 6px;
|
|
398
|
+
color: var(--text-muted);
|
|
399
|
+
font-family: var(--font-mono);
|
|
400
|
+
font-size: 12.5px;
|
|
401
|
+
white-space: pre-wrap;
|
|
402
|
+
line-height: 1.55;
|
|
403
|
+
max-height: 360px;
|
|
404
|
+
overflow-y: auto;
|
|
405
|
+
}
|
|
406
|
+
.event.checkpoint.expanded .checkpoint-body { display: block; }
|
|
407
|
+
.event.checkpoint .checkpoint-toggle {
|
|
408
|
+
color: var(--text-dim);
|
|
409
|
+
font-size: 12px;
|
|
410
|
+
margin-left: 8px;
|
|
411
|
+
}
|
|
360
412
|
</style>
|
|
361
413
|
</head>
|
|
362
414
|
<body>
|
|
@@ -440,9 +492,10 @@ function sessionColor(id) {
|
|
|
440
492
|
if (!sessions[id]) {
|
|
441
493
|
sessions[id] = {
|
|
442
494
|
color: palette[colorIdx++ % palette.length],
|
|
443
|
-
count: 0, lastEvent: null,
|
|
495
|
+
count: 0, lastEvent: null, lastEventTs: 0,
|
|
444
496
|
name: '', kind: '', purpose: '', priority: '', persona: '',
|
|
445
497
|
app: null, label: null,
|
|
498
|
+
terminated: false, terminatedAt: 0,
|
|
446
499
|
};
|
|
447
500
|
updateSessionBar();
|
|
448
501
|
updateFilterSession();
|
|
@@ -458,6 +511,7 @@ function updateSessionLabel(sid, ev) {
|
|
|
458
511
|
let changed = false;
|
|
459
512
|
|
|
460
513
|
if (!s.app && ev.source_app) { s.app = ev.source_app; changed = true; }
|
|
514
|
+
if (ev._ts && ev._ts > (s.lastEventTs || 0)) s.lastEventTs = ev._ts;
|
|
461
515
|
|
|
462
516
|
// Configured metadata wins over heuristics. Update on every event so a
|
|
463
517
|
// mid-session /session-init relabel is reflected without a refresh.
|
|
@@ -472,8 +526,17 @@ function updateSessionLabel(sid, ev) {
|
|
|
472
526
|
}
|
|
473
527
|
}
|
|
474
528
|
|
|
475
|
-
//
|
|
529
|
+
// Lifecycle: a session is Terminated when we receive Stop / SessionEnd
|
|
530
|
+
// (Claude Code's natural exit signal) OR when ev.lifecycle === 'terminated'
|
|
531
|
+
// (the /session-end slash command). Time-idleness alone does NOT terminate.
|
|
476
532
|
const type = ev.event_type || ev.hook_event_name || '';
|
|
533
|
+
if (!s.terminated && (type === 'Stop' || type === 'SessionEnd' || ev.lifecycle === 'terminated')) {
|
|
534
|
+
s.terminated = true;
|
|
535
|
+
s.terminatedAt = ev._ts || Date.now();
|
|
536
|
+
changed = true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Fall back to first UserPromptSubmit if no configured name yet
|
|
477
540
|
if (!s.name && !s.label && type === 'UserPromptSubmit') {
|
|
478
541
|
const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
|
|
479
542
|
if (prompt.length > 0) {
|
|
@@ -490,6 +553,16 @@ function updateSessionLabel(sid, ev) {
|
|
|
490
553
|
}
|
|
491
554
|
}
|
|
492
555
|
|
|
556
|
+
// Active = event in last ACTIVE_WINDOW_MS; Terminated = explicit signal seen;
|
|
557
|
+
// Idle = anything else (including long-running sessions sitting dormant).
|
|
558
|
+
const ACTIVE_WINDOW_MS = 5 * 60 * 1000;
|
|
559
|
+
function sessionStatus(s) {
|
|
560
|
+
if (!s) return 'idle';
|
|
561
|
+
if (s.terminated) return 'terminated';
|
|
562
|
+
if ((Date.now() - (s.lastEventTs || 0)) <= ACTIVE_WINDOW_MS) return 'active';
|
|
563
|
+
return 'idle';
|
|
564
|
+
}
|
|
565
|
+
|
|
493
566
|
function sessionDisplayName(id) {
|
|
494
567
|
const s = sessions[id];
|
|
495
568
|
if (!s) return shortId(id);
|
|
@@ -603,11 +676,13 @@ function renderEvent(ev) {
|
|
|
603
676
|
const fail = isFailure(ev);
|
|
604
677
|
const boundary = isSessionBoundary(ev);
|
|
605
678
|
const prompt = isUserPrompt(ev);
|
|
679
|
+
const checkpoint = type === 'AutoCheckpoint';
|
|
606
680
|
const priority = sessions[sid]?.priority || '';
|
|
607
681
|
|
|
608
682
|
const div = document.createElement('div');
|
|
609
683
|
let cls = 'event';
|
|
610
|
-
if (
|
|
684
|
+
if (checkpoint) cls += ' checkpoint';
|
|
685
|
+
else if (human) cls += ' needs-human';
|
|
611
686
|
else if (fail) cls += ' failure';
|
|
612
687
|
else if (prompt) cls += ' user-prompt';
|
|
613
688
|
else if (boundary) cls += ' session-boundary';
|
|
@@ -617,13 +692,32 @@ function renderEvent(ev) {
|
|
|
617
692
|
|
|
618
693
|
const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
|
|
619
694
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
695
|
+
if (checkpoint) {
|
|
696
|
+
const kind = ev.checkpoint_kind || 'auto';
|
|
697
|
+
const kindLabel = kind === 'auto-mechanical' ? 'mechanical' : kind === 'auto-semantic' ? 'semantic' : kind;
|
|
698
|
+
const calls = ev.tool_calls_in_window || 0;
|
|
699
|
+
const interval = ev.interval_minutes || '';
|
|
700
|
+
const summary = `<span class="checkpoint-summary">${escapeHtml(ev.checkpoint_path?.split('/').slice(-1)[0] || 'checkpoint')}</span>` +
|
|
701
|
+
`<span class="checkpoint-kind">${kindLabel}</span>` +
|
|
702
|
+
`<span class="checkpoint-toggle">${calls} tool calls in last ${interval}m · click to expand</span>`;
|
|
703
|
+
const body = ev.checkpoint_preview ? `<div class="checkpoint-body">${escapeHtml(ev.checkpoint_preview)}</div>` : '';
|
|
704
|
+
div.innerHTML = `
|
|
705
|
+
<span class="time">${formatTime(ev._ts)}</span>
|
|
706
|
+
<span class="type type-${type}">${type}</span>
|
|
707
|
+
<span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
|
|
708
|
+
<span class="tool"></span>
|
|
709
|
+
<span class="detail">${summary}${body}</span>
|
|
710
|
+
`;
|
|
711
|
+
div.querySelector('.detail').addEventListener('click', () => div.classList.toggle('expanded'));
|
|
712
|
+
} else {
|
|
713
|
+
div.innerHTML = `
|
|
714
|
+
<span class="time">${formatTime(ev._ts)}</span>
|
|
715
|
+
<span class="type type-${type}">${type}${humanBadge}</span>
|
|
716
|
+
<span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
|
|
717
|
+
<span class="tool">${tool}</span>
|
|
718
|
+
<span class="detail">${eventDetail(ev)}</span>
|
|
719
|
+
`;
|
|
720
|
+
}
|
|
627
721
|
|
|
628
722
|
div._ev = ev;
|
|
629
723
|
|
|
@@ -676,6 +770,10 @@ setInterval(() => {
|
|
|
676
770
|
if (events.length > 0) updateWaitingText(events[events.length - 1]);
|
|
677
771
|
}, 5000);
|
|
678
772
|
|
|
773
|
+
// Refresh the session bar periodically so Active→Idle transitions show up
|
|
774
|
+
// without requiring a new event to arrive.
|
|
775
|
+
setInterval(updateSessionBar, 30000);
|
|
776
|
+
|
|
679
777
|
function updateStats() {
|
|
680
778
|
document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
|
|
681
779
|
document.getElementById('eventCount').textContent = events.length;
|
|
@@ -703,11 +801,18 @@ function pill(cls, label) {
|
|
|
703
801
|
return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
|
|
704
802
|
}
|
|
705
803
|
|
|
804
|
+
// Lifecycle sort: active first, then idle, terminated last.
|
|
805
|
+
const LIFECYCLE_ORDER = { active: 0, idle: 1, terminated: 2 };
|
|
806
|
+
|
|
706
807
|
function updateSessionBar() {
|
|
707
808
|
sessionBar.innerHTML = '';
|
|
708
|
-
// Sort:
|
|
709
|
-
// recent activity
|
|
809
|
+
// Sort: lifecycle (active → idle → terminated) trumps priority (high → low),
|
|
810
|
+
// then most-recent activity. So a high-priority terminated session sits
|
|
811
|
+
// below a low-priority active one — what's running now matters more.
|
|
710
812
|
const ids = Object.keys(sessions).sort((a, b) => {
|
|
813
|
+
const la = LIFECYCLE_ORDER[sessionStatus(sessions[a])];
|
|
814
|
+
const lb = LIFECYCLE_ORDER[sessionStatus(sessions[b])];
|
|
815
|
+
if (la !== lb) return la - lb;
|
|
711
816
|
const pa = PRIORITY_ORDER[sessions[a].priority] ?? PRIORITY_ORDER[''];
|
|
712
817
|
const pb = PRIORITY_ORDER[sessions[b].priority] ?? PRIORITY_ORDER[''];
|
|
713
818
|
if (pa !== pb) return pa - pb;
|
|
@@ -716,16 +821,19 @@ function updateSessionBar() {
|
|
|
716
821
|
|
|
717
822
|
for (const id of ids) {
|
|
718
823
|
const s = sessions[id];
|
|
824
|
+
const lifecycle = sessionStatus(s);
|
|
719
825
|
const tag = document.createElement('span');
|
|
720
826
|
let cls = 'session-tag';
|
|
721
827
|
if (selectedSession === id) cls += ' active';
|
|
722
828
|
if (s.priority === 'low') cls += ' priority-low';
|
|
829
|
+
cls += ' lifecycle-' + lifecycle;
|
|
723
830
|
tag.className = cls;
|
|
724
831
|
tag.style.background = s.color + '22';
|
|
725
832
|
tag.style.color = s.color;
|
|
726
833
|
tag.title = `${id}${s.purpose ? '\n' + s.purpose : ''}`;
|
|
727
834
|
|
|
728
835
|
const pills = [];
|
|
836
|
+
pills.push(pill('lifecycle-' + lifecycle, lifecycle));
|
|
729
837
|
if (s.persona) pills.push(pill('persona-' + s.persona, s.persona));
|
|
730
838
|
if (s.priority) pills.push(pill('priority-' + s.priority, 'P:' + s.priority));
|
|
731
839
|
if (s.kind) pills.push(pill('kind-' + s.kind, s.kind));
|
package/watchtower/server.js
CHANGED
|
@@ -8,6 +8,7 @@ const http = require("http");
|
|
|
8
8
|
const fs = require("fs");
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const crypto = require("crypto");
|
|
11
|
+
const autoCheckpointRunner = require("./auto-checkpoint-runner");
|
|
11
12
|
|
|
12
13
|
const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
|
|
13
14
|
const DATA_FILE = path.join(__dirname, "events.json");
|
|
@@ -176,4 +177,24 @@ server.listen(PORT, () => {
|
|
|
176
177
|
console.log(
|
|
177
178
|
`Waiting for hook events on POST http://localhost:${PORT}/events`,
|
|
178
179
|
);
|
|
180
|
+
|
|
181
|
+
// Tier B auto-checkpoint runner. Polls every minute, calls
|
|
182
|
+
// `claude -p --bare --model haiku` for each active session whose
|
|
183
|
+
// configured interval has elapsed. Disable with `/auto-checkpoint off`
|
|
184
|
+
// per project, or set UVS_AUTO_CHECKPOINT_DISABLED=1 to disable globally.
|
|
185
|
+
if (!process.env.UVS_AUTO_CHECKPOINT_DISABLED) {
|
|
186
|
+
autoCheckpointRunner.start({
|
|
187
|
+
getEvents: () => events,
|
|
188
|
+
broadcast: (ev) => {
|
|
189
|
+
ev._ts = ev._ts || Date.now();
|
|
190
|
+
ev._id = crypto.randomUUID();
|
|
191
|
+
events.push(ev);
|
|
192
|
+
broadcast(ev);
|
|
193
|
+
saveEvents();
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
console.log(
|
|
197
|
+
"Auto-checkpoint runner started (Tier B, polls every 60s, uses claude -p)",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
179
200
|
});
|