uv-suite 0.30.0 → 0.32.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.
Files changed (42) hide show
  1. package/README.md +14 -11
  2. package/bin/cli.js +124 -54
  3. package/hooks/uv-out-notify.sh +19 -12
  4. package/hooks/watchtower-end.sh +23 -0
  5. package/hooks/watchtower-notify.sh +11 -0
  6. package/hooks/watchtower-send.sh +6 -3
  7. package/hooks/watchtower-tokens.sh +61 -0
  8. package/package.json +6 -3
  9. package/personas/auto.json +24 -0
  10. package/personas/professional.json +24 -0
  11. package/personas/spike.json +24 -0
  12. package/personas/sport.json +24 -0
  13. package/uv.sh +1 -1
  14. package/watchtower/README.md +13 -18
  15. package/watchtower/app/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/watchtower/app/__pycache__/db.cpython-312.pyc +0 -0
  17. package/watchtower/app/__pycache__/main.cpython-312.pyc +0 -0
  18. package/watchtower/app/__pycache__/models.cpython-312.pyc +0 -0
  19. package/watchtower/app/db.py +95 -51
  20. package/watchtower/app/main.py +4 -6
  21. package/watchtower/app/models.py +5 -0
  22. package/watchtower/app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
  23. package/watchtower/app/routers/__pycache__/control.cpython-312.pyc +0 -0
  24. package/watchtower/app/routers/__pycache__/ingest.cpython-312.pyc +0 -0
  25. package/watchtower/app/routers/__pycache__/query.cpython-312.pyc +0 -0
  26. package/watchtower/app/routers/__pycache__/settings.cpython-312.pyc +0 -0
  27. package/watchtower/app/routers/__pycache__/stream.cpython-312.pyc +0 -0
  28. package/watchtower/app/routers/control.py +174 -58
  29. package/watchtower/app/routers/ingest.py +101 -46
  30. package/watchtower/app/routers/query.py +77 -28
  31. package/watchtower/app/routers/settings.py +34 -0
  32. package/watchtower/app/routers/stream.py +3 -5
  33. package/watchtower/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/watchtower/app/services/__pycache__/checkpoint.cpython-312.pyc +0 -0
  35. package/watchtower/app/services/__pycache__/tmux.cpython-312.pyc +0 -0
  36. package/watchtower/app/services/checkpoint.py +64 -22
  37. package/watchtower/requirements.txt +1 -1
  38. package/watchtower/static/dashboard.html +427 -299
  39. package/watchtower/watchtower.db +0 -0
  40. package/watchtower/Dockerfile +0 -9
  41. package/watchtower/docker-compose.yml +0 -22
  42. package/watchtower/schema.sql +0 -43
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # UV Suite
2
2
 
3
- A portable layer that turns Claude Code, Cursor, or Codex into a labeled, observable, anti-slop dev environment — with named sessions, a real-time observability dashboard, anti-slop guardrails, and per-session memory across launches.
3
+ A portable layer that turns Claude Code, Cursor, or Codex into a labeled, observable, anti-slop dev environment — with named sessions, a real-time observability **and control** dashboard, anti-slop guardrails, and per-session memory across launches.
4
+
5
+ By [Utsav](https://www.utsava.xyz/) · [github.com/utsavanand/uv-suite](https://github.com/utsavanand/uv-suite)
4
6
 
5
7
  ## Install
6
8
 
@@ -24,7 +26,7 @@ uvs claude pro # Claude Code, Professional persona
24
26
  uvs codex auto # Codex, Auto persona
25
27
  uvs pro # Shorthand for uvs claude pro
26
28
  uvs install # Explicit install (also runs automatically on launch)
27
- uvs watch # Open the Watchtower observability dashboard
29
+ uvs watch # Open the Watchtower observability + control dashboard
28
30
  uvs info # Show what's installed
29
31
  ```
30
32
 
@@ -69,22 +71,19 @@ Picking a persona (Spike / Sport / Professional / Auto) is a separate axis from
69
71
  Each `uvs` launch generates a `UVS_SESSION_ID` and writes metadata to `.uv-suite-state/sessions/<id>.json`. This unlocks:
70
72
 
71
73
  - **Concurrent terminals don't collide.** Two `uvs` launches in the same repo run as distinct sessions with separate names, checkpoints, and dashboard rows.
72
- - **`uvs watch` shows them all.** The Watchtower dashboard at `localhost:4200` streams every tool call across every session in real time labeled by your name, sorted by priority (high to top, low dimmed), color-coded by persona.
74
+ - **`uvs watch` shows them all.** The Watchtower control plane at `localhost:4200` streams every session live and lets you act on them from the browser see [Watchtower at a glance](#watchtower-at-a-glance).
73
75
  - **Per-session checkpoints.** `/session checkpoint` writes to `uv-out/checkpoints/<sid>/`, and `/session restore` auto-picks the current session's latest. Pass a session id prefix or name to restore from a different one.
74
76
  - **Status line shows it all.** The Claude Code status bar shows session name, persona, priority, and elapsed time continuously.
75
77
 
76
78
  ### Watchtower at a glance
77
79
 
78
- ```
79
- Sessions Events Tool calls Errors Need human
80
- 4 1,247 914 2 0
80
+ `uvs watch` starts the dashboard at `localhost:4200` — Python + **embedded SQLite**, no Docker and no database to set up (it provisions its own deps on first run). It's a control plane, not just a viewer, laid out in three panes:
81
81
 
82
- [payments retry [auto] [P:high] [outcome] ] (147)
83
- [infra cleanup [pro] [P:med] [long-running] ] (382)
84
- [exec deck [spike] [P:low] [outcome] ] (89)
85
- ```
82
+ - **Heartbeat** (left) — a live, scrolling stream of what every agent is doing, as it happens.
83
+ - **Sessions** (center) — each session as a flat row (state · tokens · tool calls · last activity). Filter by time / priority / kind and search by name; expand a row to **checkpoint, view checkpoint history, compact, fork, close, or delete** it.
84
+ - **Needs human** (right) — sessions waiting on you (a tool-permission prompt or an idle wait), with the tool + command as context. **Approve / Deny** from the browser; for `uvs`-launched (tmux-owned) sessions the keystroke is sent for you.
86
85
 
87
- Hooks fire on every Claude Code event (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `SessionStart`, `Stop`, `PermissionRequest`, ...) and forward to the dashboard with the session metadata merged in. Zero dependencies vanilla Node + SSE.
86
+ Sessions launched via `uvs` run inside a transparent tmux so Watchtower can act on them. Hooks forward every Claude Code event (`PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Notification`, `SessionStart`, `Stop`, ...) with session metadata merged in. A Node-only fallback (no Python) is available via `uvs watch --legacy`.
88
87
 
89
88
  ## Personas
90
89
 
@@ -241,6 +240,10 @@ uv-out/ Agent output artifacts (gitignored)
241
240
  | [research/tool-comparison.md](research/tool-comparison.md) | Claude Code vs Cursor vs Codex — how UV Suite works across all three |
242
241
  | [research/best-practices.md](research/best-practices.md) | Subagent patterns, remote sessions, sharing with engineers, cost optimization |
243
242
 
243
+ ## Author
244
+
245
+ Built by [Utsav](https://www.utsava.xyz/) — [utsava.xyz](https://www.utsava.xyz/).
246
+
244
247
  ## License
245
248
 
246
249
  MIT
package/bin/cli.js CHANGED
@@ -34,7 +34,7 @@ function usage() {
34
34
  Monitoring:
35
35
  uvs watch Start Watchtower dashboard (open browser)
36
36
  uvs watch --bg Start Watchtower in background
37
- uvs watch --legacy Start the legacy Node Watchtower (no Docker/Postgres)
37
+ uvs watch --legacy Start the legacy Node Watchtower
38
38
 
39
39
  Personas:
40
40
  spike Research & docs (Opus, max effort)
@@ -183,19 +183,19 @@ function prompt(rl, question) {
183
183
  return new Promise((resolve) => rl.question(question, resolve));
184
184
  }
185
185
 
186
- function normalizeKind(s) {
187
- const v = (s || "").toLowerCase().trim();
188
- if (["l", "long", "long-running"].includes(v)) return "long-running";
189
- if (["o", "outcome"].includes(v)) return "outcome";
190
- return "";
191
- }
192
-
193
- function normalizePriority(s) {
194
- const v = (s || "").toLowerCase().trim();
195
- if (["l", "low"].includes(v)) return "low";
196
- if (["m", "med", "medium"].includes(v)) return "med";
197
- if (["h", "high"].includes(v)) return "high";
198
- return "";
186
+ // Prompt for one of a fixed set of values (enum). Accepts the number, the full
187
+ // value, or a unique prefix; Enter skips (returns ""). Re-prompts on invalid input.
188
+ async function promptChoice(rl, label, options) {
189
+ const menu = options.map((o, i) => `${i + 1}=${o}`).join(" ");
190
+ for (;;) {
191
+ const raw = (await prompt(rl, `${label} (${menu}, Enter to skip): `)).trim().toLowerCase();
192
+ if (!raw) return "";
193
+ const n = parseInt(raw, 10);
194
+ if (n >= 1 && n <= options.length) return options[n - 1];
195
+ const matches = options.filter((o) => o === raw || o.startsWith(raw));
196
+ if (matches.length === 1) return matches[0];
197
+ console.log(` ? pick one of: ${options.join(", ")} (or its number)`);
198
+ }
199
199
  }
200
200
 
201
201
  // Generate a UVS_SESSION_ID, prompt for metadata (name/kind/purpose/priority),
@@ -221,13 +221,11 @@ async function setupSession(persona) {
221
221
  });
222
222
  console.log("");
223
223
  console.log("Label this session (Enter to skip — you'll be reminded):");
224
- name = (await prompt(rl, " name: ")).trim();
225
- const kindRaw = await prompt(rl, " kind [long/outcome]: ");
226
- purpose = (await prompt(rl, " purpose: ")).trim();
227
- const priorityRaw = await prompt(rl, " priority [low/med/high]: ");
224
+ name = (await prompt(rl, " name: ")).trim();
225
+ kind = await promptChoice(rl, " kind", ["long-running", "outcome"]);
226
+ purpose = (await prompt(rl, " purpose: ")).trim();
227
+ priority = await promptChoice(rl, " priority", ["low", "med", "high"]);
228
228
  rl.close();
229
- kind = normalizeKind(kindRaw);
230
- priority = normalizePriority(priorityRaw);
231
229
  }
232
230
 
233
231
  const meta = {
@@ -254,6 +252,47 @@ function ensureInstalled(persona) {
254
252
  syncPackageFiles(persona);
255
253
  }
256
254
 
255
+ // Launch `tool` so Watchtower can control it: wrap it in a transparent tmux session
256
+ // (dedicated socket `uvs`) and register the handle, so the dashboard can checkpoint /
257
+ // close / approve it. Falls back to a direct spawn when tmux is unavailable, UVS_NO_TMUX
258
+ // is set, or we're already inside the wrapper.
259
+ function launchWrapped(tool, toolArgs, sid) {
260
+ const { spawnSync } = require("child_process");
261
+ const env = { ...process.env, UVS_SESSION_ID: sid };
262
+ const canTmux =
263
+ !process.env.UVS_NO_TMUX &&
264
+ !process.env.UVS_IN_TMUX &&
265
+ spawnSync("tmux", ["-V"], { stdio: "ignore" }).status === 0;
266
+
267
+ if (canTmux) {
268
+ const tname = "uvs_" + sid;
269
+ const wtUrl = process.env.UVS_WATCHTOWER_URL || "http://localhost:4200";
270
+ const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
271
+ const inner =
272
+ "UVS_SESSION_ID=" + shq(sid) + " UVS_IN_TMUX=1 exec " + [tool, ...toolArgs].map(shq).join(" ");
273
+ const mk = spawnSync(
274
+ "tmux", ["-L", "uvs", "new-session", "-d", "-s", tname, "-c", process.cwd(), inner],
275
+ { stdio: "ignore" },
276
+ );
277
+ if (mk.status === 0) {
278
+ spawnSync("tmux", ["-L", "uvs", "set", "-t", tname, "status", "off"], { stdio: "ignore" });
279
+ spawnSync(
280
+ "curl", ["-s", "-m", "2", wtUrl + "/sessions/register",
281
+ "-H", "Content-Type: application/json",
282
+ "-d", JSON.stringify({ id: sid, tmux_target: tname, pid: process.pid, cwd: process.cwd() })],
283
+ { stdio: "ignore" },
284
+ );
285
+ const att = spawn("tmux", ["-L", "uvs", "attach", "-t", tname], { stdio: "inherit" });
286
+ att.on("exit", (code) => process.exit(code || 0));
287
+ return;
288
+ }
289
+ // tmux new-session failed — fall through to a direct spawn.
290
+ }
291
+
292
+ const child = spawn(tool, toolArgs, { stdio: "inherit", env });
293
+ child.on("exit", (code) => process.exit(code || 0));
294
+ }
295
+
257
296
  async function launchClaude(persona, extra) {
258
297
  syncPackageFiles(persona);
259
298
  const settings = path.resolve(".claude/personas", `${persona}.json`);
@@ -268,11 +307,7 @@ async function launchClaude(persona, extra) {
268
307
  console.log(`UV Suite | Claude Code | ${personaLabel(persona)}`);
269
308
  console.log(`Session: ${sid.slice(0, 8)}${name ? " — " + name : ""}`);
270
309
  console.log("");
271
- const child = spawn("claude", ["--settings", settings, ...extra], {
272
- stdio: "inherit",
273
- env: { ...process.env, UVS_SESSION_ID: sid },
274
- });
275
- child.on("exit", (code) => process.exit(code || 0));
310
+ launchWrapped("claude", ["--settings", settings, ...extra], sid);
276
311
  }
277
312
 
278
313
  async function launchCodex(persona, extra) {
@@ -288,11 +323,55 @@ async function launchCodex(persona, extra) {
288
323
  console.log(`UV Suite | Codex | ${personaLabel(persona)}`);
289
324
  console.log(`Session: ${sid.slice(0, 8)}${name ? " — " + name : ""}`);
290
325
  console.log("");
291
- const child = spawn("codex", [...codexArgs, ...extra], {
292
- stdio: "inherit",
293
- env: { ...process.env, UVS_SESSION_ID: sid },
294
- });
295
- child.on("exit", (code) => process.exit(code || 0));
326
+ launchWrapped("codex", [...codexArgs, ...extra], sid);
327
+ }
328
+
329
+ // Resolve how to run the Python app, provisioning deps on first run.
330
+ // Prefers `uv` (astral) if present; otherwise a venv at watchtower/.venv.
331
+ // Returns the argv prefix to which we append `uvicorn` args.
332
+ function ensurePyEnv(wtDir) {
333
+ const { spawnSync } = require("child_process");
334
+ const hasUv = spawnSync("uv", ["--version"], { stdio: "ignore" }).status === 0;
335
+ if (hasUv) {
336
+ // --native-tls: use the OS cert store so corporate SSL-inspection proxies don't break pypi.
337
+ return ["uv", "run", "--native-tls", "--python", "3.12", "--no-project", "--with-requirements", "requirements.txt", "--", "python", "-m"];
338
+ }
339
+ const venv = path.join(wtDir, ".venv");
340
+ const py =
341
+ process.platform === "win32"
342
+ ? path.join(venv, "Scripts", "python.exe")
343
+ : path.join(venv, "bin", "python");
344
+ if (!fs.existsSync(py)) {
345
+ const python3 = spawnSync("python3", ["--version"], { stdio: "ignore" }).status === 0 ? "python3" : "python";
346
+ console.log("First run: creating Python env in watchtower/.venv (one-time)...");
347
+ if (spawnSync(python3, ["-m", "venv", ".venv"], { cwd: wtDir, stdio: "inherit" }).status !== 0) {
348
+ console.error("Failed to create venv. Install Python 3 (python3) and retry.");
349
+ process.exit(1);
350
+ }
351
+ console.log("Installing dependencies (fastapi, uvicorn, aiosqlite)...");
352
+ if (spawnSync(py, ["-m", "pip", "install", "-q", "-r", "requirements.txt"], { cwd: wtDir, stdio: "inherit" }).status !== 0) {
353
+ console.error("pip install failed (see output above).");
354
+ process.exit(1);
355
+ }
356
+ }
357
+ return [py, "-m"];
358
+ }
359
+
360
+ // If a Watchtower is already running on this port, stop it so we start fresh —
361
+ // uvicorn doesn't hot-reload, so a long-running process serves stale routes while
362
+ // the (disk-served) dashboard updates. Only kills a process that answers /health
363
+ // like a Watchtower, so we never kill an unrelated app on the port.
364
+ function restartIfRunning(port) {
365
+ const { spawnSync } = require("child_process");
366
+ if (process.platform === "win32") return;
367
+ const health = spawnSync("curl", ["-s", "-m", "1", `http://localhost:${port}/health`], { encoding: "utf8" });
368
+ if (!(health.stdout || "").includes("status")) return;
369
+ const pids = (spawnSync("lsof", ["-ti", `tcp:${port}`], { encoding: "utf8" }).stdout || "")
370
+ .trim().split(/\s+/).filter(Boolean);
371
+ if (!pids.length) return;
372
+ spawnSync("kill", pids);
373
+ console.log(`Restarting Watchtower (stopped existing PID ${pids.join(", ")})`);
374
+ spawnSync("sleep", ["1"]); // let the port free up before rebinding
296
375
  }
297
376
 
298
377
  function watch() {
@@ -322,35 +401,26 @@ function watch() {
322
401
  }
323
402
  return;
324
403
  }
325
- if (!fs.existsSync(path.join(wtDir, "docker-compose.yml"))) {
326
- console.error("Error: watchtower compose not found at", wtDir);
327
- process.exit(1);
328
- }
329
404
  const bg = args.includes("--bg") || args.includes("--background");
330
- const url = "http://localhost:" + (process.env.UVS_WATCHTOWER_PORT || 4200);
405
+ const port = process.env.UVS_WATCHTOWER_PORT || 4200;
406
+ const url = "http://localhost:" + port;
331
407
  const opener =
332
408
  process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
333
- console.log("UV Suite Watchtower (Python + Postgres) starting via docker compose...");
409
+
410
+ // Python + SQLite, run locally (no Docker, no database to install).
411
+ restartIfRunning(port);
412
+ console.log("UV Suite Watchtower starting...");
334
413
  console.log("Dashboard: " + url);
335
414
  console.log("");
336
-
337
- // Bring the stack up (Postgres + FastAPI). Build is cached after first run.
338
- const up = spawn("docker", ["compose", "up", "--build", "-d"], { cwd: wtDir, stdio: "inherit" });
339
- up.on("exit", (code) => {
340
- if (code) {
341
- console.error("docker compose failed. Is Docker running? (the Python Watchtower needs it)");
342
- process.exit(code);
343
- }
344
- setTimeout(() => spawn(opener, [url], { stdio: "ignore" }), 1500);
345
- if (bg) {
346
- console.log("Watchtower running in background.");
347
- console.log("Stop with: (cd watchtower && docker compose down)");
348
- process.exit(0);
349
- }
350
- // Foreground: follow logs until Ctrl-C.
351
- const logs = spawn("docker", ["compose", "logs", "-f"], { cwd: wtDir, stdio: "inherit" });
352
- logs.on("exit", (c) => process.exit(c || 0));
353
- });
415
+ const argv = [...ensurePyEnv(wtDir), "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", String(port)];
416
+ setTimeout(() => spawn(opener, [url], { stdio: "ignore" }), 1500);
417
+ const child = spawn(argv[0], argv.slice(1), { cwd: wtDir, stdio: bg ? "ignore" : "inherit", detached: bg });
418
+ if (bg) {
419
+ child.unref();
420
+ console.log(`Running in background (PID: ${child.pid}). Stop with: kill ${child.pid}`);
421
+ } else {
422
+ child.on("exit", (code) => process.exit(code || 0));
423
+ }
354
424
  }
355
425
 
356
426
  // --- Parse and route ---
@@ -1,27 +1,34 @@
1
1
  #!/bin/bash
2
- # UV Suite Hook: Surface the uv-out/ artifact path after a skill run.
2
+ # UV Suite Hook: surface real skill artifacts written to uv-out/ after a run.
3
3
  # Event: Stop
4
- # When a UV Suite skill writes artifacts to uv-out/, the writing happens inside a
5
- # forked sub-agent whose transcript the user never sees. This hook runs when control
6
- # returns to the main loop and prints the path so the user knows where output landed.
4
+ # A skill (/spec, /review, /understand, …) writes artifacts inside a forked sub-agent
5
+ # whose transcript the user never sees; this prints the path when control returns.
6
+ #
7
+ # Scoped to the CURRENT session and excludes checkpoints — checkpoints are background
8
+ # state (surfaced by the dashboard and /session restore), and listing every session's
9
+ # checkpoints here produced cross-session noise.
7
10
 
8
11
  [ -d uv-out ] || exit 0
9
12
 
10
- # Files written in the ~2 min covering the run that just finished.
11
- RECENT=$(find uv-out -type f -mmin -2 2>/dev/null | sort)
13
+ SID="${UVS_SESSION_ID:-}"
14
+ [ -z "$SID" ] && [ -f .uv-suite-state/current-session.txt ] && SID=$(cat .uv-suite-state/current-session.txt 2>/dev/null)
15
+
16
+ # Recent files, minus checkpoints, minus other sessions (when we know our own).
17
+ RECENT=$(find uv-out -type f -mmin -2 2>/dev/null \
18
+ | grep -v '/checkpoints/' \
19
+ | awk -v sid="$SID" '
20
+ /^uv-out\/sessions\// { if (sid == "" || index($0, "uv-out/sessions/" sid "/") == 1) print; next }
21
+ { print }
22
+ ' \
23
+ | sort)
12
24
  [ -z "$RECENT" ] && exit 0
13
25
 
14
26
  LIST=$(echo "$RECENT" | sed 's/^/ /' | sed 's/$/\\n/' | tr -d '\n')
15
27
 
16
- # Name the session if these artifacts are session-scoped (uv-out/sessions/<sid>/...).
17
- SID=$(echo "$RECENT" | sed -n 's#^uv-out/sessions/\([^/]*\)/.*#\1#p' | head -1)
18
- HEADER="UV Suite output written to:"
19
- [ -n "$SID" ] && HEADER="UV Suite output (session ${SID}) written to:"
20
-
21
28
  cat <<EOF
22
29
  {
23
30
  "continue": true,
24
- "systemMessage": "${HEADER}\n${LIST}"
31
+ "systemMessage": "UV Suite output written to:\n${LIST}"
25
32
  }
26
33
  EOF
27
34
 
@@ -0,0 +1,23 @@
1
+ #!/bin/bash
2
+ # UV Suite Hook: tell Watchtower the session has ended (Event: SessionEnd).
3
+ # POSTs state=terminated so the dashboard stops showing it as active when the
4
+ # user exits the session directly (rather than via the dashboard Close button).
5
+ # Non-blocking; fails silently if Watchtower isn't running.
6
+
7
+ cat >/dev/null # drain stdin
8
+
9
+ WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
10
+ STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
11
+
12
+ SID="${UVS_SESSION_ID:-}"
13
+ if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
14
+ SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
15
+ fi
16
+ [ -z "$SID" ] && exit 0
17
+
18
+ curl -s -X POST "$WATCHTOWER_URL/sessions/$SID/state" \
19
+ -H "Content-Type: application/json" \
20
+ -d '{"state":"terminated"}' \
21
+ &>/dev/null &
22
+
23
+ exit 0
@@ -10,6 +10,17 @@
10
10
  INPUT=$(cat)
11
11
  WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
12
12
 
13
+ # Notification events carry a type; only surface ones that mean "human needed".
14
+ # (permission_prompt = tool approval, idle_prompt = waiting for input.) PermissionRequest
15
+ # events have no type, so they pass through.
16
+ if command -v jq >/dev/null 2>&1; then
17
+ NTYPE=$(echo "$INPUT" | jq -r '.type // ""' 2>/dev/null)
18
+ case "$NTYPE" in
19
+ ""|permission_prompt|idle_prompt) ;;
20
+ *) exit 0 ;;
21
+ esac
22
+ fi
23
+
13
24
  STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
14
25
 
15
26
  # Resolve UVS session id: env first, then current-session pointer
@@ -29,11 +29,11 @@ META_FILE=""
29
29
  PAYLOAD=""
30
30
  if command -v jq >/dev/null 2>&1; then
31
31
  if [ -n "$META_FILE" ] && [ -f "$META_FILE" ]; then
32
- PAYLOAD=$(echo "$INPUT" | jq -c --arg etype "$EVENT_TYPE" --slurpfile m "$META_FILE" '
32
+ PAYLOAD=$(echo "$INPUT" | jq -c --arg etype "$EVENT_TYPE" --arg sid "$SID" --slurpfile m "$META_FILE" '
33
33
  . + {
34
34
  event_type: $etype,
35
35
  source_app: (.cwd // "" | split("/") | last),
36
- uvs_session_id: ($m[0].uvs_session_id // ""),
36
+ uvs_session_id: ($m[0].uvs_session_id // $sid),
37
37
  session_name: ($m[0].name // ""),
38
38
  session_kind: ($m[0].kind // ""),
39
39
  session_purpose: ($m[0].purpose // ""),
@@ -46,10 +46,13 @@ if command -v jq >/dev/null 2>&1; then
46
46
  _hook_ts: now
47
47
  }' 2>/dev/null)
48
48
  else
49
- PAYLOAD=$(echo "$INPUT" | jq -c --arg etype "$EVENT_TYPE" '
49
+ # No metadata file (e.g. a forked session): still tag with the resolved UVS
50
+ # session id so events attribute to the right session, not Claude's internal id.
51
+ PAYLOAD=$(echo "$INPUT" | jq -c --arg etype "$EVENT_TYPE" --arg sid "$SID" '
50
52
  . + {
51
53
  event_type: $etype,
52
54
  source_app: (.cwd // "" | split("/") | last),
55
+ uvs_session_id: $sid,
53
56
  _hook_ts: now
54
57
  }' 2>/dev/null)
55
58
  fi
@@ -0,0 +1,61 @@
1
+ #!/bin/bash
2
+ # UV Suite Hook: report Claude Code token usage to Watchtower (Event: Stop).
3
+ # Parses the session transcript (transcript_path from the hook input), sums per-message
4
+ # token usage (input + cache + output), and POSTs the totals so the dashboard can show
5
+ # tokens used. Non-blocking; fails silently if Watchtower is down or there's no transcript.
6
+
7
+ INPUT=$(cat 2>/dev/null || true)
8
+ WATCHTOWER_URL="${UVS_WATCHTOWER_URL:-http://localhost:4200}"
9
+ STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
10
+
11
+ SID="${UVS_SESSION_ID:-}"
12
+ if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
13
+ SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
14
+ fi
15
+ [ -z "$SID" ] && exit 0
16
+
17
+ # Locate the transcript path inside the hook input.
18
+ TP=""
19
+ if command -v jq >/dev/null 2>&1; then
20
+ TP=$(printf '%s' "$INPUT" | jq -r '.transcript_path // ""' 2>/dev/null)
21
+ fi
22
+ [ -z "$TP" ] && TP=$(printf '%s' "$INPUT" | grep -o '"transcript_path":"[^"]*"' | head -1 | cut -d'"' -f4)
23
+ { [ -z "$TP" ] || [ ! -f "$TP" ]; } && exit 0
24
+
25
+ # Sum usage across assistant messages in the transcript (JSONL).
26
+ TOTALS=$(TP_VAL="$TP" python3 -c '
27
+ import json, os
28
+ inp = out = 0
29
+ try:
30
+ with open(os.environ["TP_VAL"]) as f:
31
+ for line in f:
32
+ line = line.strip()
33
+ if not line:
34
+ continue
35
+ try:
36
+ o = json.loads(line)
37
+ except Exception:
38
+ continue
39
+ u = (o.get("message") or {}).get("usage") or o.get("usage") or {}
40
+ if not isinstance(u, dict):
41
+ continue
42
+ inp += (u.get("input_tokens") or 0) \
43
+ + (u.get("cache_read_input_tokens") or 0) \
44
+ + (u.get("cache_creation_input_tokens") or 0)
45
+ out += (u.get("output_tokens") or 0)
46
+ except Exception:
47
+ pass
48
+ print(inp); print(out)
49
+ ' 2>/dev/null)
50
+
51
+ IN=$(echo "$TOTALS" | sed -n 1p)
52
+ OUT=$(echo "$TOTALS" | sed -n 2p)
53
+ [ -z "$IN" ] && exit 0
54
+ [ "$IN" = "0" ] && [ "$OUT" = "0" ] && exit 0
55
+
56
+ curl -s -X POST "$WATCHTOWER_URL/sessions/$SID/tokens" \
57
+ -H "Content-Type: application/json" \
58
+ -d "{\"input_tokens\":$IN,\"output_tokens\":$OUT}" \
59
+ &>/dev/null &
60
+
61
+ exit 0
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.30.0",
4
- "description": "Anti-slop guardrails, specialized agents, and a live observability + control plane for AI-assisted coding. 8 agents, 12 skills, 27 hooks, 6 guardrails, 4 personas; Watchtower (Python/Postgres) for monitoring + checkpoint/close/approve from the browser. Works with Claude Code, Cursor, and Codex.",
5
- "author": "Utsav Anand",
3
+ "version": "0.32.0",
4
+ "description": "Anti-slop guardrails, specialized agents, and a live observability + control plane for AI-assisted coding. Watchtower (Python + SQLite, no setup) shows a live heartbeat, your sessions, and a needs-human approval queue \u2014 checkpoint, compact, fork, close, or approve sessions from the browser. 8 agents, 12 skills, ~28 hooks, 6 guardrails, 4 personas. Works with Claude Code, Cursor, and Codex.",
5
+ "author": {
6
+ "name": "Utsav",
7
+ "url": "https://www.utsava.xyz/"
8
+ },
6
9
  "license": "MIT",
7
10
  "repository": {
8
11
  "type": "git",
@@ -146,6 +146,11 @@
146
146
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh Notification",
147
147
  "timeout": 2,
148
148
  "async": true
149
+ },
150
+ {
151
+ "type": "command",
152
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-notify.sh",
153
+ "timeout": 5
149
154
  }
150
155
  ]
151
156
  }
@@ -231,6 +236,25 @@
231
236
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/uv-out-notify.sh",
232
237
  "timeout": 5,
233
238
  "statusMessage": "Locating UV Suite output..."
239
+ },
240
+ {
241
+ "type": "command",
242
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-tokens.sh",
243
+ "timeout": 10,
244
+ "statusMessage": "Reporting token usage..."
245
+ }
246
+ ]
247
+ }
248
+ ],
249
+ "SessionEnd": [
250
+ {
251
+ "matcher": "*",
252
+ "hooks": [
253
+ {
254
+ "type": "command",
255
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-end.sh",
256
+ "timeout": 5,
257
+ "statusMessage": "Notifying Watchtower..."
234
258
  }
235
259
  ]
236
260
  }
@@ -158,6 +158,11 @@
158
158
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh Notification",
159
159
  "timeout": 2,
160
160
  "async": true
161
+ },
162
+ {
163
+ "type": "command",
164
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-notify.sh",
165
+ "timeout": 5
161
166
  }
162
167
  ]
163
168
  }
@@ -255,6 +260,25 @@
255
260
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/uv-out-notify.sh",
256
261
  "timeout": 5,
257
262
  "statusMessage": "Locating UV Suite output..."
263
+ },
264
+ {
265
+ "type": "command",
266
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-tokens.sh",
267
+ "timeout": 10,
268
+ "statusMessage": "Reporting token usage..."
269
+ }
270
+ ]
271
+ }
272
+ ],
273
+ "SessionEnd": [
274
+ {
275
+ "matcher": "*",
276
+ "hooks": [
277
+ {
278
+ "type": "command",
279
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-end.sh",
280
+ "timeout": 5,
281
+ "statusMessage": "Notifying Watchtower..."
258
282
  }
259
283
  ]
260
284
  }
@@ -136,6 +136,11 @@
136
136
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh Notification",
137
137
  "timeout": 2,
138
138
  "async": true
139
+ },
140
+ {
141
+ "type": "command",
142
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-notify.sh",
143
+ "timeout": 5
139
144
  }
140
145
  ]
141
146
  }
@@ -197,6 +202,25 @@
197
202
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/uv-out-notify.sh",
198
203
  "timeout": 5,
199
204
  "statusMessage": "Locating UV Suite output..."
205
+ },
206
+ {
207
+ "type": "command",
208
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-tokens.sh",
209
+ "timeout": 10,
210
+ "statusMessage": "Reporting token usage..."
211
+ }
212
+ ]
213
+ }
214
+ ],
215
+ "SessionEnd": [
216
+ {
217
+ "matcher": "*",
218
+ "hooks": [
219
+ {
220
+ "type": "command",
221
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-end.sh",
222
+ "timeout": 5,
223
+ "statusMessage": "Notifying Watchtower..."
200
224
  }
201
225
  ]
202
226
  }
@@ -132,6 +132,11 @@
132
132
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh Notification",
133
133
  "timeout": 2,
134
134
  "async": true
135
+ },
136
+ {
137
+ "type": "command",
138
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-notify.sh",
139
+ "timeout": 5
135
140
  }
136
141
  ]
137
142
  }
@@ -199,6 +204,25 @@
199
204
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/uv-out-notify.sh",
200
205
  "timeout": 5,
201
206
  "statusMessage": "Locating UV Suite output..."
207
+ },
208
+ {
209
+ "type": "command",
210
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-tokens.sh",
211
+ "timeout": 10,
212
+ "statusMessage": "Reporting token usage..."
213
+ }
214
+ ]
215
+ }
216
+ ],
217
+ "SessionEnd": [
218
+ {
219
+ "matcher": "*",
220
+ "hooks": [
221
+ {
222
+ "type": "command",
223
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-end.sh",
224
+ "timeout": 5,
225
+ "statusMessage": "Notifying Watchtower..."
202
226
  }
203
227
  ]
204
228
  }