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/uv.sh CHANGED
@@ -175,7 +175,7 @@ launch_session() {
175
175
 
176
176
  curl -s "$wt_url/sessions/register" \
177
177
  -H 'Content-Type: application/json' \
178
- -d "{\"id\":\"$UVS_SESSION_ID\",\"tmux\":\"$tname\",\"pid\":$$,\"cwd\":\"$PWD\"}" \
178
+ -d "{\"id\":\"$UVS_SESSION_ID\",\"tmux_target\":\"$tname\",\"pid\":$$,\"cwd\":\"$PWD\"}" \
179
179
  >/dev/null 2>&1 || true
180
180
 
181
181
  exec tmux -L "$socket" attach -t "$tname"
@@ -4,23 +4,20 @@ Observability **and control** for UV Suite sessions. Hooks push events; the dash
4
4
  observes *and* acts — checkpoint a session, close it, or approve one that's blocked waiting
5
5
  for a human — from the browser.
6
6
 
7
- FastAPI + Postgres. A hook insert fires `pg_notify`; a listener forwards it to connected
8
- dashboards over WebSocket (no polling).
7
+ FastAPI + embedded **SQLite** no server, no Docker, no database to install. The live
8
+ WebSocket stream is fed by an in-process broadcaster, so there is nothing external to run.
9
9
 
10
10
  ## Run
11
11
 
12
12
  ```bash
13
- cd watchtower
14
- docker compose up --build # Postgres + the service on :4200
15
- # open http://localhost:4200
13
+ uvs watch # opens http://localhost:4200
16
14
  ```
17
15
 
18
- Local (without Docker), against your own Postgres:
19
- ```bash
20
- export DATABASE_URL=postgresql://watchtower:watchtower@localhost:5432/watchtower
21
- uv run --with-requirements requirements.txt uvicorn app.main:app --host 127.0.0.1 --port 4200
22
- ```
23
- > Bind control to **127.0.0.1** — the control API can checkpoint/kill/approve sessions.
16
+ That's the whole setup. `uvs watch` provisions the Python deps on first run (via `uv`, or
17
+ a local `.venv`) and stores data in `watchtower/watchtower.db` (override with `WATCHTOWER_DB`).
18
+
19
+ > The service binds to **127.0.0.1** the control API can checkpoint/kill/approve
20
+ > sessions, so it must stay on localhost.
24
21
 
25
22
  ## How sessions become controllable
26
23
 
@@ -49,26 +46,24 @@ works via PID, approve is unavailable from the UI.
49
46
  ```
50
47
  app/
51
48
  main.py app wiring + lifespan + dashboard serving
52
- db.py asyncpg pool + NOTIFY WebSocket broadcaster
49
+ db.py SQLite layer + in-process WebSocket broadcaster
53
50
  models.py request models
54
51
  routers/ ingest.py · query.py · stream.py · control.py
55
52
  services/ checkpoint.py (out-of-band) · tmux.py (send-keys/kill/capture)
56
53
  static/dashboard.html
57
- schema.sql · Dockerfile · docker-compose.yml · requirements.txt
54
+ requirements.txt
58
55
  ```
59
56
 
60
57
  ## Status / follow-ups
61
58
  - The `Notification` hook (`hooks/watchtower-notify.sh`) still needs wiring into
62
59
  `personas/*.json` (a `Notification` event entry) for approve to fire.
63
- - The approve keystroke map (`y`/`n`) is a placeholder — Claude Code's permission UI may
64
- use arrow-select + Enter. Validate against the installed version (`capture_pane` shows it).
60
+ - Approve sends `1`+Enter (select "Yes") / Esc (cancel)matched to Claude Code's
61
+ numbered arrow-select permission widget. Revisit if that TUI changes.
65
62
  - The semantic (haiku) checkpoint summary is a TODO; v1 checkpoints are mechanical.
66
- - Supersedes the old Node `server.js`/`dashboard.html`/`*-runner.js` (kept for now).
67
63
 
68
64
  ## Legacy fallback (Node)
69
65
 
70
- The original Node Watchtower lives under `watchtower/legacy/` as a fallback that needs
71
- **no Docker/Postgres**:
66
+ The original Node Watchtower lives under `watchtower/legacy/` pure Node, no Python:
72
67
 
73
68
  ```bash
74
69
  uvs watch --legacy # runs legacy/server.js on :4200 (flat events.json, SSE)
@@ -1,26 +1,28 @@
1
- """Async Postgres layer + a NOTIFY WebSocket broadcaster.
1
+ """Database layer embedded SQLite (no server, no Docker).
2
2
 
3
- A hook insert calls `notify()` which `pg_notify`s on CHANNEL; a dedicated listener
4
- connection forwards each payload to the Broadcaster, which fans it out to connected
5
- WebSocket subscribers. No polling.
3
+ Data lives in a single file (default watchtower/watchtower.db, override with
4
+ WATCHTOWER_DB). The live WebSocket stream is fed by an in-process broadcaster, so
5
+ there is nothing external to run. Routers use one small async API: `fetch`,
6
+ `fetchrow`, `execute`, `insert` (with `?` placeholders) and the sync `notify(payload)`.
7
+ JSON columns are stored as TEXT; callers json.dumps on write and json.loads on read.
6
8
  """
7
9
  import asyncio
8
10
  import json
9
11
  import os
10
12
 
11
- import asyncpg
13
+ import aiosqlite
12
14
 
13
- DATABASE_URL = os.environ.get(
14
- "DATABASE_URL", "postgresql://watchtower:watchtower@localhost:5432/watchtower"
15
+ SQLITE_PATH = os.environ.get(
16
+ "WATCHTOWER_DB",
17
+ os.path.join(os.path.dirname(__file__), "..", "watchtower.db"),
15
18
  )
16
- CHANNEL = "watchtower_events"
17
- SCHEMA_PATH = os.path.join(os.path.dirname(__file__), "..", "schema.sql")
18
19
 
19
- pool: asyncpg.Pool | None = None
20
+ _db: aiosqlite.Connection | None = None
21
+ _write_lock = asyncio.Lock()
20
22
 
21
23
 
22
24
  class Broadcaster:
23
- """Fan out NOTIFY payloads (JSON strings) to subscribed WebSocket clients."""
25
+ """Fan out payloads (JSON strings) to subscribed WebSocket clients (in-process)."""
24
26
 
25
27
  def __init__(self) -> None:
26
28
  self._subs: set[asyncio.Queue] = set()
@@ -43,43 +45,85 @@ class Broadcaster:
43
45
 
44
46
  broadcaster = Broadcaster()
45
47
 
46
-
47
- async def init_pool() -> None:
48
- global pool
49
- async def _init(con):
50
- await con.set_type_codec(
51
- "jsonb", encoder=json.dumps, decoder=json.loads, schema="pg_catalog"
52
- )
53
-
54
- pool = await asyncpg.create_pool(DATABASE_URL, min_size=1, max_size=10, init=_init)
55
- with open(SCHEMA_PATH) as f:
56
- schema = f.read()
57
- async with pool.acquire() as con:
58
- await con.execute(schema)
59
-
60
-
61
- async def listen_notify() -> None:
62
- """Dedicated connection LISTENing on CHANNEL; forwards payloads to the broadcaster."""
63
- con = await asyncpg.connect(DATABASE_URL)
64
- await con.add_listener(CHANNEL, lambda *args: broadcaster.publish(args[-1]))
65
- try:
66
- while True:
67
- await asyncio.sleep(3600)
68
- finally:
69
- await con.close()
70
-
71
-
72
- async def close_pool() -> None:
73
- if pool:
74
- await pool.close()
75
-
76
-
77
- async def db() -> asyncpg.Connection:
78
- """FastAPI dependency: yields a pooled connection."""
79
- async with pool.acquire() as con:
80
- yield con
81
-
82
-
83
- async def notify(con: asyncpg.Connection, payload: dict) -> None:
84
- """Emit a NOTIFY so the stream router pushes this to dashboards."""
85
- await con.execute("SELECT pg_notify($1, $2)", CHANNEL, json.dumps(payload, default=str))
48
+ _SCHEMA = [
49
+ """CREATE TABLE IF NOT EXISTS sessions (
50
+ id text PRIMARY KEY, name text, kind text, purpose text, priority text,
51
+ persona text, cwd text, worktree text, branch text, pid integer,
52
+ tmux_target text, parent_id text, state text DEFAULT 'active',
53
+ input_tokens integer, output_tokens integer,
54
+ started_at TEXT DEFAULT CURRENT_TIMESTAMP, ended_at text)""",
55
+ """CREATE TABLE IF NOT EXISTS events (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id text, event_type text,
57
+ tool_name text, command text, payload text,
58
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP)""",
59
+ """CREATE TABLE IF NOT EXISTS approvals (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT, session_id text, tool_name text,
61
+ command text, request text, status text DEFAULT 'pending', decided_by text,
62
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP, decided_at text)""",
63
+ "CREATE TABLE IF NOT EXISTS settings (key text PRIMARY KEY, value text)",
64
+ "CREATE INDEX IF NOT EXISTS idx_events_session_created ON events (session_id, created_at)",
65
+ "CREATE INDEX IF NOT EXISTS idx_approvals_session_status ON approvals (session_id, status)",
66
+ ]
67
+
68
+
69
+ async def init_db() -> None:
70
+ global _db
71
+ _db = await aiosqlite.connect(SQLITE_PATH)
72
+ _db.row_factory = aiosqlite.Row
73
+ for stmt in _SCHEMA:
74
+ await _db.execute(stmt)
75
+ # Migrate older dbs that predate newer columns.
76
+ for col in ("parent_id text", "input_tokens integer", "output_tokens integer"):
77
+ try:
78
+ await _db.execute(f"ALTER TABLE sessions ADD COLUMN {col}")
79
+ except Exception:
80
+ pass # column already exists
81
+ await _db.commit()
82
+
83
+
84
+ async def close() -> None:
85
+ if _db:
86
+ await _db.close()
87
+
88
+
89
+ async def fetch(sql: str, *args) -> list[dict]:
90
+ cur = await _db.execute(sql, args)
91
+ rows = await cur.fetchall()
92
+ return [dict(r) for r in rows]
93
+
94
+
95
+ async def fetchrow(sql: str, *args) -> dict | None:
96
+ rows = await fetch(sql, *args)
97
+ return rows[0] if rows else None
98
+
99
+
100
+ async def execute(sql: str, *args) -> None:
101
+ async with _write_lock:
102
+ await _db.execute(sql, args)
103
+ await _db.commit()
104
+
105
+
106
+ async def insert(sql: str, *args) -> int:
107
+ """Run an INSERT and return the new row id."""
108
+ async with _write_lock:
109
+ cur = await _db.execute(sql, args)
110
+ await _db.commit()
111
+ return cur.lastrowid
112
+
113
+
114
+ async def get_setting(key: str, default: str | None = None) -> str | None:
115
+ row = await fetchrow("SELECT value FROM settings WHERE key = ?", key)
116
+ return row["value"] if row else default
117
+
118
+
119
+ async def set_setting(key: str, value: str) -> None:
120
+ await execute(
121
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
122
+ "ON CONFLICT (key) DO UPDATE SET value = excluded.value",
123
+ key, value,
124
+ )
125
+
126
+
127
+ def notify(payload: dict) -> None:
128
+ """Push an update to connected dashboards (in-process)."""
129
+ broadcaster.publish(json.dumps(payload, default=str))
@@ -1,5 +1,4 @@
1
1
  """Watchtower service entrypoint. Wires the routers built independently in app/routers/."""
2
- import asyncio
3
2
  import os
4
3
  from contextlib import asynccontextmanager
5
4
 
@@ -8,20 +7,18 @@ from fastapi.responses import FileResponse
8
7
  from fastapi.staticfiles import StaticFiles
9
8
 
10
9
  from app import db
11
- from app.routers import control, ingest, query, stream
10
+ from app.routers import control, ingest, query, settings, stream
12
11
 
13
12
  STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static")
14
13
 
15
14
 
16
15
  @asynccontextmanager
17
16
  async def lifespan(app: FastAPI):
18
- await db.init_pool()
19
- listener = asyncio.create_task(db.listen_notify())
17
+ await db.init_db()
20
18
  try:
21
19
  yield
22
20
  finally:
23
- listener.cancel()
24
- await db.close_pool()
21
+ await db.close()
25
22
 
26
23
 
27
24
  app = FastAPI(title="Watchtower", version="1.0.0", lifespan=lifespan)
@@ -30,6 +27,7 @@ app.include_router(ingest.router)
30
27
  app.include_router(query.router)
31
28
  app.include_router(stream.router)
32
29
  app.include_router(control.router)
30
+ app.include_router(settings.router)
33
31
 
34
32
 
35
33
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -30,6 +30,11 @@ class StateUpdate(BaseModel):
30
30
  state: Literal["active", "idle", "awaiting_human", "terminated"]
31
31
 
32
32
 
33
+ class TokensIn(BaseModel):
34
+ input_tokens: int = 0
35
+ output_tokens: int = 0
36
+
37
+
33
38
  class ApprovalIn(BaseModel):
34
39
  session_id: str
35
40
  tool_name: str | None = None
@@ -5,13 +5,16 @@
5
5
  # is started with `--host 127.0.0.1`; keep it bound to localhost.
6
6
  """
7
7
  import asyncio
8
+ import json
8
9
  import os
10
+ import shlex
9
11
  import shutil
10
12
  import signal
13
+ import subprocess
14
+ import sys
11
15
  import uuid
12
16
 
13
- import asyncpg
14
- from fastapi import APIRouter, Depends, HTTPException
17
+ from fastapi import APIRouter, HTTPException
15
18
 
16
19
  from app import db
17
20
  from app.models import ApprovalDecision, SpawnRequest
@@ -19,30 +22,70 @@ from app.services import checkpoint, tmux
19
22
 
20
23
  router = APIRouter()
21
24
 
25
+
26
+ def _terminal_app(pref: str | None = None) -> str:
27
+ """Which macOS terminal to open. Precedence: the UI setting (pref), then the
28
+ UVS_TERMINAL env, then the terminal that launched Watchtower (TERM_PROGRAM),
29
+ then Terminal.app. 'auto'/empty falls through to detection."""
30
+ for candidate in (pref, os.environ.get("UVS_TERMINAL", "")):
31
+ c = (candidate or "").strip().lower()
32
+ if c in ("iterm", "iterm2"):
33
+ return "iterm"
34
+ if c in ("terminal", "terminal.app", "apple_terminal"):
35
+ return "terminal"
36
+ if os.environ.get("TERM_PROGRAM") == "iTerm.app":
37
+ return "iterm"
38
+ return "terminal"
39
+
40
+
41
+ def _term_script(app: str, attach: str) -> str:
42
+ if app == "iterm":
43
+ return (
44
+ 'tell application "iTerm"\n'
45
+ " create window with default profile\n"
46
+ f' tell current session of current window to write text "{attach}"\n'
47
+ " activate\n"
48
+ "end tell"
49
+ )
50
+ return f'tell application "Terminal" to do script "{attach}"\ntell application "Terminal" to activate'
51
+
52
+
53
+ def _open_terminal(target: str, pref: str | None = None) -> bool:
54
+ """Best-effort: open a terminal window attached to the tmux session (macOS).
55
+ Tries the preferred app first, then the other as a fallback."""
56
+ if sys.platform != "darwin":
57
+ return False
58
+ attach = f"tmux -L {tmux.SOCKET} attach -t {shlex.quote(target)}"
59
+ order = ["iterm", "terminal"] if _terminal_app(pref) == "iterm" else ["terminal", "iterm"]
60
+ for app in order:
61
+ if subprocess.run(["osascript", "-e", _term_script(app, attach)], capture_output=True).returncode == 0:
62
+ return True
63
+ return False
64
+
22
65
  # Claude Code's permission gate is a numbered arrow-select menu confirmed with Enter and
23
66
  # cancelled with Esc (verified live: the trust-folder and tool-permission prompts use the
24
67
  # same widget — it is NOT a y/n prompt). Approve = select option 1 ("Yes") + Enter;
25
68
  # deny = Esc. This is the one part coupled to the tool's TUI; revisit if the widget changes.
26
69
 
27
70
 
28
- async def _load_session(id: str, con: asyncpg.Connection) -> dict:
29
- row = await con.fetchrow("SELECT * FROM sessions WHERE id = $1", id)
71
+ async def _load_session(id: str) -> dict:
72
+ row = await db.fetchrow("SELECT * FROM sessions WHERE id = ?", id)
30
73
  if row is None:
31
74
  raise HTTPException(status_code=404, detail=f"session {id} not found")
32
- return dict(row)
75
+ return row
33
76
 
34
77
 
35
78
  @router.post("/sessions/{id}/checkpoint")
36
- async def checkpoint_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> dict:
37
- session = await _load_session(id, con)
79
+ async def checkpoint_session(id: str) -> dict:
80
+ session = await _load_session(id)
38
81
  path = await checkpoint.write_checkpoint(session)
39
- await db.notify(con, {"type": "checkpoint", "session_id": id, "path": path})
82
+ db.notify({"type": "checkpoint", "session_id": id, "path": path})
40
83
  return {"path": path}
41
84
 
42
85
 
43
86
  @router.post("/sessions/{id}/close")
44
- async def close_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> dict:
45
- session = await _load_session(id, con)
87
+ async def close_session(id: str) -> dict:
88
+ session = await _load_session(id)
46
89
 
47
90
  path = await checkpoint.write_checkpoint(session)
48
91
 
@@ -57,61 +100,137 @@ async def close_session(id: str, con: asyncpg.Connection = Depends(db.db)) -> di
57
100
  except ProcessLookupError:
58
101
  terminated_via = "pid_gone"
59
102
 
60
- await con.execute(
61
- "UPDATE sessions SET state = 'terminated', ended_at = now() WHERE id = $1", id
103
+ await db.execute(
104
+ "UPDATE sessions SET state = 'terminated', ended_at = CURRENT_TIMESTAMP WHERE id = ?",
105
+ id,
62
106
  )
63
- await db.notify(con, {"type": "session", "session_id": id, "state": "terminated"})
107
+ db.notify({"type": "session", "session_id": id, "state": "terminated"})
64
108
  return {"ok": True, "checkpoint": path, "terminated_via": terminated_via}
65
109
 
66
110
 
67
- @router.post("/sessions/{id}/approve")
68
- async def approve_session(
69
- id: str, decision: ApprovalDecision, con: asyncpg.Connection = Depends(db.db)
70
- ) -> dict:
71
- session = await _load_session(id, con)
111
+ @router.post("/approvals/{approval_id}/decide")
112
+ async def decide_approval(approval_id: int, decision: ApprovalDecision) -> dict:
113
+ """Resolve an approval by its own id. Actuates via send-keys when the session is
114
+ owned (tmux); otherwise still records the decision so it clears from the UI (the
115
+ user answers in the terminal). Never 404s on a missing/unowned session."""
116
+ appr = await db.fetchrow("SELECT * FROM approvals WHERE id = ?", approval_id)
117
+ if appr is None:
118
+ raise HTTPException(status_code=404, detail="approval not found")
72
119
 
120
+ sid = appr["session_id"]
121
+ session = await db.fetchrow("SELECT * FROM sessions WHERE id = ?", sid)
122
+
123
+ actuated = False
124
+ if session and session.get("tmux_target"):
125
+ keys = "1" if decision.decision == "approve" else "Escape" # numbered menu: 1=Yes / Esc=cancel
126
+ await asyncio.to_thread(tmux.send_keys, session["tmux_target"], keys, decision.decision == "approve")
127
+ actuated = True
128
+
129
+ new_status = "approved" if decision.decision == "approve" else "denied"
130
+ await db.execute(
131
+ "UPDATE approvals SET status = ?, decided_by = ?, decided_at = CURRENT_TIMESTAMP WHERE id = ?",
132
+ new_status, decision.decided_by, approval_id,
133
+ )
134
+ if session:
135
+ await db.execute("UPDATE sessions SET state = 'active' WHERE id = ?", sid)
136
+ db.notify({"type": "approval", "id": approval_id, "session_id": sid, "status": new_status})
137
+ return {"ok": True, "status": new_status, "actuated": actuated}
138
+
139
+
140
+ @router.post("/sessions/{id}/compact")
141
+ async def compact_session(id: str) -> dict:
142
+ session = await _load_session(id)
73
143
  if not session.get("tmux_target"):
74
144
  raise HTTPException(
75
145
  status_code=400,
76
- detail="session not owned by Watchtower; approve in the terminal",
146
+ detail="session not owned by Watchtower; run /compact in the terminal",
77
147
  )
148
+ await asyncio.to_thread(tmux.send_keys, session["tmux_target"], "/compact", True)
149
+ db.notify({"type": "event", "session_id": id, "event_type": "Compact"})
150
+ return {"ok": True}
78
151
 
79
- approval = await con.fetchrow(
80
- """SELECT id FROM approvals
81
- WHERE session_id = $1 AND status = 'pending'
82
- ORDER BY created_at DESC
83
- LIMIT 1""",
84
- id,
152
+
153
+ @router.post("/sessions/{id}/fork")
154
+ async def fork_session(id: str, open: bool = True) -> dict:
155
+ parent = await _load_session(id)
156
+ if not tmux.has_tmux():
157
+ raise HTTPException(status_code=400, detail="tmux not available; cannot fork")
158
+
159
+ child = uuid.uuid4().hex
160
+ cwd = parent.get("cwd") or os.getcwd()
161
+ persona = parent.get("persona") or "professional"
162
+ name = (parent.get("name") or parent["id"][:8]) + " (fork)"
163
+
164
+ # Write a metadata file so the child's hooks attribute events to it (name, persona,
165
+ # parent) instead of falling back to Claude's internal session id.
166
+ try:
167
+ meta_dir = os.path.join(cwd, ".uv-suite-state", "sessions")
168
+ os.makedirs(meta_dir, exist_ok=True)
169
+ with open(os.path.join(meta_dir, f"{child}.json"), "w") as f:
170
+ json.dump(
171
+ {"uvs_session_id": child, "name": name, "persona": persona,
172
+ "parent_id": id, "cwd": cwd},
173
+ f,
174
+ )
175
+ except OSError:
176
+ pass # best-effort; the hook still tags by UVS_SESSION_ID
177
+
178
+ settings = os.path.join(cwd, ".claude", "personas", f"{persona}.json")
179
+ claude = "claude" + (f" --settings {shlex.quote(settings)}" if os.path.isfile(settings) else "")
180
+ cmd = f"UVS_SESSION_ID={child} UVS_IN_TMUX=1 exec {claude}"
181
+
182
+ try:
183
+ target = await asyncio.to_thread(tmux.spawn, child, cmd, cwd)
184
+ except RuntimeError as e:
185
+ raise HTTPException(status_code=400, detail=str(e))
186
+
187
+ await db.execute(
188
+ """INSERT INTO sessions (id, name, persona, cwd, tmux_target, parent_id, state)
189
+ VALUES (?, ?, ?, ?, ?, ?, 'active')""",
190
+ child, name, persona, cwd, target, id,
85
191
  )
86
- if approval is None:
87
- raise HTTPException(status_code=404, detail="no pending approval for session")
192
+ pref = await db.get_setting("terminal_app")
193
+ opened = await asyncio.to_thread(_open_terminal, target, pref) if open else False
194
+ db.notify({"type": "session", "session_id": child, "state": "active"})
195
+ return {"id": child, "name": name, "tmux_target": target, "parent_id": id, "terminal_opened": opened}
88
196
 
89
- target = session["tmux_target"]
90
- # Read the current prompt before answering (surfaced for debugging / UI).
91
- prompt = await asyncio.to_thread(tmux.capture_pane, target)
92
- if decision.decision == "approve":
93
- await asyncio.to_thread(tmux.send_keys, target, "1", True) # select "Yes" + Enter
94
- else:
95
- await asyncio.to_thread(tmux.send_keys, target, "Escape", False) # Esc cancels → reject
96
197
 
97
- new_status = "approved" if decision.decision == "approve" else "denied"
98
- await con.execute(
99
- """UPDATE approvals
100
- SET status = $2, decided_by = $3, decided_at = now()
101
- WHERE id = $1""",
102
- approval["id"], new_status, decision.decided_by,
198
+ @router.delete("/sessions/{id}")
199
+ async def delete_session(id: str) -> dict:
200
+ """Permanently remove a session and its events/approvals from Watchtower.
201
+ Kills the live tmux session first (if owned + active) so it can't re-register.
202
+ On-disk checkpoint files in the project's uv-out/ are left intact."""
203
+ session = await db.fetchrow("SELECT tmux_target, state FROM sessions WHERE id = ?", id)
204
+ if session is None:
205
+ raise HTTPException(status_code=404, detail="session not found")
206
+ if session.get("tmux_target") and session.get("state") != "terminated":
207
+ await asyncio.to_thread(tmux.kill_session, session["tmux_target"])
208
+ await db.execute("DELETE FROM events WHERE session_id = ?", id)
209
+ await db.execute("DELETE FROM approvals WHERE session_id = ?", id)
210
+ await db.execute("DELETE FROM sessions WHERE id = ?", id)
211
+ db.notify({"type": "session_deleted", "session_id": id})
212
+ return {"ok": True, "deleted": id}
213
+
214
+
215
+ @router.post("/sessions/cleanup")
216
+ async def cleanup_sessions() -> dict:
217
+ """Permanently remove ALL sessions (and their events/approvals). Kills any owned,
218
+ still-live tmux sessions first. Used by the dashboard's double-confirmed cleanup."""
219
+ live = await db.fetch(
220
+ "SELECT tmux_target FROM sessions WHERE state != 'terminated' AND tmux_target IS NOT NULL"
103
221
  )
104
- await con.execute("UPDATE sessions SET state = 'active' WHERE id = $1", id)
105
- await db.notify(con, {
106
- "type": "approval",
107
- "session_id": id,
108
- "status": new_status,
109
- })
110
- return {"ok": True, "status": new_status, "prompt": prompt[-500:]}
222
+ for r in live:
223
+ await asyncio.to_thread(tmux.kill_session, r["tmux_target"])
224
+ count = (await db.fetchrow("SELECT count(*) AS n FROM sessions"))["n"]
225
+ await db.execute("DELETE FROM events")
226
+ await db.execute("DELETE FROM approvals")
227
+ await db.execute("DELETE FROM sessions")
228
+ db.notify({"type": "cleanup"})
229
+ return {"ok": True, "deleted": count}
111
230
 
112
231
 
113
232
  @router.post("/sessions/spawn")
114
- async def spawn_session(req: SpawnRequest, con: asyncpg.Connection = Depends(db.db)) -> dict:
233
+ async def spawn_session(req: SpawnRequest) -> dict:
115
234
  if not tmux.has_tmux():
116
235
  raise HTTPException(status_code=400, detail="tmux not available; cannot spawn")
117
236
 
@@ -119,10 +238,7 @@ async def spawn_session(req: SpawnRequest, con: asyncpg.Connection = Depends(db.
119
238
  cwd = req.cwd or os.getcwd()
120
239
 
121
240
  # Prefer the real `uvs <tool> <persona>` launcher; fall back to the bare tool.
122
- if shutil.which("uvs"):
123
- launch = f"uvs {req.tool} {req.persona}"
124
- else:
125
- launch = req.tool
241
+ launch = f"uvs {req.tool} {req.persona}" if shutil.which("uvs") else req.tool
126
242
  cmd = f"UVS_SESSION_ID={id} {launch}"
127
243
 
128
244
  try:
@@ -130,15 +246,15 @@ async def spawn_session(req: SpawnRequest, con: asyncpg.Connection = Depends(db.
130
246
  except RuntimeError as e:
131
247
  raise HTTPException(status_code=400, detail=str(e))
132
248
 
133
- await con.execute(
249
+ await db.execute(
134
250
  """INSERT INTO sessions (id, persona, cwd, tmux_target, state)
135
- VALUES ($1, $2, $3, $4, 'active')
251
+ VALUES (?, ?, ?, ?, 'active')
136
252
  ON CONFLICT (id) DO UPDATE SET
137
- persona = COALESCE($2, sessions.persona),
138
- cwd = COALESCE($3, sessions.cwd),
139
- tmux_target = $4,
253
+ persona = COALESCE(excluded.persona, sessions.persona),
254
+ cwd = COALESCE(excluded.cwd, sessions.cwd),
255
+ tmux_target = excluded.tmux_target,
140
256
  state = 'active'""",
141
257
  id, req.persona, cwd, target,
142
258
  )
143
- await db.notify(con, {"type": "session", "session_id": id, "state": "active"})
259
+ db.notify({"type": "session", "session_id": id, "state": "active"})
144
260
  return {"id": id, "tmux_target": target}