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
|
@@ -10,355 +10,520 @@
|
|
|
10
10
|
--panel: #161b22;
|
|
11
11
|
--panel-2: #1c2430;
|
|
12
12
|
--border: #2a3240;
|
|
13
|
-
--text: #
|
|
14
|
-
--muted: #
|
|
13
|
+
--text: #e6edf3;
|
|
14
|
+
--muted: #8b949e;
|
|
15
15
|
--accent: #58a6ff;
|
|
16
16
|
--active: #3fb950;
|
|
17
|
-
--idle: #
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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:
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
color: var(--
|
|
45
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
133
|
-
.
|
|
134
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
196
|
-
|
|
197
|
-
<div id="
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
<div id="
|
|
202
|
-
|
|
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();
|
|
211
|
-
const approvals = new Map();
|
|
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
|
|
214
|
-
|
|
215
|
-
const $
|
|
216
|
-
const $
|
|
217
|
-
const $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
|
-
|
|
224
|
-
|
|
237
|
+
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[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
|
-
|
|
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 <
|
|
234
|
-
if (d <
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
// ----------
|
|
312
|
+
// ---------- sessions ----------
|
|
269
313
|
function renderSessions() {
|
|
270
|
-
const list = [...sessions.values()].sort((a, b) => {
|
|
271
|
-
const order = { awaiting_human:
|
|
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.
|
|
318
|
+
return (toMillis(b.last_event_at || b.started_at) || 0) - (toMillis(a.last_event_at || a.started_at) || 0);
|
|
275
319
|
});
|
|
276
|
-
|
|
277
|
-
$grid.innerHTML = list.map(
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
</
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
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(`/
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
357
|
-
|
|
358
|
-
(
|
|
359
|
-
|
|
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
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
const s = sessions.get(
|
|
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
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
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>
|