uv-suite 0.25.0 → 0.26.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
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",
@@ -5,95 +5,340 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>UV Suite Watchtower</title>
7
7
  <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #0b0b0d;
11
+ --surface: #16161a;
12
+ --surface-hover: #1d1d22;
13
+ --border: #26262c;
14
+ --border-subtle: #1a1a1e;
15
+ --text: #e9e9ec;
16
+ --text-muted: #9a9aa3;
17
+ --text-dim: #6a6a73;
18
+ --accent: #0a84ff;
19
+ --accent-contrast: #ffffff;
20
+ --success: #30d158;
21
+ --success-soft: rgba(48, 209, 88, 0.18);
22
+ --danger: #ff453a;
23
+ --danger-soft: #ff375f;
24
+ --warning: #ff9f0a;
25
+ --yellow: #ffd60a;
26
+ --info: #64d2ff;
27
+ --purple: #bf5af2;
28
+ --purple-soft: #ac8ee0;
29
+ --peach: #ff6961;
30
+
31
+ --event-latest-bg: #17171c;
32
+ --needs-human-bg: rgba(255, 55, 95, 0.14);
33
+ --failure-bg: rgba(255, 105, 97, 0.12);
34
+ --session-boundary-bg: rgba(48, 209, 88, 0.06);
35
+ --user-prompt-bg: rgba(255, 214, 10, 0.06);
36
+ --user-prompt-text: #ffd60a;
37
+
38
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', system-ui, sans-serif;
39
+ --font-mono: 'SF Mono', ui-monospace, Menlo, Consolas, monospace;
40
+ }
41
+
42
+ [data-theme="light"] {
43
+ color-scheme: light;
44
+ --bg: #fafafa;
45
+ --surface: #ffffff;
46
+ --surface-hover: #f4f4f5;
47
+ --border: #e4e4e7;
48
+ --border-subtle: #ededef;
49
+ --text: #18181b;
50
+ --text-muted: #52525b;
51
+ --text-dim: #8a8a93;
52
+ --accent: #0066cc;
53
+ --accent-contrast: #ffffff;
54
+ --success: #1a9e3e;
55
+ --success-soft: rgba(26, 158, 62, 0.15);
56
+ --danger: #d8302a;
57
+ --danger-soft: #dc184a;
58
+ --warning: #c07300;
59
+ --yellow: #a5720d;
60
+ --info: #0b8aa4;
61
+ --purple: #7c2fbc;
62
+ --purple-soft: #8b3fd4;
63
+ --peach: #c13a35;
64
+
65
+ --event-latest-bg: #f4f4f5;
66
+ --needs-human-bg: rgba(220, 24, 74, 0.08);
67
+ --failure-bg: rgba(216, 48, 42, 0.07);
68
+ --session-boundary-bg: rgba(26, 158, 62, 0.06);
69
+ --user-prompt-bg: rgba(165, 114, 13, 0.06);
70
+ --user-prompt-text: #8a5f0b;
71
+ }
72
+
8
73
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
- body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif; background: #000; color: #f5f5f7; }
10
- .header { padding: 16px 24px; border-bottom: 1px solid #2d2d2f; display: flex; align-items: center; justify-content: space-between; }
11
- .header h1 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
12
- .header .status { font-size: 11px; color: #86868b; }
13
- .header .status .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
14
- .header .status .dot.on { background: #30d158; }
15
- .header .status .dot.off { background: #ff453a; }
16
-
17
- .filters { padding: 8px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
18
- .filters select, .filters button { background: #1d1d1f; color: #a1a1a6; border: 1px solid #2d2d2f; border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer; }
19
- .filters select:focus, .filters button:hover { border-color: #424245; color: #f5f5f7; }
20
- .filters button.active { background: #0071e3; color: #fff; border-color: #0071e3; }
21
-
22
- .stats { padding: 12px 24px; display: flex; gap: 24px; border-bottom: 1px solid #1d1d1f; }
23
- .stat { text-align: center; }
24
- .stat .n { font-size: 20px; font-weight: 600; }
25
- .stat .l { font-size: 10px; color: #86868b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
26
- .stat .n.alert { color: #ff375f; }
27
-
28
- .sessions { padding: 12px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
29
- .session-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
30
- .session-tag:hover { border-color: #424245; }
31
- .session-tag.active { border-color: #fff; }
32
-
33
- .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 280px); }
34
-
35
- .event { padding: 10px 24px; display: grid; grid-template-columns: 70px 110px 140px 65px 1fr; gap: 10px; align-items: start; font-size: 13px; border-bottom: 1px solid #0d0d0d; transition: all 0.2s; opacity: 0.75; }
36
- .event:hover { background: #0d0d0d; opacity: 1; }
37
- .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; padding-top: 2px; }
38
- .event .type { font-weight: 500; font-size: 12px; }
39
- .event .session { font-size: 11px; border-radius: 8px; padding: 2px 8px; display: inline-block; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40
- .event .tool { color: #a1a1a6; font-family: 'SF Mono', monospace; font-size: 11px; padding-top: 2px; }
41
- .event .detail { color: #a1a1a6; font-size: 12px; line-height: 1.5; word-break: break-all; }
42
- .event .detail .filepath { color: #64d2ff; }
43
- .event .detail .cmd { color: #ffd60a; }
44
-
45
- /* Latest event — full opacity, larger, highlighted */
46
- .event.latest { opacity: 1; font-size: 14px; background: #0a0a0a; border-bottom: 2px solid #2d2d2f; }
47
- .event.latest .type { font-size: 13px; }
48
- .event.latest .detail { font-size: 13px; color: #d1d1d6; }
49
-
50
- /* Human intervention — strong highlight */
51
- .event.needs-human { background: #ff375f18; border-left: 4px solid #ff375f; opacity: 1; }
52
- .event.needs-human .type { color: #ff375f; }
53
- .event.needs-human .human-badge { display: inline-block; background: #ff375f; color: #fff; font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 3px; letter-spacing: 0.5px; margin-left: 8px; vertical-align: middle; }
54
-
55
- /* Failure highlight */
56
- .event.failure { background: #ff696115; border-left: 4px solid #ff6961; opacity: 1; }
57
-
58
- /* Session start/end */
59
- .event.session-boundary { background: #30d15808; }
60
-
61
- /* User prompt — show what was asked */
62
- .event.user-prompt { background: #ffd60a08; }
63
- .event.user-prompt .detail { color: #ffd60a; font-style: italic; }
64
-
65
- /* Bottom loader area */
66
- .timeline-end { padding: 24px; text-align: center; }
67
- .loader { display: inline-block; width: 40px; height: 4px; position: relative; }
68
- .loader::before { content: ''; position: absolute; width: 8px; height: 4px; background: #424245; border-radius: 2px; animation: loader 1.2s ease-in-out infinite; }
74
+
75
+ html, body { height: 100%; }
76
+ body {
77
+ font-family: var(--font-sans);
78
+ background: var(--bg);
79
+ color: var(--text);
80
+ font-size: 14px;
81
+ line-height: 1.5;
82
+ -webkit-font-smoothing: antialiased;
83
+ -moz-osx-font-smoothing: grayscale;
84
+ display: flex;
85
+ flex-direction: column;
86
+ transition: background-color 0.2s ease, color 0.2s ease;
87
+ }
88
+
89
+ .header {
90
+ padding: 18px 28px;
91
+ border-bottom: 1px solid var(--border);
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 16px;
96
+ }
97
+ .header h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.02em; }
98
+ .header .meta { display: flex; align-items: center; gap: 14px; }
99
+ .header .status { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
100
+ .header .status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; vertical-align: 1px; }
101
+ .header .status .dot.on { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
102
+ .header .status .dot.off { background: var(--danger); }
103
+
104
+ .theme-toggle {
105
+ background: transparent;
106
+ color: var(--text-muted);
107
+ border: 1px solid var(--border);
108
+ border-radius: 7px;
109
+ width: 34px;
110
+ height: 30px;
111
+ display: inline-flex;
112
+ align-items: center;
113
+ justify-content: center;
114
+ cursor: pointer;
115
+ padding: 0;
116
+ transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
117
+ }
118
+ .theme-toggle:hover { color: var(--text); border-color: var(--text-dim); background: var(--surface); }
119
+ .theme-toggle svg { width: 15px; height: 15px; }
120
+ .theme-toggle .icon-sun { display: block; }
121
+ .theme-toggle .icon-moon { display: none; }
122
+ [data-theme="light"] .theme-toggle .icon-sun { display: none; }
123
+ [data-theme="light"] .theme-toggle .icon-moon { display: block; }
124
+
125
+ .stats {
126
+ padding: 16px 28px;
127
+ display: flex;
128
+ gap: 36px;
129
+ border-bottom: 1px solid var(--border-subtle);
130
+ }
131
+ .stat { display: flex; flex-direction: column; }
132
+ .stat .n {
133
+ font-size: 26px;
134
+ font-weight: 600;
135
+ letter-spacing: -0.02em;
136
+ font-variant-numeric: tabular-nums;
137
+ line-height: 1.1;
138
+ }
139
+ .stat .l {
140
+ font-size: 11px;
141
+ color: var(--text-muted);
142
+ text-transform: uppercase;
143
+ letter-spacing: 0.08em;
144
+ font-weight: 500;
145
+ margin-top: 4px;
146
+ }
147
+ .stat .n.alert { color: var(--danger-soft); }
148
+
149
+ .sessions {
150
+ padding: 12px 28px;
151
+ border-bottom: 1px solid var(--border-subtle);
152
+ display: flex;
153
+ gap: 8px;
154
+ flex-wrap: wrap;
155
+ }
156
+ .session-tag {
157
+ padding: 4px 12px;
158
+ border-radius: 14px;
159
+ font-size: 13px;
160
+ font-weight: 500;
161
+ cursor: pointer;
162
+ border: 1px solid transparent;
163
+ transition: border-color 0.15s ease;
164
+ }
165
+ .session-tag:hover { border-color: var(--text-dim); }
166
+ .session-tag.active { border-color: var(--text); }
167
+
168
+ .filters {
169
+ padding: 10px 28px;
170
+ border-bottom: 1px solid var(--border-subtle);
171
+ display: flex;
172
+ gap: 10px;
173
+ flex-wrap: wrap;
174
+ align-items: center;
175
+ }
176
+ .filters select, .filters button {
177
+ background: var(--surface);
178
+ color: var(--text-muted);
179
+ border: 1px solid var(--border);
180
+ border-radius: 6px;
181
+ padding: 0 12px;
182
+ font-size: 13px;
183
+ font-family: inherit;
184
+ cursor: pointer;
185
+ height: 32px;
186
+ transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
187
+ }
188
+ .filters select:hover, .filters button:hover { border-color: var(--text-dim); color: var(--text); }
189
+ .filters select:focus { outline: none; border-color: var(--accent); }
190
+ .filters button.active { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
191
+
192
+ .timeline { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0; }
193
+
194
+ .event {
195
+ padding: 14px 28px;
196
+ display: grid;
197
+ grid-template-columns: 82px 150px 170px 90px 1fr;
198
+ gap: 14px;
199
+ align-items: start;
200
+ font-size: 14px;
201
+ border-bottom: 1px solid var(--border-subtle);
202
+ transition: background-color 0.15s ease, opacity 0.15s ease;
203
+ opacity: 0.82;
204
+ }
205
+ .event:hover { background: var(--surface-hover); opacity: 1; }
206
+ .event .time {
207
+ color: var(--text-dim);
208
+ font-variant-numeric: tabular-nums;
209
+ font-family: var(--font-mono);
210
+ font-size: 12.5px;
211
+ padding-top: 2px;
212
+ }
213
+ .event .type {
214
+ font-weight: 600;
215
+ font-size: 14px;
216
+ letter-spacing: -0.01em;
217
+ }
218
+ .event .session {
219
+ font-size: 12.5px;
220
+ font-weight: 500;
221
+ border-radius: 10px;
222
+ padding: 3px 10px;
223
+ display: inline-block;
224
+ max-width: 170px;
225
+ overflow: hidden;
226
+ text-overflow: ellipsis;
227
+ white-space: nowrap;
228
+ }
229
+ .event .tool {
230
+ color: var(--text-muted);
231
+ font-family: var(--font-mono);
232
+ font-size: 12.5px;
233
+ padding-top: 2px;
234
+ }
235
+ .event .detail {
236
+ color: var(--text-muted);
237
+ font-size: 14px;
238
+ line-height: 1.55;
239
+ word-break: break-word;
240
+ }
241
+ .event .detail .filepath { color: var(--info); }
242
+ .event .detail .cmd { color: var(--yellow); font-family: var(--font-mono); }
243
+
244
+ .event.latest {
245
+ opacity: 1;
246
+ background: var(--event-latest-bg);
247
+ border-bottom: 2px solid var(--border);
248
+ }
249
+ .event.latest .type { font-size: 15px; }
250
+ .event.latest .detail { font-size: 15px; color: var(--text); }
251
+
252
+ .event.needs-human {
253
+ background: var(--needs-human-bg);
254
+ border-left: 4px solid var(--danger-soft);
255
+ padding-left: 24px;
256
+ opacity: 1;
257
+ }
258
+ .event.needs-human .type { color: var(--danger-soft); }
259
+ .event.needs-human .human-badge {
260
+ display: inline-block;
261
+ background: var(--danger-soft);
262
+ color: #fff;
263
+ font-size: 10px;
264
+ font-weight: 700;
265
+ padding: 2px 7px;
266
+ border-radius: 4px;
267
+ letter-spacing: 0.06em;
268
+ margin-left: 10px;
269
+ vertical-align: middle;
270
+ }
271
+
272
+ .event.failure {
273
+ background: var(--failure-bg);
274
+ border-left: 4px solid var(--peach);
275
+ padding-left: 24px;
276
+ opacity: 1;
277
+ }
278
+ .event.session-boundary { background: var(--session-boundary-bg); }
279
+ .event.user-prompt { background: var(--user-prompt-bg); }
280
+ .event.user-prompt .detail { color: var(--user-prompt-text); font-style: italic; }
281
+
282
+ .timeline-end { padding: 20px 24px; text-align: center; border-bottom: 1px solid var(--border-subtle); }
283
+ .loader { display: inline-block; width: 48px; height: 4px; position: relative; }
284
+ .loader::before {
285
+ content: '';
286
+ position: absolute;
287
+ width: 10px;
288
+ height: 4px;
289
+ background: var(--text-dim);
290
+ border-radius: 2px;
291
+ animation: loader 1.4s ease-in-out infinite;
292
+ }
69
293
  @keyframes loader {
70
294
  0%, 100% { left: 0; }
71
- 50% { left: 32px; }
72
- }
73
- .timeline-end .waiting { font-size: 11px; color: #424245; margin-top: 8px; }
74
-
75
- .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
76
- .empty p { margin-top: 8px; font-size: 13px; }
77
-
78
- /* Event type colors */
79
- .type-SessionStart { color: #30d158; }
80
- .type-SessionEnd, .type-Stop { color: #ff453a; }
81
- .type-PreToolUse { color: #0a84ff; }
82
- .type-PostToolUse { color: #64d2ff; }
83
- .type-PostToolUseFailure { color: #ff6961; }
84
- .type-UserPromptSubmit { color: #ffd60a; }
85
- .type-SubagentStart { color: #bf5af2; }
86
- .type-SubagentStop { color: #ac8ee0; }
87
- .type-Notification { color: #ff9f0a; }
88
- .type-PermissionRequest { color: #ff375f; }
89
- .type-PreCompact { color: #6e6e73; }
295
+ 50% { left: 38px; }
296
+ }
297
+ .timeline-end .waiting {
298
+ font-size: 13px;
299
+ color: var(--text-dim);
300
+ margin-top: 10px;
301
+ font-variant-numeric: tabular-nums;
302
+ }
303
+
304
+ .empty { padding: 72px 28px; text-align: center; color: var(--text-dim); }
305
+ .empty p { margin-top: 10px; font-size: 14px; }
306
+ .empty p strong { color: var(--text-muted); font-weight: 600; font-size: 15px; }
307
+
308
+ .timeline::-webkit-scrollbar { width: 10px; }
309
+ .timeline::-webkit-scrollbar-track { background: transparent; }
310
+ .timeline::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
311
+ .timeline::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
312
+
313
+ .type-SessionStart { color: var(--success); }
314
+ .type-SessionEnd, .type-Stop { color: var(--danger); }
315
+ .type-PreToolUse { color: var(--accent); }
316
+ .type-PostToolUse { color: var(--info); }
317
+ .type-PostToolUseFailure { color: var(--peach); }
318
+ .type-UserPromptSubmit { color: var(--yellow); }
319
+ .type-SubagentStart { color: var(--purple); }
320
+ .type-SubagentStop { color: var(--purple-soft); }
321
+ .type-Notification { color: var(--warning); }
322
+ .type-PermissionRequest { color: var(--danger-soft); }
323
+ .type-PreCompact { color: var(--text-muted); }
90
324
  </style>
91
325
  </head>
92
326
  <body>
93
327
 
94
328
  <div class="header">
95
329
  <h1>UV Suite Watchtower</h1>
96
- <div class="status"><span class="dot" id="statusDot"></span><span id="statusText">Connecting...</span></div>
330
+ <div class="meta">
331
+ <div class="status"><span class="dot" id="statusDot"></span><span id="statusText">Connecting...</span></div>
332
+ <button class="theme-toggle" id="themeToggle" type="button" aria-label="Toggle theme" title="Toggle theme">
333
+ <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">
334
+ <circle cx="12" cy="12" r="4"/>
335
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
336
+ </svg>
337
+ <svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
338
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
339
+ </svg>
340
+ </button>
341
+ </div>
97
342
  </div>
98
343
 
99
344
  <div class="stats">
@@ -308,6 +553,8 @@ function renderEvent(ev) {
308
553
  <span class="detail">${eventDetail(ev)}</span>
309
554
  `;
310
555
 
556
+ div._ev = ev;
557
+
311
558
  // Check filters
312
559
  const typeMatch = !selectedType || type === selectedType;
313
560
  const sessMatch = !selectedSession || sid === selectedSession;
@@ -362,7 +609,14 @@ function updateStats() {
362
609
  const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
363
610
  document.getElementById('errorCount').textContent = errors;
364
611
  document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
365
- const humans = events.filter(needsHuman).length;
612
+ // Count sessions whose most recent event is waiting on a human — so the
613
+ // number drops back down once the session continues past the prompt.
614
+ const latestBySession = {};
615
+ for (const ev of events) {
616
+ const sid = ev.session_id || ev.source_app || 'unknown';
617
+ latestBySession[sid] = ev;
618
+ }
619
+ const humans = Object.values(latestBySession).filter(needsHuman).length;
366
620
  document.getElementById('humanCount').textContent = humans;
367
621
  document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
368
622
  }
@@ -405,8 +659,9 @@ function refilter() {
405
659
  selectedType = filterType.value;
406
660
  selectedSession = filterSession.value;
407
661
  const rows = timeline.querySelectorAll('.event');
408
- rows.forEach((row, i) => {
409
- const ev = events[i];
662
+ rows.forEach((row) => {
663
+ const ev = row._ev;
664
+ if (!ev) return;
410
665
  const sid = ev.session_id || ev.source_app || 'unknown';
411
666
  const type = ev.event_type || ev.hook_event_name || '';
412
667
  const show = (!selectedType || type === selectedType)
@@ -456,6 +711,27 @@ function connect() {
456
711
  };
457
712
  }
458
713
 
714
+ // Theme: persist choice in localStorage, fall back to system preference
715
+ const THEME_KEY = 'uv-watchtower-theme';
716
+ const themeToggle = document.getElementById('themeToggle');
717
+ const mql = window.matchMedia('(prefers-color-scheme: light)');
718
+
719
+ function applyTheme(t) { document.documentElement.setAttribute('data-theme', t); }
720
+ function resolvedTheme() {
721
+ return localStorage.getItem(THEME_KEY) || (mql.matches ? 'light' : 'dark');
722
+ }
723
+ applyTheme(resolvedTheme());
724
+
725
+ themeToggle.onclick = () => {
726
+ const next = resolvedTheme() === 'light' ? 'dark' : 'light';
727
+ localStorage.setItem(THEME_KEY, next);
728
+ applyTheme(next);
729
+ };
730
+
731
+ mql.addEventListener('change', () => {
732
+ if (!localStorage.getItem(THEME_KEY)) applyTheme(resolvedTheme());
733
+ });
734
+
459
735
  connect();
460
736
  </script>
461
737
  </body>
@@ -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
  });