uv-suite 0.26.0 → 0.26.2

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.
@@ -279,6 +279,42 @@
279
279
  .event.user-prompt { background: var(--user-prompt-bg); }
280
280
  .event.user-prompt .detail { color: var(--user-prompt-text); font-style: italic; }
281
281
 
282
+ /* Session priority: low rows are dimmed, high rows get an accent strip */
283
+ .event.priority-low { opacity: 0.45; }
284
+ .event.priority-low:hover { opacity: 0.85; }
285
+ .event.priority-high { border-left: 3px solid var(--accent); padding-left: 25px; }
286
+
287
+ /* Pills shown next to a session label */
288
+ .pill {
289
+ display: inline-block;
290
+ padding: 1px 7px;
291
+ margin-left: 6px;
292
+ font-size: 10.5px;
293
+ font-weight: 600;
294
+ letter-spacing: 0.04em;
295
+ text-transform: uppercase;
296
+ border-radius: 4px;
297
+ vertical-align: 1px;
298
+ }
299
+ .pill.persona-spike { background: rgba(191, 90, 242, 0.18); color: var(--purple); }
300
+ .pill.persona-sport { background: rgba(48, 209, 88, 0.18); color: var(--success); }
301
+ .pill.persona-professional { background: rgba(10, 132, 255, 0.18); color: var(--accent); }
302
+ .pill.persona-auto { background: rgba(255, 159, 10, 0.18); color: var(--warning); }
303
+ .pill.priority-low { background: rgba(154, 154, 163, 0.18); color: var(--text-muted); }
304
+ .pill.priority-med { background: rgba(255, 214, 10, 0.18); color: var(--yellow); }
305
+ .pill.priority-high { background: rgba(255, 69, 58, 0.18); color: var(--danger); }
306
+ .pill.kind-long-running { background: rgba(100, 210, 255, 0.18); color: var(--info); }
307
+ .pill.kind-outcome { background: rgba(255, 105, 97, 0.18); color: var(--peach); }
308
+
309
+ .session-tag.priority-low { opacity: 0.6; }
310
+ .session-tag .meta-line {
311
+ display: block;
312
+ font-size: 11px;
313
+ font-weight: 400;
314
+ color: var(--text-muted);
315
+ margin-top: 2px;
316
+ }
317
+
282
318
  .timeline-end { padding: 20px 24px; text-align: center; border-bottom: 1px solid var(--border-subtle); }
283
319
  .loader { display: inline-block; width: 48px; height: 4px; position: relative; }
284
320
  .loader::before {
@@ -393,9 +429,21 @@ let lastEventDiv = null;
393
429
  // Session colors and naming
394
430
  const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
395
431
  let colorIdx = 0;
432
+
433
+ // Resolve the canonical session key for an event: prefer the UV Suite id (one
434
+ // per `uv` launch), fall back to Claude Code's session id, then source_app.
435
+ function eventSid(ev) {
436
+ return ev.uvs_session_id || ev.session_id || ev.source_app || 'unknown';
437
+ }
438
+
396
439
  function sessionColor(id) {
397
440
  if (!sessions[id]) {
398
- sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null, label: null, app: null };
441
+ sessions[id] = {
442
+ color: palette[colorIdx++ % palette.length],
443
+ count: 0, lastEvent: null,
444
+ name: '', kind: '', purpose: '', priority: '', persona: '',
445
+ app: null, label: null,
446
+ };
399
447
  updateSessionBar();
400
448
  updateFilterSession();
401
449
  }
@@ -406,33 +454,54 @@ function sessionColor(id) {
406
454
 
407
455
  function updateSessionLabel(sid, ev) {
408
456
  if (!sessions[sid]) return;
409
- if (!sessions[sid].app && ev.source_app) {
410
- sessions[sid].app = ev.source_app;
457
+ const s = sessions[sid];
458
+ let changed = false;
459
+
460
+ if (!s.app && ev.source_app) { s.app = ev.source_app; changed = true; }
461
+
462
+ // Configured metadata wins over heuristics. Update on every event so a
463
+ // mid-session /session-init relabel is reflected without a refresh.
464
+ for (const [evKey, sKey] of [
465
+ ['session_name','name'], ['session_kind','kind'],
466
+ ['session_purpose','purpose'], ['session_priority','priority'],
467
+ ['persona','persona'],
468
+ ]) {
469
+ if (ev[evKey] !== undefined && ev[evKey] !== null && ev[evKey] !== s[sKey]) {
470
+ s[sKey] = ev[evKey];
471
+ changed = true;
472
+ }
411
473
  }
412
- // Use first UserPromptSubmit as session label
474
+
475
+ // Fall back to first UserPromptSubmit if no configured name yet
413
476
  const type = ev.event_type || ev.hook_event_name || '';
414
- if (!sessions[sid].label && type === 'UserPromptSubmit') {
477
+ if (!s.name && !s.label && type === 'UserPromptSubmit') {
415
478
  const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
416
479
  if (prompt.length > 0) {
417
480
  let label = prompt.slice(0, 45).replace(/\s+\S*$/, '');
418
481
  if (prompt.length > label.length) label += '...';
419
- sessions[sid].label = label;
420
- updateSessionBar();
421
- updateFilterSession();
482
+ s.label = label;
483
+ changed = true;
422
484
  }
423
485
  }
486
+
487
+ if (changed) {
488
+ updateSessionBar();
489
+ updateFilterSession();
490
+ }
424
491
  }
425
492
 
426
493
  function sessionDisplayName(id) {
427
494
  const s = sessions[id];
428
495
  if (!s) return shortId(id);
429
- if (s.label) {
430
- return s.app ? s.app + ': ' + s.label : s.label;
431
- }
496
+ if (s.name) return s.name;
497
+ if (s.label) return s.app ? s.app + ': ' + s.label : s.label;
432
498
  if (s.app) return s.app;
433
499
  return shortId(id);
434
500
  }
435
501
 
502
+ // Sort order: high → med → unset → low (low last so it groups at the bottom)
503
+ const PRIORITY_ORDER = { high: 0, med: 1, '': 2, low: 3 };
504
+
436
505
  function shortId(id) {
437
506
  if (!id) return '—';
438
507
  return id.length > 10 ? id.slice(0, 8) + '..' : id;
@@ -526,7 +595,7 @@ function eventDetail(ev) {
526
595
  }
527
596
 
528
597
  function renderEvent(ev) {
529
- const sid = ev.session_id || ev.source_app || 'unknown';
598
+ const sid = eventSid(ev);
530
599
  const color = sessionColor(sid);
531
600
  const type = ev.event_type || ev.hook_event_name || '?';
532
601
  const tool = ev.tool_name || '';
@@ -534,6 +603,7 @@ function renderEvent(ev) {
534
603
  const fail = isFailure(ev);
535
604
  const boundary = isSessionBoundary(ev);
536
605
  const prompt = isUserPrompt(ev);
606
+ const priority = sessions[sid]?.priority || '';
537
607
 
538
608
  const div = document.createElement('div');
539
609
  let cls = 'event';
@@ -541,6 +611,8 @@ function renderEvent(ev) {
541
611
  else if (fail) cls += ' failure';
542
612
  else if (prompt) cls += ' user-prompt';
543
613
  else if (boundary) cls += ' session-boundary';
614
+ if (priority === 'high') cls += ' priority-high';
615
+ if (priority === 'low') cls += ' priority-low';
544
616
  div.className = cls;
545
617
 
546
618
  const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
@@ -553,6 +625,8 @@ function renderEvent(ev) {
553
625
  <span class="detail">${eventDetail(ev)}</span>
554
626
  `;
555
627
 
628
+ div._ev = ev;
629
+
556
630
  // Check filters
557
631
  const typeMatch = !selectedType || type === selectedType;
558
632
  const sessMatch = !selectedSession || sid === selectedSession;
@@ -565,7 +639,9 @@ function renderEvent(ev) {
565
639
  function addEvent(ev) {
566
640
  events.push(ev);
567
641
  if (emptyState.parentNode) emptyState.remove();
568
- const sid = ev.session_id || ev.source_app || 'unknown';
642
+ const sid = eventSid(ev);
643
+ // Make sure the session is registered before we try to update its metadata
644
+ sessionColor(sid);
569
645
  updateSessionLabel(sid, ev);
570
646
 
571
647
  // Remove "latest" class from previous latest
@@ -590,7 +666,7 @@ function addEvent(ev) {
590
666
 
591
667
  function updateWaitingText(ev) {
592
668
  if (ev) {
593
- const sid = ev.session_id || ev.source_app || 'unknown';
669
+ const sid = eventSid(ev);
594
670
  waitingText.textContent = `Last: ${sessionDisplayName(sid)} — ${timeSince(ev._ts)}`;
595
671
  }
596
672
  }
@@ -607,20 +683,58 @@ function updateStats() {
607
683
  const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
608
684
  document.getElementById('errorCount').textContent = errors;
609
685
  document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
610
- const humans = events.filter(needsHuman).length;
686
+ // Count sessions whose most recent event is waiting on a human — so the
687
+ // number drops back down once the session continues past the prompt.
688
+ const latestBySession = {};
689
+ for (const ev of events) {
690
+ const sid = eventSid(ev);
691
+ latestBySession[sid] = ev;
692
+ }
693
+ const humans = Object.values(latestBySession).filter(needsHuman).length;
611
694
  document.getElementById('humanCount').textContent = humans;
612
695
  document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
613
696
  }
614
697
 
698
+ function escapeHtml(s) {
699
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
700
+ }
701
+
702
+ function pill(cls, label) {
703
+ return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
704
+ }
705
+
615
706
  function updateSessionBar() {
616
707
  sessionBar.innerHTML = '';
617
- for (const [id, s] of Object.entries(sessions)) {
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.
710
+ const ids = Object.keys(sessions).sort((a, b) => {
711
+ const pa = PRIORITY_ORDER[sessions[a].priority] ?? PRIORITY_ORDER[''];
712
+ const pb = PRIORITY_ORDER[sessions[b].priority] ?? PRIORITY_ORDER[''];
713
+ if (pa !== pb) return pa - pb;
714
+ return (sessions[b].lastEvent || 0) - (sessions[a].lastEvent || 0);
715
+ });
716
+
717
+ for (const id of ids) {
718
+ const s = sessions[id];
618
719
  const tag = document.createElement('span');
619
- tag.className = 'session-tag' + (selectedSession === id ? ' active' : '');
720
+ let cls = 'session-tag';
721
+ if (selectedSession === id) cls += ' active';
722
+ if (s.priority === 'low') cls += ' priority-low';
723
+ tag.className = cls;
620
724
  tag.style.background = s.color + '22';
621
725
  tag.style.color = s.color;
622
- tag.textContent = sessionDisplayName(id) + ' (' + s.count + ')';
623
- tag.title = id;
726
+ tag.title = `${id}${s.purpose ? '\n' + s.purpose : ''}`;
727
+
728
+ const pills = [];
729
+ if (s.persona) pills.push(pill('persona-' + s.persona, s.persona));
730
+ if (s.priority) pills.push(pill('priority-' + s.priority, 'P:' + s.priority));
731
+ if (s.kind) pills.push(pill('kind-' + s.kind, s.kind));
732
+
733
+ const namePart = `<strong>${escapeHtml(sessionDisplayName(id))}</strong>` +
734
+ ` <span style="opacity:0.7">(${s.count})</span>`;
735
+ const meta = pills.length ? `<span class="meta-line">${pills.join('')}</span>` : '';
736
+ tag.innerHTML = namePart + meta;
737
+
624
738
  tag.onclick = () => { selectedSession = selectedSession === id ? '' : id; refilter(); updateSessionBar(); };
625
739
  sessionBar.appendChild(tag);
626
740
  }
@@ -650,9 +764,10 @@ function refilter() {
650
764
  selectedType = filterType.value;
651
765
  selectedSession = filterSession.value;
652
766
  const rows = timeline.querySelectorAll('.event');
653
- rows.forEach((row, i) => {
654
- const ev = events[i];
655
- const sid = ev.session_id || ev.source_app || 'unknown';
767
+ rows.forEach((row) => {
768
+ const ev = row._ev;
769
+ if (!ev) return;
770
+ const sid = eventSid(ev);
656
771
  const type = ev.event_type || ev.hook_event_name || '';
657
772
  const show = (!selectedType || type === selectedType)
658
773
  && (!selectedSession || sid === selectedSession)
@@ -1,22 +1 @@
1
- [
2
- {
3
- "event_type": "PostToolUse",
4
- "session_id": "test-123",
5
- "source_app": "uv-suite",
6
- "tool_name": "Edit",
7
- "cwd": "/tmp",
8
- "_ts": 1776756371726,
9
- "_id": "3f130976-6226-47ea-a477-18a16e194415"
10
- },
11
- {
12
- "event_type": "PostToolUse",
13
- "session_id": "test-session",
14
- "source_app": "my-project",
15
- "tool_name": "Edit",
16
- "tool_input": {
17
- "file_path": "src/app.ts"
18
- },
19
- "_ts": 1776757586012,
20
- "_id": "efa9ae75-c0e0-48c2-a078-e7075ccef5a7"
21
- }
22
- ]
1
+ []
@@ -4,20 +4,20 @@
4
4
  // Zero dependencies beyond Node.js
5
5
  // Uses Server-Sent Events (SSE) instead of WebSocket — simpler, auto-reconnects
6
6
 
7
- const http = require('http');
8
- const fs = require('fs');
9
- const path = require('path');
10
- const crypto = require('crypto');
7
+ const http = require("http");
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const crypto = require("crypto");
11
11
 
12
12
  const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
13
- const DATA_FILE = path.join(__dirname, 'events.json');
13
+ const DATA_FILE = path.join(__dirname, "events.json");
14
14
  const MAX_EVENTS = 500;
15
15
 
16
16
  // In-memory event store
17
17
  let events = [];
18
18
  try {
19
19
  if (fs.existsSync(DATA_FILE)) {
20
- events = JSON.parse(fs.readFileSync(DATA_FILE, 'utf-8'));
20
+ events = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8"));
21
21
  }
22
22
  } catch (e) {
23
23
  events = [];
@@ -50,20 +50,20 @@ function saveEvents() {
50
50
 
51
51
  const server = http.createServer((req, res) => {
52
52
  // CORS
53
- res.setHeader('Access-Control-Allow-Origin', '*');
54
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
55
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
53
+ res.setHeader("Access-Control-Allow-Origin", "*");
54
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
55
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
56
56
 
57
- if (req.method === 'OPTIONS') {
57
+ if (req.method === "OPTIONS") {
58
58
  res.writeHead(200);
59
59
  return res.end();
60
60
  }
61
61
 
62
62
  // POST /events — receive hook events
63
- if (req.method === 'POST' && req.url === '/events') {
64
- let body = '';
65
- req.on('data', chunk => body += chunk);
66
- req.on('end', () => {
63
+ if (req.method === "POST" && req.url === "/events") {
64
+ let body = "";
65
+ req.on("data", (chunk) => (body += chunk));
66
+ req.on("end", () => {
67
67
  try {
68
68
  const event = JSON.parse(body);
69
69
  event._ts = Date.now();
@@ -71,7 +71,7 @@ const server = http.createServer((req, res) => {
71
71
  events.push(event);
72
72
  broadcast(event);
73
73
  saveEvents();
74
- res.writeHead(200, { 'Content-Type': 'application/json' });
74
+ res.writeHead(200, { "Content-Type": "application/json" });
75
75
  res.end('{"ok":true}');
76
76
  } catch (e) {
77
77
  res.writeHead(400);
@@ -82,24 +82,30 @@ const server = http.createServer((req, res) => {
82
82
  }
83
83
 
84
84
  // GET /stream — SSE endpoint (replaces WebSocket)
85
- if (req.method === 'GET' && req.url === '/stream') {
85
+ if (req.method === "GET" && req.url === "/stream") {
86
86
  res.writeHead(200, {
87
- 'Content-Type': 'text/event-stream',
88
- 'Cache-Control': 'no-cache',
89
- 'Connection': 'keep-alive',
87
+ "Content-Type": "text/event-stream",
88
+ "Cache-Control": "no-cache",
89
+ Connection: "keep-alive",
90
90
  });
91
91
 
92
92
  // Send recent events as init
93
- res.write(`data: ${JSON.stringify({ type: 'init', events: events.slice(-100) })}\n\n`);
93
+ res.write(
94
+ `data: ${JSON.stringify({ type: "init", events: events.slice(-100) })}\n\n`,
95
+ );
94
96
 
95
97
  sseClients.add(res);
96
98
 
97
99
  // Keep-alive ping every 15 seconds
98
100
  const keepAlive = setInterval(() => {
99
- try { res.write(': ping\n\n'); } catch (e) { clearInterval(keepAlive); }
101
+ try {
102
+ res.write(": ping\n\n");
103
+ } catch (e) {
104
+ clearInterval(keepAlive);
105
+ }
100
106
  }, 15000);
101
107
 
102
- req.on('close', () => {
108
+ req.on("close", () => {
103
109
  sseClients.delete(res);
104
110
  clearInterval(keepAlive);
105
111
  });
@@ -107,26 +113,67 @@ const server = http.createServer((req, res) => {
107
113
  }
108
114
 
109
115
  // GET /events — fetch recent events (REST fallback)
110
- if (req.method === 'GET' && req.url.startsWith('/events')) {
111
- res.writeHead(200, { 'Content-Type': 'application/json' });
116
+ if (req.method === "GET" && req.url.startsWith("/events")) {
117
+ res.writeHead(200, { "Content-Type": "application/json" });
112
118
  res.end(JSON.stringify(events.slice(-100)));
113
119
  return;
114
120
  }
115
121
 
116
122
  // GET / — serve dashboard
117
- if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
118
- const html = fs.readFileSync(path.join(__dirname, 'dashboard.html'), 'utf-8');
119
- res.writeHead(200, { 'Content-Type': 'text/html' });
123
+ if (req.method === "GET" && (req.url === "/" || req.url === "/index.html")) {
124
+ const html = fs.readFileSync(
125
+ path.join(__dirname, "dashboard.html"),
126
+ "utf-8",
127
+ );
128
+ res.writeHead(200, { "Content-Type": "text/html" });
120
129
  res.end(html);
121
130
  return;
122
131
  }
123
132
 
124
133
  res.writeHead(404);
125
- res.end('not found');
134
+ res.end("not found");
135
+ });
136
+
137
+ server.on("error", (err) => {
138
+ if (err.code !== "EADDRINUSE") {
139
+ console.error("Watchtower server error:", err.message);
140
+ process.exit(1);
141
+ }
142
+ // Port busy — probe to see if it's an existing watchtower or another process
143
+ const req = http.request(
144
+ { host: "127.0.0.1", port: PORT, path: "/", method: "GET", timeout: 1500 },
145
+ (res) => {
146
+ let body = "";
147
+ res.on("data", (c) => (body += c));
148
+ res.on("end", () => {
149
+ if (/UV Suite Watchtower/.test(body)) {
150
+ console.log(
151
+ `UV Suite Watchtower is already running at http://localhost:${PORT}`,
152
+ );
153
+ process.exit(0);
154
+ } else {
155
+ console.error(`Port ${PORT} is in use by another process.`);
156
+ console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
157
+ process.exit(1);
158
+ }
159
+ });
160
+ },
161
+ );
162
+ req.on("error", () => {
163
+ console.error(`Port ${PORT} is in use but not responding.`);
164
+ console.error(`Set UVS_WATCHTOWER_PORT to use a different port.`);
165
+ process.exit(1);
166
+ });
167
+ req.on("timeout", () => {
168
+ req.destroy();
169
+ });
170
+ req.end();
126
171
  });
127
172
 
128
173
  server.listen(PORT, () => {
129
174
  console.log(`UV Suite Watchtower running at http://localhost:${PORT}`);
130
175
  console.log(`${events.length} events loaded from disk`);
131
- console.log(`Waiting for hook events on POST http://localhost:${PORT}/events`);
176
+ console.log(
177
+ `Waiting for hook events on POST http://localhost:${PORT}/events`,
178
+ );
132
179
  });