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
@@ -10,355 +10,520 @@
10
10
  --panel: #161b22;
11
11
  --panel-2: #1c2430;
12
12
  --border: #2a3240;
13
- --text: #c9d1d9;
14
- --muted: #7d8590;
13
+ --text: #e6edf3;
14
+ --muted: #8b949e;
15
15
  --accent: #58a6ff;
16
16
  --active: #3fb950;
17
- --idle: #7d8590;
17
+ --idle: #8b949e;
18
18
  --awaiting: #d29922;
19
19
  --terminated: #f85149;
20
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
20
21
  }
21
22
  * { box-sizing: border-box; }
23
+ html, body { height: 100%; }
22
24
  body {
23
- margin: 0;
24
- background: var(--bg);
25
- color: var(--text);
26
- font: 13px/1.5 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
25
+ margin: 0; background: var(--bg); color: var(--text);
26
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
27
+ display: flex; flex-direction: column; overflow: hidden;
27
28
  }
29
+
28
30
  header {
29
- display: flex;
30
- align-items: center;
31
- gap: 12px;
32
- padding: 14px 20px;
33
- border-bottom: 1px solid var(--border);
34
- background: var(--panel);
35
- position: sticky;
36
- top: 0;
37
- z-index: 10;
31
+ display: flex; align-items: center; gap: 14px;
32
+ padding: 10px 18px; border-bottom: 1px solid var(--border); background: var(--panel);
33
+ flex-shrink: 0;
38
34
  }
39
- header h1 { font-size: 16px; margin: 0; letter-spacing: 1px; }
35
+ header h1 { font-size: 15px; margin: 0; letter-spacing: 0.5px; font-weight: 600; white-space: nowrap; }
40
36
  header h1 .eye { color: var(--accent); }
41
- #conn {
42
- margin-left: auto;
43
- font-size: 11px;
44
- color: var(--muted);
45
- display: flex;
46
- align-items: center;
47
- gap: 6px;
37
+ .spacer { flex: 1; }
38
+ header .ctl { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
39
+ input, select {
40
+ font: inherit; font-size: 12px; background: var(--panel-2); color: var(--text);
41
+ border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px;
48
42
  }
43
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
44
+ #search { width: 220px; }
45
+ #conn { font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 6px; white-space: nowrap; }
49
46
  #conn .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--idle); }
50
47
  #conn.up .dot { background: var(--active); }
51
48
  #conn.down .dot { background: var(--terminated); }
52
49
 
53
- #banner {
54
- display: none;
55
- background: #4a1414;
56
- color: #ffb3ad;
57
- border-bottom: 1px solid var(--terminated);
58
- padding: 8px 20px;
59
- font-size: 12px;
60
- }
50
+ #banner { display: none; background: #4a1414; color: #ffb3ad; border-bottom: 1px solid var(--terminated); padding: 8px 18px; font-size: 12px; flex-shrink: 0; }
61
51
  #banner.show { display: block; }
62
52
 
63
- main { padding: 20px; max-width: 1400px; margin: 0 auto; }
64
- h2 {
65
- font-size: 12px;
66
- text-transform: uppercase;
67
- letter-spacing: 1px;
68
- color: var(--muted);
69
- margin: 24px 0 12px;
70
- font-weight: 600;
71
- }
72
- h2:first-child { margin-top: 0; }
73
- .count { color: var(--accent); }
74
-
75
- /* Approvals */
76
- #approvals { display: flex; flex-direction: column; gap: 10px; }
77
- #approvals-empty { color: var(--muted); font-style: italic; }
78
- .approval {
79
- background: var(--panel-2);
80
- border: 1px solid var(--awaiting);
81
- border-radius: 6px;
82
- padding: 12px 14px;
83
- display: flex;
84
- align-items: center;
85
- gap: 14px;
86
- }
87
- .approval .info { flex: 1; min-width: 0; }
88
- .approval .sess { color: var(--awaiting); font-weight: 600; }
89
- .approval .cmd {
90
- margin-top: 4px;
91
- color: var(--text);
92
- background: #0d1117;
93
- border-radius: 4px;
94
- padding: 5px 8px;
95
- overflow-x: auto;
96
- white-space: pre;
97
- }
98
- .approval .tool { color: var(--muted); }
99
-
100
- /* Session grid */
101
- #grid {
102
- display: grid;
103
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
104
- gap: 14px;
105
- }
106
- .card {
107
- background: var(--panel);
108
- border: 1px solid var(--border);
109
- border-left: 3px solid var(--idle);
110
- border-radius: 6px;
111
- padding: 14px;
112
- display: flex;
113
- flex-direction: column;
114
- gap: 10px;
115
- }
116
- .card.s-active { border-left-color: var(--active); }
117
- .card.s-idle { border-left-color: var(--idle); }
118
- .card.s-awaiting_human { border-left-color: var(--awaiting); box-shadow: 0 0 0 1px var(--awaiting), 0 0 14px -4px var(--awaiting); }
119
- .card.s-terminated { border-left-color: var(--terminated); opacity: 0.7; }
120
-
121
- .card .top { display: flex; align-items: flex-start; gap: 8px; }
122
- .card .name { font-weight: 600; font-size: 14px; flex: 1; word-break: break-word; }
123
- .badge {
124
- font-size: 10px;
125
- text-transform: uppercase;
126
- letter-spacing: 0.5px;
127
- padding: 2px 7px;
128
- border-radius: 10px;
129
- white-space: nowrap;
53
+ /* 3-pane layout */
54
+ #layout { flex: 1; display: flex; min-height: 0; }
55
+ .pane { display: flex; flex-direction: column; min-height: 0; }
56
+ #heartbeat { width: 25%; min-width: 240px; border-right: 1px solid var(--border); }
57
+ #center { flex: 1; }
58
+ #approvals-pane { width: 30%; min-width: 280px; border-left: 1px solid var(--border); }
59
+ .pane-head {
60
+ display: flex; align-items: center; gap: 8px; padding: 11px 16px;
61
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted);
62
+ font-weight: 600; border-bottom: 1px solid var(--border); flex-shrink: 0;
130
63
  }
64
+ .pane-head .count { color: var(--accent); }
65
+ .pane-body { flex: 1; overflow-y: auto; padding: 14px 16px; }
66
+
67
+ /* Heartbeat */
68
+ #hb-log { font-family: var(--mono); font-size: 11.5px; line-height: 1.65; }
69
+ .hb-line { display: flex; gap: 7px; padding: 1px 0; white-space: nowrap; overflow: hidden; }
70
+ .hb-time { color: var(--muted); flex-shrink: 0; }
71
+ .hb-sess { color: var(--accent); flex-shrink: 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; }
72
+ .hb-evt { color: var(--text); overflow: hidden; text-overflow: ellipsis; }
73
+ .hb-line.t-prompt .hb-evt { color: var(--active); }
74
+ .hb-line.t-notify .hb-evt { color: var(--awaiting); }
75
+ .hb-line.t-meta .hb-evt { color: var(--muted); }
76
+ .hb-empty { color: var(--muted); font-style: italic; }
77
+
78
+ /* Filters bar (center) */
79
+ #filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
80
+ #filters .lbl { font-size: 11px; color: var(--muted); }
81
+ #date-range { display: none; gap: 6px; align-items: center; }
82
+ #date-range.show { display: flex; }
83
+
84
+ /* Sessions — flat rows */
85
+ #grid { display: flex; flex-direction: column; }
86
+ .srow { border-bottom: 1px solid var(--border); }
87
+ .srow .hdr { display: flex; align-items: center; gap: 10px; padding: 11px 4px; cursor: pointer; }
88
+ .srow .hdr:hover { background: rgba(255,255,255,0.025); }
89
+ .srow .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--idle); flex-shrink: 0; }
90
+ .srow.s-active .dot { background: var(--active); }
91
+ .srow.s-awaiting_human .dot { background: var(--awaiting); box-shadow: 0 0 0 3px rgba(210,153,34,0.25); }
92
+ .srow.s-terminated { opacity: 0.5; }
93
+ .srow.s-terminated .dot { background: var(--terminated); }
94
+ .srow .name { font-weight: 600; font-size: 13.5px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
95
+ .srow .name mark { background: rgba(88,166,255,0.3); color: inherit; border-radius: 2px; }
96
+ .srow .quick { margin-left: auto; color: var(--muted); font-size: 11.5px; white-space: nowrap; display: flex; gap: 9px; align-items: center; }
97
+ .srow .quick .chev { font-size: 9px; width: 9px; }
98
+ .srow .detail { padding: 0 4px 14px 22px; display: flex; flex-direction: column; gap: 10px; }
99
+ .badge { font-size: 10px; letter-spacing: 0.3px; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
131
100
  .badge.persona { background: var(--panel-2); color: var(--accent); border: 1px solid var(--border); }
132
- .badge.state { color: #0d1117; font-weight: 700; }
133
- .badge.state.active { background: var(--active); }
134
- .badge.state.idle { background: var(--idle); }
135
- .badge.state.awaiting_human { background: var(--awaiting); }
136
- .badge.state.terminated { background: var(--terminated); }
137
-
138
- .meta { display: grid; grid-template-columns: auto 1fr; gap: 3px 10px; font-size: 12px; }
101
+ .badge.kind { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
102
+ .lineage { font-size: 11px; color: var(--muted); }
103
+ .meta { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 12px; max-width: 480px; }
139
104
  .meta .k { color: var(--muted); }
140
105
  .meta .v { color: var(--text); word-break: break-all; }
141
- .meta .v.cwd { font-size: 11px; }
106
+ .meta .v.cwd { font-size: 11px; font-family: var(--mono); }
107
+ .actions { display: flex; flex-wrap: wrap; gap: 6px; }
108
+ .actions button { padding: 5px 9px; font-size: 11px; }
109
+
110
+ /* Approvals */
111
+ #approvals-pane .pane-head { color: var(--awaiting); }
112
+ .approval { background: var(--panel-2); border: 1px solid var(--awaiting); border-radius: 8px; padding: 12px; margin-bottom: 10px; }
113
+ .approval .who { font-weight: 600; color: var(--awaiting); }
114
+ .approval .ctx { color: var(--muted); font-size: 12px; margin: 5px 0; }
115
+ .approval .cmd { font-family: var(--mono); font-size: 11.5px; background: #0d1117; border-radius: 5px; padding: 6px 8px; margin: 6px 0; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 120px; overflow-y: auto; }
116
+ .approval .row { display: flex; gap: 8px; margin-top: 8px; }
117
+ .approval .row button { flex: 1; }
118
+ .empty { color: var(--muted); font-style: italic; }
142
119
 
143
- .actions { display: flex; gap: 8px; margin-top: 2px; }
144
120
  button {
145
- font: inherit;
146
- font-size: 12px;
147
- cursor: pointer;
148
- border-radius: 5px;
149
- padding: 6px 12px;
150
- border: 1px solid var(--border);
151
- background: var(--panel-2);
152
- color: var(--text);
153
- transition: background 0.12s, border-color 0.12s;
121
+ font: inherit; font-size: 12px; cursor: pointer; border-radius: 6px; padding: 6px 11px;
122
+ border: 1px solid var(--border); background: var(--panel-2); color: var(--text); transition: background 0.12s;
154
123
  }
155
124
  button:hover { background: #2a3240; }
156
- button:disabled { opacity: 0.5; cursor: default; }
157
125
  button.primary { border-color: var(--active); color: var(--active); }
158
126
  button.primary:hover { background: rgba(63,185,80,0.12); }
159
127
  button.danger { border-color: var(--terminated); color: var(--terminated); }
160
128
  button.danger:hover { background: rgba(248,81,73,0.12); }
161
129
 
162
130
  /* Toasts */
163
- #toasts {
164
- position: fixed;
165
- bottom: 20px;
166
- right: 20px;
167
- display: flex;
168
- flex-direction: column;
169
- gap: 8px;
170
- z-index: 50;
171
- }
172
- .toast {
173
- background: var(--panel-2);
174
- border: 1px solid var(--border);
175
- border-left: 3px solid var(--accent);
176
- border-radius: 6px;
177
- padding: 10px 14px;
178
- font-size: 12px;
179
- max-width: 380px;
180
- box-shadow: 0 4px 16px rgba(0,0,0,0.5);
181
- animation: slidein 0.15s ease-out;
182
- word-break: break-all;
183
- }
131
+ #toasts { position: fixed; bottom: 18px; right: 18px; display: flex; flex-direction: column; gap: 8px; z-index: 50; }
132
+ .toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 6px; padding: 10px 14px; font-size: 12px; max-width: 380px; box-shadow: 0 4px 16px rgba(0,0,0,0.5); }
184
133
  .toast.err { border-left-color: var(--terminated); }
185
- @keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
134
+
135
+ /* Checkpoint modal */
136
+ #modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: center; justify-content: center; z-index: 60; }
137
+ #modal-overlay.show { display: flex; }
138
+ .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; width: min(860px, 92vw); height: min(640px, 86vh); display: flex; flex-direction: column; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }
139
+ .modal > .mhead { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-bottom: 1px solid var(--border); }
140
+ .modal .title { font-weight: 600; flex: 1; }
141
+ .modal .mbody { display: flex; min-height: 0; flex: 1; }
142
+ .modal .cklist { width: 250px; border-right: 1px solid var(--border); overflow-y: auto; padding: 8px; }
143
+ .modal .ckitem { padding: 8px 10px; border-radius: 6px; cursor: pointer; font-size: 12px; border: 1px solid transparent; }
144
+ .modal .ckitem:hover { background: var(--panel-2); }
145
+ .modal .ckitem.sel { background: var(--panel-2); border-color: var(--border); }
146
+ .modal .ckitem .when { color: var(--muted); font-size: 11px; }
147
+ .modal pre { flex: 1; margin: 0; padding: 16px; overflow: auto; white-space: pre-wrap; word-break: break-word; font-size: 12px; line-height: 1.5; font-family: var(--mono); }
148
+ .modal .empty { padding: 16px; font-size: 12px; }
186
149
  </style>
187
150
  </head>
188
151
  <body>
189
152
  <header>
190
153
  <h1><span class="eye">◉</span> WATCHTOWER</h1>
154
+ <input id="search" type="search" placeholder="Search sessions by name…" oninput="setFilter('q', this.value)">
155
+ <div class="spacer"></div>
156
+ <label class="ctl">Fork in
157
+ <select id="terminal-app" onchange="saveTerminalApp(this.value)">
158
+ <option value="auto">Auto</option><option value="terminal">Terminal</option><option value="iterm">iTerm</option>
159
+ </select>
160
+ </label>
161
+ <button class="danger" onclick="cleanupAll()">Clean up all</button>
191
162
  <div id="conn"><span class="dot"></span><span id="conn-text">connecting…</span></div>
192
163
  </header>
193
164
  <div id="banner"></div>
194
165
 
195
- <main>
196
- <h2>Needs human <span class="count" id="appr-count"></span></h2>
197
- <div id="approvals"></div>
198
- <div id="approvals-empty">No pending approvals.</div>
166
+ <div id="layout">
167
+ <!-- LEFT: heartbeat -->
168
+ <div id="heartbeat" class="pane">
169
+ <div class="pane-head">Heartbeat <span class="count" id="hb-count"></span></div>
170
+ <div class="pane-body"><div id="hb-log"><div class="hb-empty">Waiting for activity…</div></div></div>
171
+ </div>
172
+
173
+ <!-- CENTER: sessions -->
174
+ <div id="center" class="pane">
175
+ <div id="filters">
176
+ <span class="lbl">Time</span>
177
+ <select id="f-time" onchange="onTimeChange(this.value)">
178
+ <option value="all">All time</option><option value="today">Today</option>
179
+ <option value="7d">Last 7 days</option><option value="30d">Last 30 days</option>
180
+ <option value="custom">Custom…</option>
181
+ </select>
182
+ <span id="date-range"><input type="date" id="f-from" onchange="setFilter('from', this.value)">
183
+ <span class="lbl">→</span><input type="date" id="f-to" onchange="setFilter('to', this.value)"></span>
184
+ <span class="lbl">Priority</span>
185
+ <select id="f-priority" onchange="setFilter('priority', this.value)">
186
+ <option value="all">All</option><option value="high">High</option><option value="med">Med</option><option value="low">Low</option>
187
+ </select>
188
+ <span class="lbl">Kind</span>
189
+ <select id="f-kind" onchange="setFilter('kind', this.value)">
190
+ <option value="all">All</option><option value="long-running">Long-running</option><option value="outcome">Outcome</option>
191
+ </select>
192
+ </div>
193
+ <div class="pane-head">Sessions <span class="count" id="sess-count"></span></div>
194
+ <div class="pane-body"><div id="grid"></div></div>
195
+ </div>
199
196
 
200
- <h2>Sessions <span class="count" id="sess-count"></span></h2>
201
- <div id="grid"></div>
202
- </main>
197
+ <!-- RIGHT: approvals -->
198
+ <div id="approvals-pane" class="pane">
199
+ <div class="pane-head">Needs human <span class="count" id="appr-count"></span></div>
200
+ <div class="pane-body"><div id="approvals"></div><div id="approvals-empty" class="empty">No pending approvals.</div></div>
201
+ </div>
202
+ </div>
203
203
 
204
204
  <div id="toasts"></div>
205
205
 
206
+ <div id="modal-overlay" onclick="if(event.target===this)closeModal()">
207
+ <div class="modal">
208
+ <div class="mhead">
209
+ <span class="title" id="modal-title">Checkpoints</span>
210
+ <button class="primary" onclick="newCheckpoint()">+ New checkpoint</button>
211
+ <button onclick="closeModal()">Close</button>
212
+ </div>
213
+ <div class="mbody">
214
+ <div class="cklist" id="modal-list"></div>
215
+ <pre id="modal-content"></pre>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
206
220
  <script>
207
221
  const API = "http://localhost:4200";
208
222
  const WS_URL = "ws://localhost:4200/live";
209
223
 
210
- const sessions = new Map(); // id -> session object
211
- const approvals = new Map(); // approval id -> approval object
224
+ const sessions = new Map();
225
+ const approvals = new Map();
226
+ const toggled = new Set(); // session ids whose open/closed state was flipped from default
227
+ const HB_MAX = 200;
212
228
 
213
- const $grid = document.getElementById("grid");
214
- const $approvals = document.getElementById("approvals");
215
- const $apprEmpty = document.getElementById("approvals-empty");
216
- const $banner = document.getElementById("banner");
217
- const $conn = document.getElementById("conn");
218
- const $connText = document.getElementById("conn-text");
229
+ const filters = { q: "", time: "all", from: "", to: "", priority: "all", kind: "all" };
230
+
231
+ const $ = id => document.getElementById(id);
232
+ const $grid = $("grid"), $approvals = $("approvals"), $apprEmpty = $("approvals-empty");
233
+ const $banner = $("banner"), $conn = $("conn"), $hbLog = $("hb-log");
219
234
 
220
235
  // ---------- helpers ----------
221
236
  function esc(s) {
222
- return String(s ?? "").replace(/[&<>"']/g, c => (
223
- { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
224
- ));
237
+ return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
238
+ }
239
+ function toMillis(ts) {
240
+ if (!ts) return NaN;
241
+ if (typeof ts === "number") return ts * (ts < 1e12 ? 1000 : 1);
242
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(ts)) return Date.parse(ts.replace(" ", "T") + "Z");
243
+ return Date.parse(ts);
225
244
  }
226
-
227
245
  function timeAgo(ts) {
228
- if (!ts) return "—";
229
- const t = typeof ts === "number" ? ts * (ts < 1e12 ? 1000 : 1) : Date.parse(ts);
230
- if (isNaN(t)) return esc(ts);
246
+ const t = toMillis(ts); if (isNaN(t)) return "—";
231
247
  const d = Math.floor((Date.now() - t) / 1000);
232
- if (d < 5) return "just now";
233
- if (d < 60) return d + "s ago";
234
- if (d < 3600) return Math.floor(d / 60) + "m ago";
235
- if (d < 86400) return Math.floor(d / 3600) + "h ago";
236
- return Math.floor(d / 86400) + "d ago";
248
+ if (d < 5) return "just now"; if (d < 60) return d + "s ago";
249
+ if (d < 3600) return Math.floor(d/60) + "m ago";
250
+ if (d < 86400) return Math.floor(d/3600) + "h ago";
251
+ return Math.floor(d/86400) + "d ago";
252
+ }
253
+ function fmtDate(ts) { const t = toMillis(ts); return isNaN(t) ? "—" : new Date(t).toLocaleString([], {month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}); }
254
+ function clockTime(t) { return new Date(t).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}); }
255
+ function fmtTokens(s) {
256
+ const t = (s.input_tokens||0) + (s.output_tokens||0);
257
+ if (!t) return "—"; if (t>=1e6) return (t/1e6).toFixed(1)+"M"; if (t>=1e3) return (t/1e3).toFixed(1)+"k"; return String(t);
258
+ }
259
+ function highlight(name, q) {
260
+ const n = esc(name);
261
+ if (!q) return n;
262
+ const i = name.toLowerCase().indexOf(q.toLowerCase());
263
+ if (i < 0) return n;
264
+ return esc(name.slice(0,i)) + "<mark>" + esc(name.slice(i,i+q.length)) + "</mark>" + esc(name.slice(i+q.length));
237
265
  }
238
-
239
266
  function toast(msg, isErr) {
240
267
  const el = document.createElement("div");
241
- el.className = "toast" + (isErr ? " err" : "");
242
- el.textContent = msg;
243
- document.getElementById("toasts").appendChild(el);
244
- setTimeout(() => el.remove(), isErr ? 7000 : 5000);
245
- }
246
-
247
- function showBanner(msg) {
248
- $banner.textContent = msg;
249
- $banner.classList.add("show");
268
+ el.className = "toast" + (isErr ? " err" : ""); el.textContent = msg;
269
+ $("toasts").appendChild(el); setTimeout(() => el.remove(), isErr ? 7000 : 5000);
250
270
  }
271
+ function showBanner(m) { $banner.textContent = m; $banner.classList.add("show"); }
251
272
  function hideBanner() { $banner.classList.remove("show"); }
252
-
253
273
  async function api(path, opts) {
254
274
  const res = await fetch(API + path, opts);
255
275
  if (!res.ok) throw new Error("HTTP " + res.status + " for " + path);
256
- const text = await res.text();
257
- return text ? JSON.parse(text) : null;
276
+ const t = await res.text(); return t ? JSON.parse(t) : null;
258
277
  }
259
-
260
278
  async function post(path, body) {
261
- return api(path, {
262
- method: "POST",
263
- headers: { "Content-Type": "application/json" },
264
- body: body ? JSON.stringify(body) : undefined,
265
- });
279
+ return api(path, { method:"POST", headers:{"Content-Type":"application/json"}, body: body ? JSON.stringify(body) : undefined });
280
+ }
281
+ async function getText(path) { const r = await fetch(API + path); if (!r.ok) throw new Error("HTTP " + r.status); return r.text(); }
282
+
283
+ // ---------- filters ----------
284
+ function setFilter(key, val) { filters[key] = val; renderSessions(); }
285
+ function onTimeChange(val) {
286
+ filters.time = val;
287
+ $("date-range").classList.toggle("show", val === "custom");
288
+ renderSessions();
289
+ }
290
+ function matchesFilters(s) {
291
+ if (filters.q && !String(s.name || s.id).toLowerCase().includes(filters.q.toLowerCase())) return false;
292
+ if (filters.priority !== "all" && s.priority !== filters.priority) return false;
293
+ if (filters.kind !== "all" && s.kind !== filters.kind) return false;
294
+
295
+ const t = toMillis(s.last_event_at || s.started_at);
296
+ if (filters.time === "today") {
297
+ if (isNaN(t)) return false;
298
+ const d = new Date(t), now = new Date();
299
+ if (d.toDateString() !== now.toDateString()) return false;
300
+ } else if (filters.time === "7d" || filters.time === "30d") {
301
+ if (isNaN(t)) return false;
302
+ const days = filters.time === "7d" ? 7 : 30;
303
+ if (Date.now() - t > days * 86400000) return false;
304
+ } else if (filters.time === "custom") {
305
+ if (isNaN(t)) return false;
306
+ if (filters.from && t < Date.parse(filters.from)) return false;
307
+ if (filters.to && t > Date.parse(filters.to) + 86400000) return false; // inclusive end-of-day
308
+ }
309
+ return true;
266
310
  }
267
311
 
268
- // ---------- rendering ----------
312
+ // ---------- sessions ----------
269
313
  function renderSessions() {
270
- const list = [...sessions.values()].sort((a, b) => {
271
- const order = { awaiting_human: 0, active: 1, idle: 2, terminated: 3 };
314
+ const list = [...sessions.values()].filter(matchesFilters).sort((a, b) => {
315
+ const order = { awaiting_human:0, active:1, idle:2, terminated:3 };
272
316
  const sa = order[a.state] ?? 9, sb = order[b.state] ?? 9;
273
317
  if (sa !== sb) return sa - sb;
274
- return (b.priority ?? 0) - (a.priority ?? 0);
318
+ return (toMillis(b.last_event_at || b.started_at) || 0) - (toMillis(a.last_event_at || a.started_at) || 0);
275
319
  });
276
- document.getElementById("sess-count").textContent = list.length ? `(${list.length})` : "";
277
- $grid.innerHTML = list.map(cardHTML).join("") || `<div style="color:var(--muted)">No sessions.</div>`;
320
+ $("sess-count").textContent = list.length ? `(${list.length})` : "";
321
+ $grid.innerHTML = list.map(rowHTML).join("") || `<div class="empty">No sessions match.</div>`;
278
322
  }
279
323
 
280
- function cardHTML(s) {
281
- const title = esc(s.name || s.id);
324
+ // A row is open by default unless terminated; clicking flips that default.
325
+ function isOpen(s) { const def = (s.state || "idle") !== "terminated"; return toggled.has(s.id) ? !def : def; }
326
+ function toggleRow(id) { toggled.has(id) ? toggled.delete(id) : toggled.add(id); renderSessions(); }
327
+
328
+ function rowHTML(s) {
282
329
  const state = s.state || "idle";
283
- return `
284
- <div class="card s-${esc(state)}">
285
- <div class="top">
286
- <div class="name">${title}</div>
287
- ${s.persona ? `<span class="badge persona">${esc(s.persona)}</span>` : ""}
288
- <span class="badge state ${esc(state)}">${esc(state.replace("_", " "))}</span>
289
- </div>
290
- <div class="meta">
291
- <span class="k">priority</span><span class="v">${esc(s.priority ?? "")}</span>
292
- <span class="k">tool calls</span><span class="v">${esc(s.tool_calls ?? 0)}</span>
293
- <span class="k">last</span><span class="v">${timeAgo(s.last_event_at)}</span>
294
- <span class="k">cwd</span><span class="v cwd">${esc(s.cwd || "—")}</span>
295
- </div>
296
- <div class="actions">
297
- <button onclick="checkpoint('${esc(s.id)}')">Checkpoint</button>
298
- <button class="danger" onclick="closeSession('${esc(s.id)}')">Close</button>
330
+ const name = highlight(s.name || s.id, filters.q);
331
+ const open = isOpen(s);
332
+ const tok = (s.input_tokens || 0) + (s.output_tokens || 0);
333
+ const quick = `<span>${esc(state.replace("_", " "))}</span>`
334
+ + (tok ? `<span>${fmtTokens(s)} tok</span>` : "")
335
+ + `<span>${timeAgo(s.last_event_at)}</span>`
336
+ + `<span class="chev">${open ? "▾" : "▸"}</span>`;
337
+ const badges = (s.persona ? `<span class="badge persona">${esc(s.persona)}</span>` : "")
338
+ + (s.kind ? `<span class="badge kind">${esc(s.kind)}</span>` : "");
339
+
340
+ let detail = "";
341
+ if (open) {
342
+ const parent = s.parent_id ? sessions.get(s.parent_id) : null;
343
+ const lineage = s.parent_id
344
+ ? `<div class="lineage">↳ forked from ${esc(parent ? (parent.name || parent.id.slice(0,8)) : s.parent_id.slice(0,8))}</div>` : "";
345
+ const actions = state === "terminated"
346
+ ? `<button class="danger" onclick="deleteSession('${esc(s.id)}')">Delete</button>`
347
+ : `<button onclick="createCheckpoint('${esc(s.id)}')">Checkpoint</button>
348
+ <button onclick="openCheckpoints('${esc(s.id)}')">History</button>
349
+ <button onclick="compact('${esc(s.id)}')">Compact</button>
350
+ <button onclick="fork('${esc(s.id)}')">Fork</button>
351
+ <button class="danger" onclick="closeSession('${esc(s.id)}')">Close</button>
352
+ <button class="danger" onclick="deleteSession('${esc(s.id)}')">Delete</button>`;
353
+ detail = `<div class="detail">
354
+ ${lineage}
355
+ <div class="meta">
356
+ <span class="k">priority</span><span class="v">${esc(s.priority ?? "—")}</span>
357
+ <span class="k">created</span><span class="v">${fmtDate(s.started_at)}</span>
358
+ <span class="k">tool calls</span><span class="v">${esc(s.tool_calls ?? 0)}</span>
359
+ <span class="k">tokens</span><span class="v">${fmtTokens(s)}</span>
360
+ <span class="k">last</span><span class="v">${timeAgo(s.last_event_at)}</span>
361
+ <span class="k">cwd</span><span class="v cwd">${esc(s.cwd || "—")}</span>
362
+ </div>
363
+ <div class="actions">${actions}</div>
364
+ </div>`;
365
+ }
366
+ return `<div class="srow s-${esc(state)}">
367
+ <div class="hdr" onclick="toggleRow('${esc(s.id)}')">
368
+ <span class="dot"></span>
369
+ <span class="name">${name}</span>
370
+ ${badges}
371
+ <span class="quick">${quick}</span>
299
372
  </div>
373
+ ${detail}
300
374
  </div>`;
301
375
  }
302
376
 
377
+ // ---------- approvals ----------
303
378
  function renderApprovals() {
304
379
  const list = [...approvals.values()];
305
- document.getElementById("appr-count").textContent = list.length ? `(${list.length})` : "";
380
+ $("appr-count").textContent = list.length ? `(${list.length})` : "";
306
381
  $apprEmpty.style.display = list.length ? "none" : "block";
307
382
  $approvals.innerHTML = list.map(a => {
308
383
  const s = sessions.get(a.session_id);
309
384
  const who = esc(s ? (s.name || s.id) : a.session_id);
385
+ const ctx = s && s.cwd ? `in ${esc(s.cwd.split("/").slice(-2).join("/"))}` : "";
386
+ const head = a.tool_name
387
+ ? `<span class="who">${who}</span> wants to run <b>${esc(a.tool_name)}</b>`
388
+ : `<span class="who">${who}</span> needs your attention`;
310
389
  return `
311
390
  <div class="approval">
312
- <div class="info">
313
- <div><span class="sess">${who}</span> <span class="tool">wants to run ${esc(a.tool_name || "a tool")}</span></div>
314
- ${a.command ? `<div class="cmd">${esc(a.command)}</div>` : ""}
391
+ <div>${head}</div>
392
+ ${ctx ? `<div class="ctx">${ctx}</div>` : ""}
393
+ ${a.command ? `<div class="cmd">${esc(a.command)}</div>` : ""}
394
+ <div class="row">
395
+ <button class="primary" onclick="decide('${esc(a.id)}','approve')">Approve</button>
396
+ <button class="danger" onclick="decide('${esc(a.id)}','deny')">Deny</button>
315
397
  </div>
316
- <button class="primary" onclick="decide('${esc(a.session_id)}','${esc(a.id)}','approve')">Approve</button>
317
- <button class="danger" onclick="decide('${esc(a.session_id)}','${esc(a.id)}','deny')">Deny</button>
318
398
  </div>`;
319
399
  }).join("");
320
400
  }
321
-
322
- // ---------- actions ----------
323
- async function checkpoint(id) {
401
+ async function decide(approvalId, decision) {
324
402
  try {
325
- const r = await post(`/sessions/${id}/checkpoint`);
326
- toast("Checkpoint saved: " + (r && (r.path || r.checkpoint || JSON.stringify(r)) || "ok"));
327
- } catch (e) { toast("Checkpoint failed: " + e.message, true); }
403
+ const r = await post(`/approvals/${approvalId}/decide`, { decision });
404
+ approvals.delete(Number(approvalId)); approvals.delete(approvalId);
405
+ renderApprovals();
406
+ const tail = r && r.actuated === false ? " (answer in the terminal — not an owned session)" : "";
407
+ toast(`${decision === "approve" ? "Approved" : "Denied"}${tail}`);
408
+ } catch (e) { toast("Decision failed: " + e.message, true); }
409
+ }
410
+
411
+ // ---------- heartbeat ----------
412
+ function pushHeartbeat(ev) {
413
+ const empty = $hbLog.querySelector(".hb-empty"); if (empty) empty.remove();
414
+ const s = sessions.get(ev.session_id);
415
+ const who = s ? (s.name || s.id.slice(0,8)) : (ev.session_id ? ev.session_id.slice(0,8) : "?");
416
+ const et = ev.event_type || "event";
417
+ let cls = "t-meta";
418
+ if (et === "UserPromptSubmit") cls = "t-prompt";
419
+ else if (et === "Notification" || et === "PermissionRequest") cls = "t-notify";
420
+ else if (et.startsWith("PreToolUse") || et.startsWith("PostToolUse")) cls = "";
421
+ const detail = [et, ev.tool_name, ev.command].filter(Boolean).join(" ");
422
+ const line = document.createElement("div");
423
+ line.className = "hb-line " + cls;
424
+ line.innerHTML = `<span class="hb-time">${clockTime(ev.time || Date.now())}</span>`
425
+ + `<span class="hb-sess" title="${esc(who)}">${esc(who)}</span>`
426
+ + `<span class="hb-evt" title="${esc(detail)}">${esc(detail)}</span>`;
427
+ $hbLog.appendChild(line);
428
+ while ($hbLog.children.length > HB_MAX) $hbLog.removeChild($hbLog.firstChild);
429
+ const body = $hbLog.parentElement;
430
+ body.scrollTop = body.scrollHeight;
431
+ $("hb-count").textContent = `(${$hbLog.children.length})`;
432
+ }
433
+
434
+ // ---------- checkpoints modal ----------
435
+ let modalSessionId = null;
436
+ async function openCheckpoints(id) {
437
+ modalSessionId = id; const s = sessions.get(id);
438
+ $("modal-title").textContent = "Checkpoints — " + (s ? (s.name || s.id) : id);
439
+ $("modal-content").textContent = ""; $("modal-overlay").classList.add("show");
440
+ await refreshCheckpointList();
441
+ }
442
+ function closeModal() { $("modal-overlay").classList.remove("show"); modalSessionId = null; }
443
+ async function refreshCheckpointList(selectName) {
444
+ const list = $("modal-list"); let cps;
445
+ try { cps = await api(`/sessions/${modalSessionId}/checkpoints`); }
446
+ catch (e) { list.innerHTML = `<div class="empty">Failed: ${esc(e.message)}</div>`; return; }
447
+ if (!cps || !cps.length) { list.innerHTML = `<div class="empty">No checkpoints yet.</div>`; $("modal-content").textContent = ""; return; }
448
+ list.innerHTML = cps.map(c => `<div class="ckitem" data-name="${esc(c.name)}" onclick="loadCheckpoint('${esc(c.name)}')">
449
+ <div>${esc(c.name)}</div><div class="when">${timeAgo(c.modified)}</div></div>`).join("");
450
+ loadCheckpoint(selectName && cps.some(c => c.name === selectName) ? selectName : cps[0].name);
451
+ }
452
+ async function loadCheckpoint(name) {
453
+ document.querySelectorAll("#modal-list .ckitem").forEach(el => el.classList.toggle("sel", el.dataset.name === name));
454
+ const pane = $("modal-content");
455
+ try { pane.textContent = await getText(`/sessions/${modalSessionId}/checkpoints/${encodeURIComponent(name)}`); }
456
+ catch (e) { pane.textContent = "Failed to load: " + e.message; }
457
+ }
458
+ async function newCheckpoint() {
459
+ if (!modalSessionId) return;
460
+ try { const r = await post(`/sessions/${modalSessionId}/checkpoint`); toast("Checkpoint saved");
461
+ await refreshCheckpointList(r && r.path ? r.path.split("/").pop() : null); }
462
+ catch (e) { toast("Checkpoint failed: " + e.message, true); }
328
463
  }
329
464
 
465
+ // ---------- settings ----------
466
+ async function loadSettings() {
467
+ try { const s = await api("/settings"); if (s && s.terminal_app) $("terminal-app").value = s.terminal_app; } catch {}
468
+ }
469
+ async function saveTerminalApp(value) {
470
+ try { await post("/settings", { key:"terminal_app", value }); toast("Fork opens in: " + value); }
471
+ catch (e) { toast("Couldn't save setting: " + e.message, true); }
472
+ }
473
+
474
+ // ---------- session actions ----------
475
+ async function createCheckpoint(id) {
476
+ try { const r = await post(`/sessions/${id}/checkpoint`); toast("Checkpoint saved: " + (r && r.path ? r.path.split("/").pop() : "ok")); }
477
+ catch (e) { toast("Checkpoint failed: " + e.message, true); }
478
+ }
479
+ async function compact(id) {
480
+ try { await post(`/sessions/${id}/compact`); toast("Sent /compact to the session"); }
481
+ catch (e) { toast("Compact failed: " + e.message, true); }
482
+ }
483
+ async function fork(id) {
484
+ try { const r = await post(`/sessions/${id}/fork`);
485
+ toast("Forked → " + (r.name || r.id) + (r.terminal_opened ? " (opened a terminal)" : " — attach: tmux -L uvs attach -t " + r.tmux_target)); }
486
+ catch (e) { toast("Fork failed: " + e.message, true); }
487
+ }
488
+ async function deleteSession(id) {
489
+ const s = sessions.get(id), who = s ? (s.name || s.id) : id;
490
+ if (!confirm(`Delete "${who}" forever?\nRemoves it from Watchtower (checkpoint files on disk are kept).`)) return;
491
+ try { await api(`/sessions/${id}`, { method:"DELETE" }); sessions.delete(id); renderSessions(); toast("Deleted " + who); }
492
+ catch (e) { toast("Delete failed: " + e.message, true); }
493
+ }
494
+ async function cleanupAll() {
495
+ const n = sessions.size;
496
+ if (!n) { toast("No sessions to clean up"); return; }
497
+ if (!confirm(`Clean up ALL ${n} sessions?`)) return;
498
+ if (!confirm(`Are you sure? This permanently deletes ${n} sessions and their events. This cannot be undone.`)) return;
499
+ try { const r = await post("/sessions/cleanup"); sessions.clear(); approvals.clear(); renderSessions(); renderApprovals();
500
+ toast(`Cleaned up ${r && r.deleted != null ? r.deleted : n} sessions`); }
501
+ catch (e) { toast("Cleanup failed: " + e.message, true); }
502
+ }
330
503
  async function closeSession(id) {
331
504
  const s = sessions.get(id);
332
505
  if (!confirm(`Close session "${s ? (s.name || s.id) : id}"?`)) return;
333
506
  try {
334
- await post(`/sessions/${id}/close`);
335
- toast("Closed session " + id);
507
+ const r = await post(`/sessions/${id}/close`);
508
+ const via = r && r.terminated_via, ck = r && r.checkpoint ? " · checkpoint saved" : "";
509
+ if (via === "tmux" || via === "pid") toast("Closed — process terminated" + ck);
510
+ else toast("Marked terminated" + ck + ". No live process to kill.");
336
511
  } catch (e) { toast("Close failed: " + e.message, true); }
337
512
  }
338
513
 
339
- async function decide(sessionId, approvalId, decision) {
340
- try {
341
- await post(`/sessions/${sessionId}/approve`, { decision });
342
- approvals.delete(approvalId);
343
- renderApprovals();
344
- toast(`${decision === "approve" ? "Approved" : "Denied"} for ${sessionId}`);
345
- } catch (e) { toast("Decision failed: " + e.message, true); }
346
- }
347
-
348
514
  // ---------- initial load ----------
349
515
  async function load() {
350
516
  try {
351
- const [sess, pend] = await Promise.all([
517
+ const [sess, pend, events] = await Promise.all([
352
518
  api("/sessions"),
353
519
  api("/approvals?status=pending").catch(() => []),
520
+ api("/events?limit=60").catch(() => []),
354
521
  ]);
355
- sessions.clear();
356
- (sess || []).forEach(s => sessions.set(s.id, s));
357
- approvals.clear();
358
- (pend || []).forEach(a => approvals.set(a.id, a));
359
- hideBanner();
360
- renderSessions();
361
- renderApprovals();
522
+ sessions.clear(); (sess || []).forEach(s => sessions.set(s.id, s));
523
+ approvals.clear(); (pend || []).forEach(a => approvals.set(a.id, a));
524
+ hideBanner(); renderSessions(); renderApprovals();
525
+ $hbLog.innerHTML = ""; (events || []).forEach(e => pushHeartbeat({ ...e, time: toMillis(e.created_at) || Date.now() }));
526
+ if (!$hbLog.children.length) $hbLog.innerHTML = `<div class="hb-empty">Waiting for activity…</div>`;
362
527
  } catch (e) {
363
528
  showBanner("Cannot reach Watchtower at " + API + " — " + e.message);
364
529
  }
@@ -366,84 +531,47 @@ async function load() {
366
531
 
367
532
  // ---------- live updates ----------
368
533
  let ws = null, retry = 0;
369
-
370
534
  function connect() {
371
- try { ws = new WebSocket(WS_URL); }
372
- catch (e) { scheduleReconnect(); return; }
373
-
535
+ try { ws = new WebSocket(WS_URL); } catch { scheduleReconnect(); return; }
374
536
  ws.onopen = () => { retry = 0; setConn(true); };
375
-
376
- ws.onmessage = ev => {
377
- let msg;
378
- try { msg = JSON.parse(ev.data); } catch { return; }
379
- handleMessage(msg);
380
- };
381
-
537
+ ws.onmessage = ev => { let m; try { m = JSON.parse(ev.data); } catch { return; } handleMessage(m); };
382
538
  ws.onclose = () => { setConn(false); scheduleReconnect(); };
383
539
  ws.onerror = () => { try { ws.close(); } catch {} };
384
540
  }
385
-
386
- function scheduleReconnect() {
387
- retry = Math.min(retry + 1, 6);
388
- const delay = Math.min(1000 * 2 ** retry, 15000);
389
- setTimeout(connect, delay);
390
- }
391
-
392
- function setConn(up) {
393
- $conn.className = up ? "up" : "down";
394
- $connText.textContent = up ? "live" : "reconnecting…";
395
- }
541
+ function scheduleReconnect() { retry = Math.min(retry+1, 6); setTimeout(connect, Math.min(1000 * 2**retry, 15000)); }
542
+ function setConn(up) { $conn.className = up ? "up" : "down"; $("conn-text").textContent = up ? "live" : "reconnecting…"; }
396
543
 
397
544
  function handleMessage(msg) {
398
545
  switch (msg.type) {
399
546
  case "snapshot":
400
- sessions.clear();
401
- (msg.sessions || []).forEach(s => sessions.set(s.id, s));
402
- hideBanner();
403
- renderSessions();
404
- break;
405
-
547
+ sessions.clear(); (msg.sessions || []).forEach(s => sessions.set(s.id, s));
548
+ hideBanner(); renderSessions(); break;
406
549
  case "session": {
407
- const s = msg.session || msg;
408
- const id = s.id || msg.session_id;
409
- if (!id) break;
410
- const prev = sessions.get(id) || {};
411
- sessions.set(id, { ...prev, ...s, id });
412
- renderSessions();
413
- break;
550
+ const s = msg.session || msg, id = s.id || msg.session_id; if (!id) break;
551
+ sessions.set(id, { ...(sessions.get(id) || {}), ...s, id }); renderSessions(); break;
414
552
  }
415
-
553
+ case "session_deleted":
554
+ if (msg.session_id) { sessions.delete(msg.session_id); renderSessions(); } break;
555
+ case "cleanup":
556
+ sessions.clear(); approvals.clear(); renderSessions(); renderApprovals(); break;
416
557
  case "event": {
417
- const id = msg.session_id;
418
- const s = sessions.get(id);
419
- if (s) {
420
- s.tool_calls = (s.tool_calls ?? 0) + 1;
421
- s.last_event_at = msg.created_at || Date.now();
422
- sessions.set(id, s);
423
- renderSessions();
424
- }
558
+ pushHeartbeat({ ...msg, time: Date.now() });
559
+ const s = sessions.get(msg.session_id);
560
+ if (s) { s.tool_calls = (s.tool_calls ?? 0) + (msg.tool_name ? 1 : 0); s.last_event_at = Date.now(); sessions.set(msg.session_id, s); renderSessions(); }
425
561
  break;
426
562
  }
427
-
428
563
  case "approval": {
429
- const a = msg.approval || msg;
430
- const id = a.id || `${msg.session_id}:${Date.now()}`;
431
- if (a.status === "resolved" || a.resolved) {
432
- approvals.delete(id);
433
- } else {
434
- approvals.set(id, { id, session_id: msg.session_id, ...a });
435
- }
436
- renderApprovals();
437
- break;
564
+ const id = msg.id ?? (msg.approval && msg.approval.id);
565
+ if (id == null) break;
566
+ if (msg.status && msg.status !== "pending") approvals.delete(id);
567
+ else approvals.set(id, { id, session_id: msg.session_id, tool_name: msg.tool_name, command: msg.command, request: msg.request });
568
+ renderApprovals(); break;
438
569
  }
439
570
  }
440
571
  }
441
572
 
442
- // refresh relative timestamps
443
573
  setInterval(renderSessions, 15000);
444
-
445
- load();
446
- connect();
574
+ load(); loadSettings(); connect();
447
575
  </script>
448
576
  </body>
449
577
  </html>