uv-suite 0.21.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",
@@ -54,7 +54,16 @@
54
54
  {
55
55
  "matcher": "*",
56
56
  "hooks": [
57
- { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 }
57
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 },
58
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart", "timeout": 2, "async": true }
59
+ ]
60
+ }
61
+ ],
62
+ "UserPromptSubmit": [
63
+ {
64
+ "matcher": "*",
65
+ "hooks": [
66
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh UserPromptSubmit", "timeout": 2, "async": true }
58
67
  ]
59
68
  }
60
69
  ],
@@ -56,6 +56,25 @@
56
56
  "type": "command",
57
57
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh",
58
58
  "timeout": 5
59
+ },
60
+ {
61
+ "type": "command",
62
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart",
63
+ "timeout": 2,
64
+ "async": true
65
+ }
66
+ ]
67
+ }
68
+ ],
69
+ "UserPromptSubmit": [
70
+ {
71
+ "matcher": "*",
72
+ "hooks": [
73
+ {
74
+ "type": "command",
75
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh UserPromptSubmit",
76
+ "timeout": 2,
77
+ "async": true
59
78
  }
60
79
  ]
61
80
  }
@@ -40,7 +40,16 @@
40
40
  {
41
41
  "matcher": "*",
42
42
  "hooks": [
43
- { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 }
43
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 },
44
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart", "timeout": 2, "async": true }
45
+ ]
46
+ }
47
+ ],
48
+ "UserPromptSubmit": [
49
+ {
50
+ "matcher": "*",
51
+ "hooks": [
52
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh UserPromptSubmit", "timeout": 2, "async": true }
44
53
  ]
45
54
  }
46
55
  ],
@@ -26,7 +26,16 @@
26
26
  {
27
27
  "matcher": "*",
28
28
  "hooks": [
29
- { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 }
29
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh", "timeout": 5 },
30
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh SessionStart", "timeout": 2, "async": true }
31
+ ]
32
+ }
33
+ ],
34
+ "UserPromptSubmit": [
35
+ {
36
+ "matcher": "*",
37
+ "hooks": [
38
+ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh UserPromptSubmit", "timeout": 2, "async": true }
30
39
  ]
31
40
  }
32
41
  ],
@@ -30,29 +30,48 @@
30
30
  .session-tag:hover { border-color: #424245; }
31
31
  .session-tag.active { border-color: #fff; }
32
32
 
33
- .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 240px); }
34
- .event { padding: 8px 24px; display: grid; grid-template-columns: 70px 110px 120px 60px 1fr; gap: 8px; align-items: start; font-size: 12px; border-bottom: 1px solid #0d0d0d; transition: background 0.15s; }
35
- .event:hover { background: #0d0d0d; }
36
- .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
37
- .event .type { font-weight: 500; }
38
- .event .session { font-size: 11px; border-radius: 8px; padding: 1px 8px; display: inline-block; }
39
- .event .tool { color: #a1a1a6; font-family: 'SF Mono', monospace; font-size: 11px; }
40
- .event .detail { color: #a1a1a6; font-size: 11px; line-height: 1.4; }
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; }
41
42
  .event .detail .filepath { color: #64d2ff; }
42
43
  .event .detail .cmd { color: #ffd60a; }
43
44
 
44
- /* Human interventionpulsing highlight */
45
- .event.needs-human { background: #ff375f12; border-left: 3px solid #ff375f; }
45
+ /* Latest eventfull 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; }
46
52
  .event.needs-human .type { color: #ff375f; }
47
- .event.needs-human::after { content: 'NEEDS HUMAN'; position: absolute; right: 24px; font-size: 9px; font-weight: 700; color: #ff375f; letter-spacing: 0.5px; }
48
- .event.needs-human { position: relative; }
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; }
49
54
 
50
55
  /* Failure highlight */
51
- .event.failure { background: #ff696112; border-left: 3px solid #ff6961; }
56
+ .event.failure { background: #ff696115; border-left: 4px solid #ff6961; opacity: 1; }
52
57
 
53
- /* Session start/end highlight */
58
+ /* Session start/end */
54
59
  .event.session-boundary { background: #30d15808; }
55
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; }
69
+ @keyframes loader {
70
+ 0%, 100% { left: 0; }
71
+ 50% { left: 32px; }
72
+ }
73
+ .timeline-end .waiting { font-size: 11px; color: #424245; margin-top: 8px; }
74
+
56
75
  .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
57
76
  .empty p { margin-top: 8px; font-size: 13px; }
58
77
 
@@ -102,6 +121,12 @@
102
121
  </div>
103
122
  </div>
104
123
 
124
+ <!-- Bottom loader — always visible, shows system is listening -->
125
+ <div class="timeline-end" id="timelineEnd">
126
+ <div class="loader"></div>
127
+ <div class="waiting" id="waitingText">Listening for events...</div>
128
+ </div>
129
+
105
130
  <script>
106
131
  const timeline = document.getElementById('timeline');
107
132
  const emptyState = document.getElementById('emptyState');
@@ -110,6 +135,7 @@ const filterSession = document.getElementById('filterSession');
110
135
  const statusDot = document.getElementById('statusDot');
111
136
  const statusText = document.getElementById('statusText');
112
137
  const sessionBar = document.getElementById('sessionBar');
138
+ const waitingText = document.getElementById('waitingText');
113
139
 
114
140
  let events = [];
115
141
  let sessions = {};
@@ -117,13 +143,14 @@ let autoScroll = true;
117
143
  let humanOnly = false;
118
144
  let selectedSession = '';
119
145
  let selectedType = '';
146
+ let lastEventDiv = null;
120
147
 
121
- // Session colors
148
+ // Session colors and naming
122
149
  const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
123
150
  let colorIdx = 0;
124
151
  function sessionColor(id) {
125
152
  if (!sessions[id]) {
126
- sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null };
153
+ sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null, label: null, app: null };
127
154
  updateSessionBar();
128
155
  updateFilterSession();
129
156
  }
@@ -132,33 +159,70 @@ function sessionColor(id) {
132
159
  return sessions[id].color;
133
160
  }
134
161
 
162
+ function updateSessionLabel(sid, ev) {
163
+ if (!sessions[sid]) return;
164
+ if (!sessions[sid].app && ev.source_app) {
165
+ sessions[sid].app = ev.source_app;
166
+ }
167
+ // Use first UserPromptSubmit as session label
168
+ const type = ev.event_type || ev.hook_event_name || '';
169
+ if (!sessions[sid].label && type === 'UserPromptSubmit') {
170
+ const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
171
+ if (prompt.length > 0) {
172
+ let label = prompt.slice(0, 45).replace(/\s+\S*$/, '');
173
+ if (prompt.length > label.length) label += '...';
174
+ sessions[sid].label = label;
175
+ updateSessionBar();
176
+ updateFilterSession();
177
+ }
178
+ }
179
+ }
180
+
181
+ function sessionDisplayName(id) {
182
+ const s = sessions[id];
183
+ if (!s) return shortId(id);
184
+ if (s.label) {
185
+ return s.app ? s.app + ': ' + s.label : s.label;
186
+ }
187
+ if (s.app) return s.app;
188
+ return shortId(id);
189
+ }
190
+
191
+ function shortId(id) {
192
+ if (!id) return '—';
193
+ return id.length > 10 ? id.slice(0, 8) + '..' : id;
194
+ }
195
+
196
+ function shortSession(id) {
197
+ return sessionDisplayName(id);
198
+ }
199
+
135
200
  function formatTime(ts) {
136
201
  const d = new Date(ts);
137
202
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
138
203
  }
139
204
 
140
- function shortSession(id) {
141
- if (!id) return '—';
142
- return id.length > 12 ? id.slice(0, 8) + '...' : id;
205
+ function timeSince(ts) {
206
+ const secs = Math.floor((Date.now() - ts) / 1000);
207
+ if (secs < 5) return 'just now';
208
+ if (secs < 60) return secs + 's ago';
209
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
210
+ return Math.floor(secs / 3600) + 'h ago';
143
211
  }
144
212
 
145
- // Detect if event needs human intervention
213
+ // Detect human intervention needed
146
214
  function needsHuman(ev) {
147
215
  const type = ev.event_type || ev.hook_event_name || '';
148
- // Permission requests always need human
149
216
  if (type === 'PermissionRequest') return true;
150
- // Notifications that are permission prompts
151
217
  if (type === 'Notification' && ev.notification_type === 'permission_prompt') return true;
152
- // Tool failures after multiple attempts
153
218
  if (type === 'PostToolUseFailure') return true;
154
- // Stuck indicators in messages
155
219
  if (ev.message && /stuck|blocked|escalat|need.*help|can.?t.*find/i.test(ev.message)) return true;
156
220
  return false;
157
221
  }
158
222
 
159
223
  function isFailure(ev) {
160
224
  const type = ev.event_type || ev.hook_event_name || '';
161
- return type.includes('Failure') || type === 'StopFailure';
225
+ return type.includes('Failure');
162
226
  }
163
227
 
164
228
  function isSessionBoundary(ev) {
@@ -166,7 +230,12 @@ function isSessionBoundary(ev) {
166
230
  return type === 'SessionStart' || type === 'SessionEnd' || type === 'Stop';
167
231
  }
168
232
 
169
- // Build rich detail string
233
+ function isUserPrompt(ev) {
234
+ const type = ev.event_type || ev.hook_event_name || '';
235
+ return type === 'UserPromptSubmit';
236
+ }
237
+
238
+ // Rich detail
170
239
  function eventDetail(ev) {
171
240
  const parts = [];
172
241
  const tool = ev.tool_name || '';
@@ -181,27 +250,29 @@ function eventDetail(ev) {
181
250
  if (input.content) parts.push(`(${input.content.length} chars)`);
182
251
  } else if (tool === 'Edit' && input.file_path) {
183
252
  parts.push(`<span class="filepath">${input.file_path}</span>`);
184
- if (input.old_string) parts.push(`replacing "${input.old_string.slice(0, 30)}..."`);
253
+ if (input.old_string) parts.push(`"${input.old_string.slice(0, 40)}..."`);
185
254
  } else if (tool === 'Bash' && input.command) {
186
- parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
187
- if (input.timeout) parts.push(`timeout:${input.timeout}ms`);
255
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 100)}</span>`);
188
256
  } else if (tool === 'Grep' && input.pattern) {
189
257
  parts.push(`pattern: "${input.pattern}"`);
190
258
  if (input.path) parts.push(`in <span class="filepath">${input.path}</span>`);
191
259
  } else if (tool === 'Glob' && input.pattern) {
192
260
  parts.push(`<span class="filepath">${input.pattern}</span>`);
193
261
  } else if (tool === 'Agent') {
194
- parts.push(input.description || input.prompt?.slice(0, 60) || 'subagent');
262
+ parts.push(input.description || input.prompt?.slice(0, 80) || 'subagent');
195
263
  } else if (tool === 'WebSearch' && input.query) {
196
- parts.push(`"${input.query.slice(0, 60)}"`);
264
+ parts.push(`"${input.query.slice(0, 80)}"`);
197
265
  } else if (tool === 'WebFetch' && input.url) {
198
- parts.push(`<span class="filepath">${input.url.slice(0, 60)}</span>`);
266
+ parts.push(`<span class="filepath">${input.url.slice(0, 80)}</span>`);
267
+ } else if (isUserPrompt(ev)) {
268
+ const prompt = input.prompt || input.content || ev.message || '';
269
+ parts.push(prompt.slice(0, 120));
199
270
  } else if (ev.message) {
200
- parts.push(ev.message.slice(0, 80));
271
+ parts.push(ev.message.slice(0, 100));
201
272
  } else if (input.file_path) {
202
273
  parts.push(`<span class="filepath">${input.file_path}</span>`);
203
274
  } else if (input.command) {
204
- parts.push(`<span class="cmd">$ ${input.command.slice(0, 60)}</span>`);
275
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
205
276
  } else if (ev.source_app) {
206
277
  parts.push(ev.source_app);
207
278
  }
@@ -217,18 +288,22 @@ function renderEvent(ev) {
217
288
  const human = needsHuman(ev);
218
289
  const fail = isFailure(ev);
219
290
  const boundary = isSessionBoundary(ev);
291
+ const prompt = isUserPrompt(ev);
220
292
 
221
293
  const div = document.createElement('div');
222
294
  let cls = 'event';
223
295
  if (human) cls += ' needs-human';
224
296
  else if (fail) cls += ' failure';
297
+ else if (prompt) cls += ' user-prompt';
225
298
  else if (boundary) cls += ' session-boundary';
226
299
  div.className = cls;
227
300
 
301
+ const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
302
+
228
303
  div.innerHTML = `
229
304
  <span class="time">${formatTime(ev._ts)}</span>
230
- <span class="type type-${type}">${type}</span>
231
- <span class="session" style="background:${color}22;color:${color}">${shortSession(sid)}</span>
305
+ <span class="type type-${type}">${type}${humanBadge}</span>
306
+ <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
232
307
  <span class="tool">${tool}</span>
233
308
  <span class="detail">${eventDetail(ev)}</span>
234
309
  `;
@@ -245,12 +320,38 @@ function renderEvent(ev) {
245
320
  function addEvent(ev) {
246
321
  events.push(ev);
247
322
  if (emptyState.parentNode) emptyState.remove();
248
- timeline.appendChild(renderEvent(ev));
323
+ const sid = ev.session_id || ev.source_app || 'unknown';
324
+ updateSessionLabel(sid, ev);
325
+
326
+ // Remove "latest" class from previous latest
327
+ if (lastEventDiv) lastEventDiv.classList.remove('latest');
328
+
329
+ const div = renderEvent(ev);
330
+ div.classList.add('latest');
331
+ lastEventDiv = div;
332
+ timeline.appendChild(div);
333
+
249
334
  updateStats();
250
335
  updateFilterType(ev);
251
- if (autoScroll) timeline.scrollTop = timeline.scrollHeight;
336
+ updateWaitingText(ev);
337
+
338
+ if (autoScroll) {
339
+ document.getElementById('timelineEnd').scrollIntoView({ behavior: 'smooth' });
340
+ }
252
341
  }
253
342
 
343
+ function updateWaitingText(ev) {
344
+ if (ev) {
345
+ const sid = ev.session_id || ev.source_app || 'unknown';
346
+ waitingText.textContent = `Last: ${sessionDisplayName(sid)} — ${timeSince(ev._ts)}`;
347
+ }
348
+ }
349
+
350
+ // Keep "time since" updated
351
+ setInterval(() => {
352
+ if (events.length > 0) updateWaitingText(events[events.length - 1]);
353
+ }, 5000);
354
+
254
355
  function updateStats() {
255
356
  document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
256
357
  document.getElementById('eventCount').textContent = events.length;
@@ -270,7 +371,8 @@ function updateSessionBar() {
270
371
  tag.className = 'session-tag' + (selectedSession === id ? ' active' : '');
271
372
  tag.style.background = s.color + '22';
272
373
  tag.style.color = s.color;
273
- tag.textContent = shortSession(id) + ' (' + s.count + ')';
374
+ tag.textContent = sessionDisplayName(id) + ' (' + s.count + ')';
375
+ tag.title = id;
274
376
  tag.onclick = () => { selectedSession = selectedSession === id ? '' : id; refilter(); updateSessionBar(); };
275
377
  sessionBar.appendChild(tag);
276
378
  }
@@ -291,7 +393,7 @@ function updateFilterSession() {
291
393
  filterSession.innerHTML = '<option value="">All sessions</option>';
292
394
  for (const id of Object.keys(sessions)) {
293
395
  const opt = document.createElement('option');
294
- opt.value = id; opt.textContent = shortSession(id);
396
+ opt.value = id; opt.textContent = sessionDisplayName(id);
295
397
  filterSession.appendChild(opt);
296
398
  }
297
399
  }
@@ -314,7 +416,7 @@ function refilter() {
314
416
  filterType.onchange = refilter;
315
417
  filterSession.onchange = refilter;
316
418
 
317
- function clearEvents() { events = []; sessions = {}; colorIdx = 0; timeline.innerHTML = ''; updateStats(); sessionBar.innerHTML = ''; }
419
+ function clearEvents() { events = []; sessions = {}; colorIdx = 0; timeline.innerHTML = ''; lastEventDiv = null; updateStats(); sessionBar.innerHTML = ''; }
318
420
  function toggleAutoScroll() {
319
421
  autoScroll = !autoScroll;
320
422
  document.getElementById('btnAutoScroll').classList.toggle('active', autoScroll);
@@ -325,7 +427,7 @@ function toggleHumanOnly() {
325
427
  refilter();
326
428
  }
327
429
 
328
- // Server-Sent Events connection
430
+ // Server-Sent Events
329
431
  function connect() {
330
432
  const source = new EventSource('/stream');
331
433