uv-suite 0.26.4 → 0.27.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.
@@ -33,7 +33,16 @@
33
33
  "Bash(npx prettier *)",
34
34
  "Bash(pytest *)",
35
35
  "Bash(go test *)",
36
- "Bash(cargo test *)"
36
+ "Bash(cargo test *)",
37
+ "Bash(chmod *)",
38
+ "Bash(mkdir *)",
39
+ "Bash(rm /tmp/*)",
40
+ "Bash(cat *)",
41
+ "Bash(ls *)",
42
+ "Bash(echo *)",
43
+ "Bash(printf *)",
44
+ "Bash(node --check *)",
45
+ "Bash(bash -n *)"
37
46
  ],
38
47
  "deny": [
39
48
  "Bash(rm -rf /)",
@@ -42,7 +51,8 @@
42
51
  "Bash(sudo rm *)",
43
52
  "Bash(git push --force * main)",
44
53
  "Bash(git push --force * master)",
45
- "Bash(git reset --hard origin/*)"
54
+ "Bash(git reset --hard origin/*)",
55
+ "Bash(sudo rm -rf *)"
46
56
  ]
47
57
  },
48
58
  "hooks": {
@@ -164,6 +174,12 @@
164
174
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
165
175
  "timeout": 2,
166
176
  "async": true
177
+ },
178
+ {
179
+ "type": "command",
180
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
181
+ "timeout": 5,
182
+ "async": true
167
183
  }
168
184
  ]
169
185
  },
@@ -22,15 +22,30 @@
22
22
  "Bash(find *)",
23
23
  "Bash(wc *)",
24
24
  "Bash(head *)",
25
- "Bash(tail *)"
25
+ "Bash(tail *)",
26
+ "Edit(*)",
27
+ "Bash(git *)",
28
+ "Bash(npx prettier *)",
29
+ "Bash(npx markdown-* *)",
30
+ "Bash(curl *)",
31
+ "Bash(chmod *)",
32
+ "Bash(mkdir *)",
33
+ "Bash(rm /tmp/*)",
34
+ "Bash(cat *)",
35
+ "Bash(ls *)",
36
+ "Bash(echo *)",
37
+ "Bash(printf *)",
38
+ "Bash(node --check *)",
39
+ "Bash(bash -n *)"
26
40
  ],
27
41
  "deny": [
28
- "Edit(*)",
29
- "Bash(git commit *)",
30
- "Bash(git push *)",
31
- "Bash(rm *)",
32
- "Bash(npm run *)",
33
- "Bash(npx *)"
42
+ "Bash(rm -rf /)",
43
+ "Bash(rm -rf ~)",
44
+ "Bash(rm -rf .)",
45
+ "Bash(sudo rm *)",
46
+ "Bash(sudo rm -rf *)",
47
+ "Bash(git push --force * main)",
48
+ "Bash(git push --force * master)"
34
49
  ]
35
50
  },
36
51
  "hooks": {
@@ -123,6 +138,12 @@
123
138
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
124
139
  "timeout": 2,
125
140
  "async": true
141
+ },
142
+ {
143
+ "type": "command",
144
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
145
+ "timeout": 5,
146
+ "async": true
126
147
  }
127
148
  ]
128
149
  },
@@ -16,7 +16,25 @@
16
16
  "Bash(npm *)",
17
17
  "Bash(npx *)",
18
18
  "Bash(node *)",
19
- "Bash(git *)"
19
+ "Bash(git *)",
20
+ "Bash(chmod *)",
21
+ "Bash(mkdir *)",
22
+ "Bash(rm /tmp/*)",
23
+ "Bash(cat *)",
24
+ "Bash(ls *)",
25
+ "Bash(echo *)",
26
+ "Bash(printf *)",
27
+ "Bash(node --check *)",
28
+ "Bash(bash -n *)"
29
+ ],
30
+ "deny": [
31
+ "Bash(rm -rf /)",
32
+ "Bash(rm -rf ~)",
33
+ "Bash(rm -rf .)",
34
+ "Bash(sudo rm *)",
35
+ "Bash(sudo rm -rf *)",
36
+ "Bash(git push --force * main)",
37
+ "Bash(git push --force * master)"
20
38
  ]
21
39
  },
22
40
  "hooks": {
@@ -109,6 +127,12 @@
109
127
  "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/watchtower-send.sh PostToolUse",
110
128
  "timeout": 2,
111
129
  "async": true
130
+ },
131
+ {
132
+ "type": "command",
133
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-checkpoint.sh",
134
+ "timeout": 5,
135
+ "async": true
112
136
  }
113
137
  ]
114
138
  },
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: auto-checkpoint
3
+ description: >
4
+ Toggle automatic checkpoints, or change how often they fire. When on,
5
+ UV Suite writes a checkpoint every N minutes (default 10): a mechanical
6
+ state snapshot from the PostToolUse hook, plus a semantic summary written
7
+ by Claude (via `claude -p --bare --model haiku`) from the Watchtower's
8
+ timer. Sessions with no activity in the window are skipped.
9
+ argument-hint: "[on|off|<minutes>|status]"
10
+ user-invocable: true
11
+ allowed-tools:
12
+ - Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/auto-checkpoint-helper.sh *)
13
+ ---
14
+
15
+ ## Apply /auto-checkpoint $ARGUMENTS
16
+
17
+ !`"$CLAUDE_PROJECT_DIR"/.claude/hooks/auto-checkpoint-helper.sh $ARGUMENTS`
18
+
19
+ ## Instructions
20
+
21
+ Show the user the line of output above as the response — it confirms what
22
+ changed. Do not add commentary. The change applies to the very next interval;
23
+ no restart needed.
24
+
25
+ ## What this controls
26
+
27
+ - `on` / `off` — enable or disable both tiers (mechanical + semantic).
28
+ - `<minutes>` — set the checkpoint interval (1-1440). Default 10.
29
+ - `status` (or no argument) — print the current mode and interval.
30
+
31
+ ## How it works
32
+
33
+ - **Tier A (mechanical):** the `auto-checkpoint.sh` hook runs after each tool
34
+ call. When the interval has passed and there's been activity since the last
35
+ checkpoint, it writes a deterministic snapshot — git state, recent tool calls,
36
+ files touched — to `uv-out/checkpoints/<sid>/auto-<ts>-mechanical.md`.
37
+ - **Tier B (semantic):** the Watchtower process (`uvs watch`) keeps a timer.
38
+ Every N minutes, for each active session, it shells out to `claude -p --bare
39
+ --model haiku` with a prompt assembled from the recent dashboard events and
40
+ git state. Output is saved next to the mechanical checkpoint as
41
+ `auto-<ts>-semantic.md`. Cap: `--max-budget-usd 0.05` per call.
42
+ - Both tiers fire `AutoCheckpoint` events to the Watchtower so they show up as
43
+ distinct rows on the dashboard.
44
+ - Sessions with zero activity in the interval are skipped — no empty checkpoints.
45
+
46
+ State lives in `.uv-suite-state/auto-checkpoint.json` (mode + interval) and
47
+ `.uv-suite-state/sessions/<sid>.last-{mechanical,semantic}-checkpoint.txt`.
@@ -7,15 +7,12 @@ description: >
7
7
  argument-hint: "[on|off|<number>|status]"
8
8
  user-invocable: true
9
9
  allowed-tools:
10
- - Bash(mkdir *)
11
- - Bash(echo *)
12
- - Bash(cat *)
13
- - Bash(printf *)
10
+ - Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/confirm-helper.sh *)
14
11
  ---
15
12
 
16
13
  ## Apply /confirm $ARGUMENTS
17
14
 
18
- !`STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"; mkdir -p "$STATE_DIR"; MODE_FILE="$STATE_DIR/confirm-mode.txt"; THRESH_FILE="$STATE_DIR/confirm-threshold.txt"; ARG=$(printf '%s' "$ARGUMENTS" | tr -d '[:space:]'); current_mode() { cat "$MODE_FILE" 2>/dev/null || echo "on"; }; current_thresh() { cat "$THRESH_FILE" 2>/dev/null || echo "50"; }; case "$ARG" in on) echo "on" > "$MODE_FILE"; echo "Confirm mode: ON (threshold: $(current_thresh) words)";; off) echo "off" > "$MODE_FILE"; echo "Confirm mode: OFF";; ''|status) echo "Confirm mode: $(current_mode) (threshold: $(current_thresh) words)";; *) if printf '%s' "$ARG" | grep -qE '^[0-9]+$'; then echo "$ARG" > "$THRESH_FILE"; echo "Threshold set to $ARG words (mode: $(current_mode))"; else echo "Usage: /confirm [on | off | <number> | status]"; fi;; esac`
15
+ !`"$CLAUDE_PROJECT_DIR"/.claude/hooks/confirm-helper.sh $ARGUMENTS`
19
16
 
20
17
  ## Instructions
21
18
 
@@ -31,5 +28,5 @@ next user prompt; no restart needed.
31
28
  step. Slash commands (`/foo ...`) are always exempt.
32
29
  - `status` (or no argument) — print the current mode and threshold.
33
30
 
34
- State lives in `${CLAUDE_PROJECT_DIR}/.uv-suite-state/confirm-mode.txt` and
35
- `confirm-threshold.txt`. Defaults if missing: mode `on`, threshold `50`.
31
+ State lives in `.uv-suite-state/confirm-mode.txt` and `.uv-suite-state/confirm-threshold.txt`
32
+ under `$CLAUDE_PROJECT_DIR`. Defaults if missing: mode `on`, threshold `50`.
@@ -0,0 +1,51 @@
1
+ You are writing an auto-checkpoint for a UV Suite coding session. Be specific
2
+ and tight — this is a state snapshot, not a narrative. Don't speculate beyond
3
+ what the events show.
4
+
5
+ ## Session
6
+
7
+ - name: {{name}}
8
+ - kind: {{kind}}
9
+ - priority: {{priority}}
10
+ - persona: {{persona}}
11
+ - purpose: {{purpose}}
12
+ - elapsed since last semantic checkpoint: {{elapsed_min}} min
13
+
14
+ ## Activity (last {{interval_min}} min, from the dashboard)
15
+
16
+ {{event_list}}
17
+
18
+ ## Git
19
+
20
+ ```
21
+ {{git_branch}}
22
+ {{git_status}}
23
+ {{git_log}}
24
+ ```
25
+
26
+ ## Write the checkpoint
27
+
28
+ Output **only** the markdown body below — no preamble, no closing remarks.
29
+ Use this exact shape, max 30 lines total:
30
+
31
+ ```markdown
32
+ # Auto-checkpoint: {{timestamp}}
33
+
34
+ ## Done in the last {{interval_min}} min
35
+ - 2-4 bullets — concrete: file edited, command run, decision visible in events.
36
+ Skip vague verbs ("worked on"); name the artifact and the change.
37
+
38
+ ## Files touched
39
+ - list (omit section if empty)
40
+
41
+ ## In progress
42
+ - 1-2 bullets — what the session appears to be working on right now, based
43
+ on the latest events.
44
+
45
+ ## Notable
46
+ - only include this section if something stands out (a failure, a long pause
47
+ followed by a burst, a clear pattern shift). Otherwise omit the section.
48
+ ```
49
+
50
+ If the activity log is sparse or ambiguous, say so plainly in "In progress"
51
+ rather than inventing details.
@@ -0,0 +1,285 @@
1
+ // UV Suite — Tier B auto-checkpoint runner.
2
+ // Called from watchtower/server.js on a setInterval. For each active session
3
+ // (one with at least one event in the last interval), shells out to
4
+ // `claude -p --bare --model haiku` to write a semantic summary.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const { spawn } = require("child_process");
9
+
10
+ const PROMPT_TEMPLATE_PATH = path.join(__dirname, "auto-checkpoint-prompt.md");
11
+ const DEFAULT_INTERVAL_MIN = 10;
12
+ const POLL_INTERVAL_MS = 60 * 1000; // wake up every 60s; per-session cadence is honored individually
13
+ const MAX_BUDGET_USD = "0.05";
14
+ const MODEL = "haiku";
15
+
16
+ function readJsonSafe(p) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function readStringSafe(p) {
25
+ try {
26
+ return fs.readFileSync(p, "utf-8").trim();
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ // Returns { mode, interval_minutes } — defaults if state file missing.
33
+ function readAutoCheckpointState(projectDir) {
34
+ const f = path.join(projectDir, ".uv-suite-state", "auto-checkpoint.json");
35
+ const d = readJsonSafe(f);
36
+ return {
37
+ mode: d?.mode ?? "on",
38
+ interval_minutes: d?.interval_minutes ?? DEFAULT_INTERVAL_MIN,
39
+ };
40
+ }
41
+
42
+ // Group events by session, keep the most recent set per session.
43
+ function groupActiveSessions(events, windowMs) {
44
+ const cutoff = Date.now() - windowMs;
45
+ const bySession = new Map();
46
+ for (const ev of events) {
47
+ const sid = ev.uvs_session_id || ev.session_id;
48
+ if (!sid) continue;
49
+ if ((ev._ts || 0) < cutoff) continue;
50
+ if (!bySession.has(sid)) {
51
+ bySession.set(sid, {
52
+ sid,
53
+ cwd: ev.cwd,
54
+ session_name: ev.session_name || "",
55
+ session_kind: ev.session_kind || "",
56
+ session_priority: ev.session_priority || "",
57
+ session_purpose: ev.session_purpose || "",
58
+ persona: ev.persona || "",
59
+ events: [],
60
+ });
61
+ }
62
+ bySession.get(sid).events.push(ev);
63
+ }
64
+ return [...bySession.values()];
65
+ }
66
+
67
+ function eventToCompactLine(ev) {
68
+ const t = ev.event_type || ev.hook_event_name || "?";
69
+ const tool = ev.tool_name || "";
70
+ const input = ev.tool_input || {};
71
+ const target =
72
+ input.file_path ||
73
+ input.command ||
74
+ input.pattern ||
75
+ input.url ||
76
+ input.description ||
77
+ "";
78
+ const ts = new Date(ev._ts || Date.now()).toISOString().slice(11, 19);
79
+ let label = t;
80
+ if (tool) label += ` ${tool}`;
81
+ if (target) label += ` ${String(target).slice(0, 80)}`;
82
+ return ` ${ts} ${label}`;
83
+ }
84
+
85
+ function gitState(cwd) {
86
+ return new Promise((resolve) => {
87
+ const out = { branch: "", status: "", log: "" };
88
+ let pending = 3;
89
+ const done = () => {
90
+ if (--pending === 0) resolve(out);
91
+ };
92
+ const run = (args, key) => {
93
+ const child = spawn("git", args, { cwd });
94
+ let buf = "";
95
+ child.stdout.on("data", (d) => (buf += d));
96
+ child.on("close", () => {
97
+ out[key] = buf.trim();
98
+ done();
99
+ });
100
+ child.on("error", () => done());
101
+ };
102
+ run(["branch", "--show-current"], "branch");
103
+ run(["status", "--short"], "status");
104
+ run(["log", "--oneline", "-5"], "log");
105
+ });
106
+ }
107
+
108
+ function buildPrompt(template, ctx) {
109
+ let out = template;
110
+ for (const [k, v] of Object.entries(ctx)) {
111
+ out = out.split(`{{${k}}}`).join(v ?? "");
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function runClaudeP(prompt) {
117
+ return new Promise((resolve) => {
118
+ const child = spawn(
119
+ "claude",
120
+ ["-p", "--bare", "--model", MODEL, "--max-budget-usd", MAX_BUDGET_USD],
121
+ { stdio: ["pipe", "pipe", "pipe"] },
122
+ );
123
+ let stdout = "";
124
+ let stderr = "";
125
+ child.stdout.on("data", (d) => (stdout += d));
126
+ child.stderr.on("data", (d) => (stderr += d));
127
+ child.on("error", (err) =>
128
+ resolve({ ok: false, stdout, stderr, error: err.message }),
129
+ );
130
+ child.on("close", (code) =>
131
+ resolve({ ok: code === 0, stdout, stderr, code }),
132
+ );
133
+ child.stdin.write(prompt);
134
+ child.stdin.end();
135
+ });
136
+ }
137
+
138
+ let promptTemplate = null;
139
+ function loadPromptTemplate() {
140
+ if (promptTemplate) return promptTemplate;
141
+ try {
142
+ promptTemplate = fs.readFileSync(PROMPT_TEMPLATE_PATH, "utf-8");
143
+ } catch {
144
+ promptTemplate = null;
145
+ }
146
+ return promptTemplate;
147
+ }
148
+
149
+ async function processSession(session, broadcast) {
150
+ const { sid, cwd, events } = session;
151
+ if (!cwd || !sid) return;
152
+
153
+ const state = readAutoCheckpointState(cwd);
154
+ if (state.mode !== "on") return;
155
+ const intervalMs = state.interval_minutes * 60 * 1000;
156
+
157
+ const lastFile = path.join(
158
+ cwd,
159
+ ".uv-suite-state",
160
+ "sessions",
161
+ `${sid}.last-semantic-checkpoint.txt`,
162
+ );
163
+ const lastTs = parseInt(readStringSafe(lastFile) || "0", 10);
164
+ const now = Date.now();
165
+ if (now - lastTs * 1000 < intervalMs) return;
166
+
167
+ // Activity since last checkpoint
168
+ const recent = events
169
+ .filter((e) => (e._ts || 0) > lastTs * 1000)
170
+ .sort((a, b) => (a._ts || 0) - (b._ts || 0));
171
+ if (recent.length === 0) return;
172
+
173
+ const template = loadPromptTemplate();
174
+ if (!template) {
175
+ console.warn("[auto-checkpoint] prompt template missing; skipping");
176
+ return;
177
+ }
178
+
179
+ const git = await gitState(cwd);
180
+ const eventList = recent.slice(-40).map(eventToCompactLine).join("\n");
181
+ const elapsedMin =
182
+ lastTs === 0
183
+ ? state.interval_minutes
184
+ : Math.round((now - lastTs * 1000) / 60000);
185
+
186
+ const prompt = buildPrompt(template, {
187
+ name: session.session_name || "(unset)",
188
+ kind: session.session_kind || "(unset)",
189
+ priority: session.session_priority || "(unset)",
190
+ persona: session.persona || "(unset)",
191
+ purpose: session.session_purpose || "(unset)",
192
+ elapsed_min: String(elapsedMin),
193
+ interval_min: String(state.interval_minutes),
194
+ event_list: eventList,
195
+ git_branch: git.branch ? `Branch: ${git.branch}` : "(not a git repo)",
196
+ git_status: git.status || "(no changes)",
197
+ git_log: git.log || "",
198
+ timestamp: new Date(now).toISOString(),
199
+ });
200
+
201
+ const result = await runClaudeP(prompt);
202
+ if (!result.ok || !result.stdout.trim()) {
203
+ console.warn(
204
+ `[auto-checkpoint] claude -p failed for ${sid.slice(0, 8)}:`,
205
+ result.error || result.stderr?.slice(0, 200) || `exit ${result.code}`,
206
+ );
207
+ return;
208
+ }
209
+
210
+ // Write the checkpoint file
211
+ const cpDir = path.join(cwd, "uv-out", "checkpoints", sid);
212
+ fs.mkdirSync(cpDir, { recursive: true });
213
+ const tsFile = new Date(now)
214
+ .toISOString()
215
+ .slice(0, 16)
216
+ .replace(/[T:]/g, "-")
217
+ .replace(/-(\d\d)$/, "$1");
218
+ const cpFile = path.join(cpDir, `auto-${tsFile}-semantic.md`);
219
+
220
+ const frontmatter = [
221
+ "---",
222
+ `uvs_session_id: ${sid}`,
223
+ `session_name: ${session.session_name || ""}`,
224
+ `session_kind: ${session.session_kind || ""}`,
225
+ `session_purpose: ${session.session_purpose || ""}`,
226
+ `session_priority: ${session.session_priority || ""}`,
227
+ `persona: ${session.persona || ""}`,
228
+ `checkpoint_at: ${new Date(now).toISOString()}`,
229
+ `checkpoint_kind: auto-semantic`,
230
+ "---",
231
+ "",
232
+ ].join("\n");
233
+
234
+ fs.writeFileSync(cpFile, frontmatter + result.stdout.trim() + "\n");
235
+ fs.writeFileSync(lastFile, String(Math.floor(now / 1000)));
236
+
237
+ // Broadcast as AutoCheckpoint event
238
+ const preview = (frontmatter + result.stdout).slice(0, 2000);
239
+ const event = {
240
+ event_type: "AutoCheckpoint",
241
+ source_app: path.basename(cwd),
242
+ cwd,
243
+ uvs_session_id: sid,
244
+ session_id: sid,
245
+ session_name: session.session_name,
246
+ session_kind: session.session_kind,
247
+ session_priority: session.session_priority,
248
+ persona: session.persona,
249
+ checkpoint_kind: "auto-semantic",
250
+ checkpoint_path: cpFile,
251
+ checkpoint_preview: preview,
252
+ interval_minutes: state.interval_minutes,
253
+ tool_calls_in_window: recent.length,
254
+ _ts: now,
255
+ };
256
+ broadcast(event);
257
+ }
258
+
259
+ // One pass over all sessions with recent activity. Exposed so tests (and
260
+ // any future "force a checkpoint now" command) can drive a single tick.
261
+ async function tick({ getEvents, broadcast }) {
262
+ try {
263
+ const events = getEvents();
264
+ const window = 60 * 60 * 1000; // 1h lookback for active sessions
265
+ const sessions = groupActiveSessions(events, window);
266
+ for (const s of sessions) {
267
+ await processSession(s, broadcast);
268
+ }
269
+ } catch (err) {
270
+ console.warn("[auto-checkpoint] tick error:", err.message);
271
+ }
272
+ }
273
+
274
+ // Public API: start the runner. `getEvents` returns the watchtower's event
275
+ // store; `broadcast` injects an AutoCheckpoint event into the SSE stream.
276
+ // First tick after POLL_INTERVAL_MS; subsequent ticks every POLL_INTERVAL_MS.
277
+ function start({ getEvents, broadcast }) {
278
+ const handle = setInterval(
279
+ () => tick({ getEvents, broadcast }),
280
+ POLL_INTERVAL_MS,
281
+ );
282
+ return () => clearInterval(handle);
283
+ }
284
+
285
+ module.exports = { start, tick };
@@ -357,6 +357,53 @@
357
357
  .type-Notification { color: var(--warning); }
358
358
  .type-PermissionRequest { color: var(--danger-soft); }
359
359
  .type-PreCompact { color: var(--text-muted); }
360
+ .type-AutoCheckpoint { color: var(--success); font-weight: 700; }
361
+
362
+ /* Auto-checkpoint rows are full-width and expandable */
363
+ .event.checkpoint {
364
+ background: rgba(48, 209, 88, 0.06);
365
+ border-left: 3px solid var(--success);
366
+ padding-left: 25px;
367
+ opacity: 1;
368
+ }
369
+ .event.checkpoint .detail { cursor: pointer; }
370
+ .event.checkpoint .checkpoint-summary {
371
+ color: var(--text);
372
+ font-weight: 500;
373
+ }
374
+ .event.checkpoint .checkpoint-kind {
375
+ display: inline-block;
376
+ margin-left: 8px;
377
+ padding: 1px 6px;
378
+ font-size: 10.5px;
379
+ font-weight: 600;
380
+ letter-spacing: 0.04em;
381
+ text-transform: uppercase;
382
+ border-radius: 4px;
383
+ background: var(--success-soft);
384
+ color: var(--success);
385
+ vertical-align: 1px;
386
+ }
387
+ .event.checkpoint .checkpoint-body {
388
+ display: none;
389
+ margin-top: 10px;
390
+ padding: 12px 14px;
391
+ background: var(--surface);
392
+ border-radius: 6px;
393
+ color: var(--text-muted);
394
+ font-family: var(--font-mono);
395
+ font-size: 12.5px;
396
+ white-space: pre-wrap;
397
+ line-height: 1.55;
398
+ max-height: 360px;
399
+ overflow-y: auto;
400
+ }
401
+ .event.checkpoint.expanded .checkpoint-body { display: block; }
402
+ .event.checkpoint .checkpoint-toggle {
403
+ color: var(--text-dim);
404
+ font-size: 12px;
405
+ margin-left: 8px;
406
+ }
360
407
  </style>
361
408
  </head>
362
409
  <body>
@@ -603,11 +650,13 @@ function renderEvent(ev) {
603
650
  const fail = isFailure(ev);
604
651
  const boundary = isSessionBoundary(ev);
605
652
  const prompt = isUserPrompt(ev);
653
+ const checkpoint = type === 'AutoCheckpoint';
606
654
  const priority = sessions[sid]?.priority || '';
607
655
 
608
656
  const div = document.createElement('div');
609
657
  let cls = 'event';
610
- if (human) cls += ' needs-human';
658
+ if (checkpoint) cls += ' checkpoint';
659
+ else if (human) cls += ' needs-human';
611
660
  else if (fail) cls += ' failure';
612
661
  else if (prompt) cls += ' user-prompt';
613
662
  else if (boundary) cls += ' session-boundary';
@@ -617,13 +666,32 @@ function renderEvent(ev) {
617
666
 
618
667
  const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
619
668
 
620
- div.innerHTML = `
621
- <span class="time">${formatTime(ev._ts)}</span>
622
- <span class="type type-${type}">${type}${humanBadge}</span>
623
- <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
624
- <span class="tool">${tool}</span>
625
- <span class="detail">${eventDetail(ev)}</span>
626
- `;
669
+ if (checkpoint) {
670
+ const kind = ev.checkpoint_kind || 'auto';
671
+ const kindLabel = kind === 'auto-mechanical' ? 'mechanical' : kind === 'auto-semantic' ? 'semantic' : kind;
672
+ const calls = ev.tool_calls_in_window || 0;
673
+ const interval = ev.interval_minutes || '';
674
+ const summary = `<span class="checkpoint-summary">${escapeHtml(ev.checkpoint_path?.split('/').slice(-1)[0] || 'checkpoint')}</span>` +
675
+ `<span class="checkpoint-kind">${kindLabel}</span>` +
676
+ `<span class="checkpoint-toggle">${calls} tool calls in last ${interval}m · click to expand</span>`;
677
+ const body = ev.checkpoint_preview ? `<div class="checkpoint-body">${escapeHtml(ev.checkpoint_preview)}</div>` : '';
678
+ div.innerHTML = `
679
+ <span class="time">${formatTime(ev._ts)}</span>
680
+ <span class="type type-${type}">${type}</span>
681
+ <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
682
+ <span class="tool"></span>
683
+ <span class="detail">${summary}${body}</span>
684
+ `;
685
+ div.querySelector('.detail').addEventListener('click', () => div.classList.toggle('expanded'));
686
+ } else {
687
+ div.innerHTML = `
688
+ <span class="time">${formatTime(ev._ts)}</span>
689
+ <span class="type type-${type}">${type}${humanBadge}</span>
690
+ <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
691
+ <span class="tool">${tool}</span>
692
+ <span class="detail">${eventDetail(ev)}</span>
693
+ `;
694
+ }
627
695
 
628
696
  div._ev = ev;
629
697
 
@@ -8,6 +8,7 @@ const http = require("http");
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
10
  const crypto = require("crypto");
11
+ const autoCheckpointRunner = require("./auto-checkpoint-runner");
11
12
 
12
13
  const PORT = process.env.UVS_WATCHTOWER_PORT || 4200;
13
14
  const DATA_FILE = path.join(__dirname, "events.json");
@@ -176,4 +177,24 @@ server.listen(PORT, () => {
176
177
  console.log(
177
178
  `Waiting for hook events on POST http://localhost:${PORT}/events`,
178
179
  );
180
+
181
+ // Tier B auto-checkpoint runner. Polls every minute, calls
182
+ // `claude -p --bare --model haiku` for each active session whose
183
+ // configured interval has elapsed. Disable with `/auto-checkpoint off`
184
+ // per project, or set UVS_AUTO_CHECKPOINT_DISABLED=1 to disable globally.
185
+ if (!process.env.UVS_AUTO_CHECKPOINT_DISABLED) {
186
+ autoCheckpointRunner.start({
187
+ getEvents: () => events,
188
+ broadcast: (ev) => {
189
+ ev._ts = ev._ts || Date.now();
190
+ ev._id = crypto.randomUUID();
191
+ events.push(ev);
192
+ broadcast(ev);
193
+ saveEvents();
194
+ },
195
+ });
196
+ console.log(
197
+ "Auto-checkpoint runner started (Tier B, polls every 60s, uses claude -p)",
198
+ );
199
+ }
179
200
  });