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/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\",\"
|
|
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"
|
package/watchtower/README.md
CHANGED
|
@@ -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 +
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
64
|
-
|
|
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/`
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/watchtower/app/db.py
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Database layer — embedded SQLite (no server, no Docker).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
13
|
+
import aiosqlite
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
"
|
|
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
|
-
|
|
20
|
+
_db: aiosqlite.Connection | None = None
|
|
21
|
+
_write_lock = asyncio.Lock()
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
class Broadcaster:
|
|
23
|
-
"""Fan out
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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))
|
package/watchtower/app/main.py
CHANGED
|
@@ -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.
|
|
19
|
-
listener = asyncio.create_task(db.listen_notify())
|
|
17
|
+
await db.init_db()
|
|
20
18
|
try:
|
|
21
19
|
yield
|
|
22
20
|
finally:
|
|
23
|
-
|
|
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")
|
package/watchtower/app/models.py
CHANGED
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
29
|
-
row = await
|
|
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
|
|
75
|
+
return row
|
|
33
76
|
|
|
34
77
|
|
|
35
78
|
@router.post("/sessions/{id}/checkpoint")
|
|
36
|
-
async def checkpoint_session(id: str
|
|
37
|
-
session = await _load_session(id
|
|
79
|
+
async def checkpoint_session(id: str) -> dict:
|
|
80
|
+
session = await _load_session(id)
|
|
38
81
|
path = await checkpoint.write_checkpoint(session)
|
|
39
|
-
|
|
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
|
|
45
|
-
session = await _load_session(id
|
|
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
|
|
61
|
-
"UPDATE sessions SET state = 'terminated', ended_at =
|
|
103
|
+
await db.execute(
|
|
104
|
+
"UPDATE sessions SET state = 'terminated', ended_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
105
|
+
id,
|
|
62
106
|
)
|
|
63
|
-
|
|
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("/
|
|
68
|
-
async def
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
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;
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
249
|
+
await db.execute(
|
|
134
250
|
"""INSERT INTO sessions (id, persona, cwd, tmux_target, state)
|
|
135
|
-
VALUES (
|
|
251
|
+
VALUES (?, ?, ?, ?, 'active')
|
|
136
252
|
ON CONFLICT (id) DO UPDATE SET
|
|
137
|
-
persona = COALESCE(
|
|
138
|
-
cwd = COALESCE(
|
|
139
|
-
tmux_target =
|
|
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
|
-
|
|
259
|
+
db.notify({"type": "session", "session_id": id, "state": "active"})
|
|
144
260
|
return {"id": id, "tmux_target": target}
|