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.
@@ -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
- // Fall back to first UserPromptSubmit if no configured name yet
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 (human) cls += ' needs-human';
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
- div.innerHTML = `
621
- <span class="time">${formatTime(ev._ts)}</span>
622
- <span class="type type-${type}">${type}${humanBadge}</span>
623
- <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
624
- <span class="tool">${tool}</span>
625
- <span class="detail">${eventDetail(ev)}</span>
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: high priority first, then med/unset, then low; within a tier, most
709
- // recent activity wins so the dashboard surfaces what's happening now.
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));
@@ -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
  });