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.
- package/README.md +14 -11
- package/bin/cli.js +124 -54
- package/hooks/uv-out-notify.sh +19 -12
- package/hooks/watchtower-end.sh +23 -0
- package/hooks/watchtower-notify.sh +11 -0
- package/hooks/watchtower-send.sh +6 -3
- package/hooks/watchtower-tokens.sh +61 -0
- package/package.json +6 -3
- package/personas/auto.json +24 -0
- package/personas/professional.json +24 -0
- package/personas/spike.json +24 -0
- package/personas/sport.json +24 -0
- package/uv.sh +1 -1
- package/watchtower/README.md +13 -18
- package/watchtower/app/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/db.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/main.cpython-312.pyc +0 -0
- package/watchtower/app/__pycache__/models.cpython-312.pyc +0 -0
- package/watchtower/app/db.py +95 -51
- package/watchtower/app/main.py +4 -6
- package/watchtower/app/models.py +5 -0
- package/watchtower/app/routers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/control.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/ingest.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/query.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/settings.cpython-312.pyc +0 -0
- package/watchtower/app/routers/__pycache__/stream.cpython-312.pyc +0 -0
- package/watchtower/app/routers/control.py +174 -58
- package/watchtower/app/routers/ingest.py +101 -46
- package/watchtower/app/routers/query.py +77 -28
- package/watchtower/app/routers/settings.py +34 -0
- package/watchtower/app/routers/stream.py +3 -5
- package/watchtower/app/services/__pycache__/__init__.cpython-312.pyc +0 -0
- package/watchtower/app/services/__pycache__/checkpoint.cpython-312.pyc +0 -0
- package/watchtower/app/services/__pycache__/tmux.cpython-312.pyc +0 -0
- package/watchtower/app/services/checkpoint.py +64 -22
- package/watchtower/requirements.txt +1 -1
- package/watchtower/static/dashboard.html +427 -299
- package/watchtower/watchtower.db +0 -0
- package/watchtower/Dockerfile +0 -9
- package/watchtower/docker-compose.yml +0 -22
- 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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
225
|
-
|
|
226
|
-
purpose = (await prompt(rl, " purpose:
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 ---
|
package/hooks/uv-out-notify.sh
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# UV Suite Hook:
|
|
2
|
+
# UV Suite Hook: surface real skill artifacts written to uv-out/ after a run.
|
|
3
3
|
# Event: Stop
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
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": "
|
|
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
|
package/hooks/watchtower-send.sh
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
4
|
-
"description": "Anti-slop guardrails, specialized agents, and a live observability + control plane for AI-assisted coding.
|
|
5
|
-
"author":
|
|
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",
|
package/personas/auto.json
CHANGED
|
@@ -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
|
}
|
package/personas/spike.json
CHANGED
|
@@ -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
|
}
|
package/personas/sport.json
CHANGED
|
@@ -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
|
}
|