uv-suite 0.20.0 → 0.22.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.
@@ -1,24 +1,37 @@
1
1
  #!/bin/bash
2
2
  # UV Suite Hook Helper: Send event to Watchtower server
3
- # Called by other hooks. Non-blocking. Fails silently if server not running.
3
+ # Called by other hooks or directly from persona hook config.
4
+ # Non-blocking. Fails silently if server not running.
4
5
  #
5
- # Usage: echo "$INPUT" | .claude/hooks/watchtower-send.sh "EventType"
6
+ # Usage from hook config:
7
+ # "command": ".claude/hooks/watchtower-send.sh PostToolUse"
8
+ # The hook input JSON comes via stdin from Claude Code.
6
9
 
7
10
  EVENT_TYPE="${1:-Unknown}"
8
11
  INPUT=$(cat)
9
12
  WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
10
13
 
11
- # Extract useful fields from the hook input
12
- SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
13
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
14
- TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null)
15
- CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
16
- SOURCE_APP=$(basename "$CWD" 2>/dev/null)
14
+ # Forward the full hook input to the watchtower, adding event_type and source_app
15
+ # jq merges the original JSON with our extra fields
16
+ PAYLOAD=$(echo "$INPUT" | jq -c ". + {
17
+ event_type: \"$EVENT_TYPE\",
18
+ source_app: (.cwd // \"\" | split(\"/\") | last),
19
+ _hook_ts: now
20
+ }" 2>/dev/null)
21
+
22
+ # Fallback if jq isn't available or fails
23
+ if [ -z "$PAYLOAD" ] || [ "$PAYLOAD" = "null" ]; then
24
+ SESSION_ID=$(echo "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | cut -d'"' -f4)
25
+ TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
26
+ CWD=$(echo "$INPUT" | grep -o '"cwd":"[^"]*"' | head -1 | cut -d'"' -f4)
27
+ SOURCE_APP=$(basename "$CWD" 2>/dev/null)
28
+ PAYLOAD="{\"event_type\":\"$EVENT_TYPE\",\"session_id\":\"$SESSION_ID\",\"source_app\":\"$SOURCE_APP\",\"tool_name\":\"$TOOL_NAME\",\"cwd\":\"$CWD\"}"
29
+ fi
17
30
 
18
31
  # Send to watchtower (non-blocking, fire-and-forget)
19
32
  curl -s -X POST "$WATCHTOWER_URL/events" \
20
33
  -H "Content-Type: application/json" \
21
- -d "{\"event_type\":\"$EVENT_TYPE\",\"session_id\":\"$SESSION_ID\",\"source_app\":\"$SOURCE_APP\",\"tool_name\":\"$TOOL_NAME\",\"tool_input\":$TOOL_INPUT,\"cwd\":\"$CWD\"}" \
34
+ -d "$PAYLOAD" \
22
35
  &>/dev/null &
23
36
 
24
37
  exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.20.0",
3
+ "version": "0.22.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
  ],
@@ -23,20 +23,35 @@
23
23
  .stat { text-align: center; }
24
24
  .stat .n { font-size: 20px; font-weight: 600; }
25
25
  .stat .l { font-size: 10px; color: #86868b; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
26
+ .stat .n.alert { color: #ff375f; }
26
27
 
27
28
  .sessions { padding: 12px 24px; border-bottom: 1px solid #1d1d1f; display: flex; gap: 8px; flex-wrap: wrap; }
28
29
  .session-tag { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; cursor: pointer; border: 1px solid transparent; }
29
30
  .session-tag:hover { border-color: #424245; }
30
31
  .session-tag.active { border-color: #fff; }
31
32
 
32
- .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 220px); }
33
- .event { padding: 6px 24px; display: grid; grid-template-columns: 70px 100px 120px 1fr 80px; gap: 8px; align-items: center; font-size: 12px; border-bottom: 1px solid #0d0d0d; }
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; }
34
35
  .event:hover { background: #0d0d0d; }
35
36
  .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
36
37
  .event .type { font-weight: 500; }
37
38
  .event .session { font-size: 11px; border-radius: 8px; padding: 1px 8px; display: inline-block; }
38
- .event .detail { color: #a1a1a6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
39
- .event .duration { color: #6e6e73; font-family: 'SF Mono', monospace; font-size: 11px; text-align: right; }
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; }
41
+ .event .detail .filepath { color: #64d2ff; }
42
+ .event .detail .cmd { color: #ffd60a; }
43
+
44
+ /* Human intervention — pulsing highlight */
45
+ .event.needs-human { background: #ff375f12; border-left: 3px solid #ff375f; }
46
+ .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; }
49
+
50
+ /* Failure highlight */
51
+ .event.failure { background: #ff696112; border-left: 3px solid #ff6961; }
52
+
53
+ /* Session start/end highlight */
54
+ .event.session-boundary { background: #30d15808; }
40
55
 
41
56
  .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
42
57
  .empty p { margin-top: 8px; font-size: 13px; }
@@ -67,6 +82,7 @@
67
82
  <div class="stat"><div class="n" id="eventCount">0</div><div class="l">Events</div></div>
68
83
  <div class="stat"><div class="n" id="toolCount">0</div><div class="l">Tool calls</div></div>
69
84
  <div class="stat"><div class="n" id="errorCount">0</div><div class="l">Errors</div></div>
85
+ <div class="stat"><div class="n" id="humanCount">0</div><div class="l">Need human</div></div>
70
86
  </div>
71
87
 
72
88
  <div class="sessions" id="sessionBar"></div>
@@ -74,6 +90,7 @@
74
90
  <div class="filters">
75
91
  <select id="filterType"><option value="">All events</option></select>
76
92
  <select id="filterSession"><option value="">All sessions</option></select>
93
+ <button id="btnHumanOnly" onclick="toggleHumanOnly()">Human needed</button>
77
94
  <button id="btnClear" onclick="clearEvents()">Clear</button>
78
95
  <button id="btnAutoScroll" class="active" onclick="toggleAutoScroll()">Auto-scroll</button>
79
96
  </div>
@@ -97,16 +114,16 @@ const sessionBar = document.getElementById('sessionBar');
97
114
  let events = [];
98
115
  let sessions = {};
99
116
  let autoScroll = true;
117
+ let humanOnly = false;
100
118
  let selectedSession = '';
101
119
  let selectedType = '';
102
- let ws;
103
120
 
104
- // Session colors
121
+ // Session colors and naming
105
122
  const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
106
123
  let colorIdx = 0;
107
124
  function sessionColor(id) {
108
125
  if (!sessions[id]) {
109
- sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null };
126
+ sessions[id] = { color: palette[colorIdx++ % palette.length], count: 0, lastEvent: null, label: null, app: null };
110
127
  updateSessionBar();
111
128
  updateFilterSession();
112
129
  }
@@ -115,41 +132,145 @@ function sessionColor(id) {
115
132
  return sessions[id].color;
116
133
  }
117
134
 
135
+ // Build a meaningful session label from the first user prompt or app name
136
+ function updateSessionLabel(sid, ev) {
137
+ if (!sessions[sid]) return;
138
+ // Capture source_app as fallback name
139
+ if (!sessions[sid].app && ev.source_app) {
140
+ sessions[sid].app = ev.source_app;
141
+ }
142
+ // Use first UserPromptSubmit content as the session label
143
+ if (!sessions[sid].label && ev.event_type === 'UserPromptSubmit') {
144
+ const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
145
+ if (prompt.length > 0) {
146
+ // Take first 40 chars, trim to last word boundary
147
+ let label = prompt.slice(0, 40).replace(/\s+\S*$/, '');
148
+ if (prompt.length > label.length) label += '...';
149
+ sessions[sid].label = label;
150
+ updateSessionBar();
151
+ updateFilterSession();
152
+ }
153
+ }
154
+ }
155
+
156
+ function sessionDisplayName(id) {
157
+ const s = sessions[id];
158
+ if (!s) return shortId(id);
159
+ if (s.label) return s.label;
160
+ if (s.app) return s.app;
161
+ return shortId(id);
162
+ }
163
+
164
+ function shortId(id) {
165
+ if (!id) return '—';
166
+ return id.length > 10 ? id.slice(0, 8) + '..' : id;
167
+ }
168
+
118
169
  function formatTime(ts) {
119
170
  const d = new Date(ts);
120
171
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
121
172
  }
122
173
 
123
174
  function shortSession(id) {
124
- if (!id) return '—';
125
- return id.length > 12 ? id.slice(0, 8) + '...' : id;
175
+ return sessionDisplayName(id);
126
176
  }
127
177
 
178
+ // Detect if event needs human intervention
179
+ function needsHuman(ev) {
180
+ const type = ev.event_type || ev.hook_event_name || '';
181
+ // Permission requests always need human
182
+ if (type === 'PermissionRequest') return true;
183
+ // Notifications that are permission prompts
184
+ if (type === 'Notification' && ev.notification_type === 'permission_prompt') return true;
185
+ // Tool failures after multiple attempts
186
+ if (type === 'PostToolUseFailure') return true;
187
+ // Stuck indicators in messages
188
+ if (ev.message && /stuck|blocked|escalat|need.*help|can.?t.*find/i.test(ev.message)) return true;
189
+ return false;
190
+ }
191
+
192
+ function isFailure(ev) {
193
+ const type = ev.event_type || ev.hook_event_name || '';
194
+ return type.includes('Failure') || type === 'StopFailure';
195
+ }
196
+
197
+ function isSessionBoundary(ev) {
198
+ const type = ev.event_type || ev.hook_event_name || '';
199
+ return type === 'SessionStart' || type === 'SessionEnd' || type === 'Stop';
200
+ }
201
+
202
+ // Build rich detail string
128
203
  function eventDetail(ev) {
129
- if (ev.tool_name) return ev.tool_name + (ev.tool_input?.command ? ': ' + ev.tool_input.command.slice(0, 60) : '');
130
- if (ev.tool_input?.file_path) return ev.tool_input.file_path;
131
- if (ev.source_app) return ev.source_app;
132
- if (ev.message) return ev.message;
133
- return '';
204
+ const parts = [];
205
+ const tool = ev.tool_name || '';
206
+ const input = ev.tool_input || {};
207
+
208
+ if (tool === 'Read' && input.file_path) {
209
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
210
+ if (input.offset) parts.push(`offset:${input.offset}`);
211
+ if (input.limit) parts.push(`limit:${input.limit}`);
212
+ } else if (tool === 'Write' && input.file_path) {
213
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
214
+ if (input.content) parts.push(`(${input.content.length} chars)`);
215
+ } else if (tool === 'Edit' && input.file_path) {
216
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
217
+ if (input.old_string) parts.push(`replacing "${input.old_string.slice(0, 30)}..."`);
218
+ } else if (tool === 'Bash' && input.command) {
219
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
220
+ if (input.timeout) parts.push(`timeout:${input.timeout}ms`);
221
+ } else if (tool === 'Grep' && input.pattern) {
222
+ parts.push(`pattern: "${input.pattern}"`);
223
+ if (input.path) parts.push(`in <span class="filepath">${input.path}</span>`);
224
+ } else if (tool === 'Glob' && input.pattern) {
225
+ parts.push(`<span class="filepath">${input.pattern}</span>`);
226
+ } else if (tool === 'Agent') {
227
+ parts.push(input.description || input.prompt?.slice(0, 60) || 'subagent');
228
+ } else if (tool === 'WebSearch' && input.query) {
229
+ parts.push(`"${input.query.slice(0, 60)}"`);
230
+ } else if (tool === 'WebFetch' && input.url) {
231
+ parts.push(`<span class="filepath">${input.url.slice(0, 60)}</span>`);
232
+ } else if (ev.message) {
233
+ parts.push(ev.message.slice(0, 80));
234
+ } else if (input.file_path) {
235
+ parts.push(`<span class="filepath">${input.file_path}</span>`);
236
+ } else if (input.command) {
237
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 60)}</span>`);
238
+ } else if (ev.source_app) {
239
+ parts.push(ev.source_app);
240
+ }
241
+
242
+ return parts.join(' ');
134
243
  }
135
244
 
136
245
  function renderEvent(ev) {
137
246
  const sid = ev.session_id || ev.source_app || 'unknown';
138
247
  const color = sessionColor(sid);
248
+ const type = ev.event_type || ev.hook_event_name || '?';
249
+ const tool = ev.tool_name || '';
250
+ const human = needsHuman(ev);
251
+ const fail = isFailure(ev);
252
+ const boundary = isSessionBoundary(ev);
253
+
139
254
  const div = document.createElement('div');
140
- div.className = 'event';
255
+ let cls = 'event';
256
+ if (human) cls += ' needs-human';
257
+ else if (fail) cls += ' failure';
258
+ else if (boundary) cls += ' session-boundary';
259
+ div.className = cls;
260
+
141
261
  div.innerHTML = `
142
262
  <span class="time">${formatTime(ev._ts)}</span>
143
- <span class="type type-${ev.event_type || ev.hook_event_name || ''}">${ev.event_type || ev.hook_event_name || '?'}</span>
263
+ <span class="type type-${type}">${type}</span>
144
264
  <span class="session" style="background:${color}22;color:${color}">${shortSession(sid)}</span>
145
- <span class="detail" title="${eventDetail(ev)}">${eventDetail(ev)}</span>
146
- <span class="duration">${ev.duration_ms ? ev.duration_ms + 'ms' : ''}</span>
265
+ <span class="tool">${tool}</span>
266
+ <span class="detail">${eventDetail(ev)}</span>
147
267
  `;
148
268
 
149
269
  // Check filters
150
- const typeMatch = !selectedType || (ev.event_type || ev.hook_event_name) === selectedType;
270
+ const typeMatch = !selectedType || type === selectedType;
151
271
  const sessMatch = !selectedSession || sid === selectedSession;
152
- if (!typeMatch || !sessMatch) div.style.display = 'none';
272
+ const humanMatch = !humanOnly || human;
273
+ if (!typeMatch || !sessMatch || !humanMatch) div.style.display = 'none';
153
274
 
154
275
  return div;
155
276
  }
@@ -157,6 +278,8 @@ function renderEvent(ev) {
157
278
  function addEvent(ev) {
158
279
  events.push(ev);
159
280
  if (emptyState.parentNode) emptyState.remove();
281
+ const sid = ev.session_id || ev.source_app || 'unknown';
282
+ updateSessionLabel(sid, ev);
160
283
  timeline.appendChild(renderEvent(ev));
161
284
  updateStats();
162
285
  updateFilterType(ev);
@@ -167,7 +290,12 @@ function updateStats() {
167
290
  document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
168
291
  document.getElementById('eventCount').textContent = events.length;
169
292
  document.getElementById('toolCount').textContent = events.filter(e => (e.event_type || e.hook_event_name || '').includes('ToolUse')).length;
170
- document.getElementById('errorCount').textContent = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
293
+ const errors = events.filter(e => (e.event_type || e.hook_event_name || '').includes('Failure')).length;
294
+ document.getElementById('errorCount').textContent = errors;
295
+ document.getElementById('errorCount').className = 'n' + (errors > 0 ? ' alert' : '');
296
+ const humans = events.filter(needsHuman).length;
297
+ document.getElementById('humanCount').textContent = humans;
298
+ document.getElementById('humanCount').className = 'n' + (humans > 0 ? ' alert' : '');
171
299
  }
172
300
 
173
301
  function updateSessionBar() {
@@ -211,7 +339,9 @@ function refilter() {
211
339
  const ev = events[i];
212
340
  const sid = ev.session_id || ev.source_app || 'unknown';
213
341
  const type = ev.event_type || ev.hook_event_name || '';
214
- const show = (!selectedType || type === selectedType) && (!selectedSession || sid === selectedSession);
342
+ const show = (!selectedType || type === selectedType)
343
+ && (!selectedSession || sid === selectedSession)
344
+ && (!humanOnly || needsHuman(ev));
215
345
  row.style.display = show ? '' : 'none';
216
346
  });
217
347
  }
@@ -224,8 +354,13 @@ function toggleAutoScroll() {
224
354
  autoScroll = !autoScroll;
225
355
  document.getElementById('btnAutoScroll').classList.toggle('active', autoScroll);
226
356
  }
357
+ function toggleHumanOnly() {
358
+ humanOnly = !humanOnly;
359
+ document.getElementById('btnHumanOnly').classList.toggle('active', humanOnly);
360
+ refilter();
361
+ }
227
362
 
228
- // Server-Sent Events connection (replaces WebSocket — simpler, auto-reconnects)
363
+ // Server-Sent Events connection
229
364
  function connect() {
230
365
  const source = new EventSource('/stream');
231
366
 
@@ -237,7 +372,6 @@ function connect() {
237
372
  source.onerror = () => {
238
373
  statusDot.className = 'dot off';
239
374
  statusText.textContent = 'Reconnecting...';
240
- // EventSource auto-reconnects — no manual retry needed
241
375
  };
242
376
 
243
377
  source.onmessage = (msg) => {
@@ -248,9 +382,7 @@ function connect() {
248
382
  } else {
249
383
  addEvent(data);
250
384
  }
251
- } catch (e) {
252
- // ignore parse errors (ping frames, etc.)
253
- }
385
+ } catch (e) {}
254
386
  };
255
387
  }
256
388