fanout-cli 0.7.0 → 0.8.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/dist/cli.js +0 -0
- package/dist/cli.js.map +7 -1
- package/dist/fake-agent.js +0 -0
- package/dist/fake-agent.js.map +7 -0
- package/dist/view.html +863 -0
- package/package.json +14 -11
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/demo.d.ts +0 -32
- package/dist/demo.d.ts.map +0 -1
- package/dist/demo.js +0 -207
- package/dist/demo.js.map +0 -1
- package/dist/format.d.ts +0 -4
- package/dist/format.d.ts.map +0 -1
- package/dist/format.js +0 -64
- package/dist/format.js.map +0 -1
- package/dist/home.d.ts +0 -9
- package/dist/home.d.ts.map +0 -1
- package/dist/home.js +0 -13
- package/dist/home.js.map +0 -1
- package/dist/main.d.ts +0 -21
- package/dist/main.d.ts.map +0 -1
- package/dist/main.js +0 -602
- package/dist/main.js.map +0 -1
- package/dist/unfinished.d.ts +0 -34
- package/dist/unfinished.d.ts.map +0 -1
- package/dist/unfinished.js +0 -84
- package/dist/unfinished.js.map +0 -1
package/dist/view.html
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="referrer" content="no-referrer" />
|
|
7
|
+
<title>fanout</title>
|
|
8
|
+
<style>
|
|
9
|
+
/*
|
|
10
|
+
* Built for people who read terminals all day. Density over decoration, one accent, hairlines instead of
|
|
11
|
+
* shadows, and numbers that do not move sideways while they count. Nothing here is ornamental: if it is on
|
|
12
|
+
* the screen it is a fact about your machine.
|
|
13
|
+
*/
|
|
14
|
+
:root {
|
|
15
|
+
color-scheme: dark;
|
|
16
|
+
--bg: #0b0d10;
|
|
17
|
+
--panel: #11141a;
|
|
18
|
+
--line: #1d222b;
|
|
19
|
+
--line-soft: #161b22;
|
|
20
|
+
--fg: #e6e9ef;
|
|
21
|
+
--fg-dim: #9aa4b2;
|
|
22
|
+
--fg-faint: #626d7d;
|
|
23
|
+
--run: #4c8dff;
|
|
24
|
+
--ok: #3fb950;
|
|
25
|
+
--warn: #d9a441;
|
|
26
|
+
--bad: #f26d6d;
|
|
27
|
+
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
|
28
|
+
--sans: ui-sans-serif, -apple-system, "Segoe UI", Inter, system-ui, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
@media (prefers-color-scheme: light) {
|
|
31
|
+
:root:not([data-theme="dark"]) {
|
|
32
|
+
color-scheme: light;
|
|
33
|
+
--bg: #fbfbfc;
|
|
34
|
+
--panel: #fff;
|
|
35
|
+
--line: #e3e6ea;
|
|
36
|
+
--line-soft: #eef0f3;
|
|
37
|
+
--fg: #14171c;
|
|
38
|
+
--fg-dim: #5b6673;
|
|
39
|
+
--fg-faint: #8b95a3;
|
|
40
|
+
--run: #1f6feb;
|
|
41
|
+
--ok: #1a7f37;
|
|
42
|
+
--warn: #9a6700;
|
|
43
|
+
--bad: #cf222e;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* {
|
|
48
|
+
box-sizing: border-box;
|
|
49
|
+
}
|
|
50
|
+
body {
|
|
51
|
+
margin: 0;
|
|
52
|
+
background: var(--bg);
|
|
53
|
+
color: var(--fg);
|
|
54
|
+
font: 13px/1.5 var(--sans);
|
|
55
|
+
-webkit-font-smoothing: antialiased;
|
|
56
|
+
}
|
|
57
|
+
/* Every number that changes stays in its column instead of jittering as digits swap width. */
|
|
58
|
+
.num,
|
|
59
|
+
.lane,
|
|
60
|
+
code {
|
|
61
|
+
font-family: var(--mono);
|
|
62
|
+
font-variant-numeric: tabular-nums;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
header {
|
|
66
|
+
display: flex;
|
|
67
|
+
align-items: baseline;
|
|
68
|
+
gap: 14px;
|
|
69
|
+
padding: 14px 20px;
|
|
70
|
+
border-bottom: 1px solid var(--line);
|
|
71
|
+
position: sticky;
|
|
72
|
+
top: 0;
|
|
73
|
+
background: var(--bg);
|
|
74
|
+
z-index: 2;
|
|
75
|
+
}
|
|
76
|
+
.brand {
|
|
77
|
+
font-family: var(--mono);
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
letter-spacing: -0.02em;
|
|
80
|
+
}
|
|
81
|
+
.goal {
|
|
82
|
+
color: var(--fg-dim);
|
|
83
|
+
flex: 1;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
}
|
|
89
|
+
.clock {
|
|
90
|
+
color: var(--fg-faint);
|
|
91
|
+
font-size: 12px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
main {
|
|
95
|
+
max-width: 940px;
|
|
96
|
+
margin: 0 auto;
|
|
97
|
+
padding: 20px;
|
|
98
|
+
display: grid;
|
|
99
|
+
gap: 18px;
|
|
100
|
+
}
|
|
101
|
+
section {
|
|
102
|
+
border: 1px solid var(--line);
|
|
103
|
+
border-radius: 6px;
|
|
104
|
+
background: var(--panel);
|
|
105
|
+
overflow: hidden;
|
|
106
|
+
}
|
|
107
|
+
h2 {
|
|
108
|
+
margin: 0;
|
|
109
|
+
padding: 10px 14px;
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
letter-spacing: 0.08em;
|
|
113
|
+
text-transform: uppercase;
|
|
114
|
+
color: var(--fg-faint);
|
|
115
|
+
border-bottom: 1px solid var(--line-soft);
|
|
116
|
+
display: flex;
|
|
117
|
+
gap: 10px;
|
|
118
|
+
align-items: baseline;
|
|
119
|
+
}
|
|
120
|
+
h2 .note {
|
|
121
|
+
font-weight: 400;
|
|
122
|
+
letter-spacing: 0;
|
|
123
|
+
text-transform: none;
|
|
124
|
+
color: var(--fg-faint);
|
|
125
|
+
margin-left: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.runs {
|
|
129
|
+
display: grid;
|
|
130
|
+
grid-template-columns: 14px 118px minmax(120px, 1fr) minmax(0, 1.3fr) 62px;
|
|
131
|
+
align-items: baseline;
|
|
132
|
+
column-gap: 14px;
|
|
133
|
+
}
|
|
134
|
+
.row {
|
|
135
|
+
display: grid;
|
|
136
|
+
grid-column: 1 / -1;
|
|
137
|
+
grid-template-columns: subgrid;
|
|
138
|
+
padding: 8px 14px;
|
|
139
|
+
border-bottom: 1px solid var(--line-soft);
|
|
140
|
+
}
|
|
141
|
+
.row:last-of-type {
|
|
142
|
+
border-bottom: 0;
|
|
143
|
+
}
|
|
144
|
+
.mark {
|
|
145
|
+
font-family: var(--mono);
|
|
146
|
+
}
|
|
147
|
+
.mark.running {
|
|
148
|
+
color: var(--run);
|
|
149
|
+
}
|
|
150
|
+
.mark.done {
|
|
151
|
+
color: var(--ok);
|
|
152
|
+
}
|
|
153
|
+
.mark.bad {
|
|
154
|
+
color: var(--bad);
|
|
155
|
+
}
|
|
156
|
+
.mark.idle {
|
|
157
|
+
color: var(--fg-faint);
|
|
158
|
+
}
|
|
159
|
+
/*
|
|
160
|
+
* The row reads left to right as a sentence: who, what they were asked for, what they are doing. The task
|
|
161
|
+
* carries the weight because it is the thing a person came to the page to follow; the action beside it is
|
|
162
|
+
* the agent's own words and sits back, because it changes every few seconds and must not pull the eye.
|
|
163
|
+
*/
|
|
164
|
+
.who {
|
|
165
|
+
color: var(--fg-dim);
|
|
166
|
+
}
|
|
167
|
+
.task {
|
|
168
|
+
color: var(--fg);
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
text-overflow: ellipsis;
|
|
171
|
+
white-space: nowrap;
|
|
172
|
+
}
|
|
173
|
+
.doing {
|
|
174
|
+
color: var(--fg-faint);
|
|
175
|
+
font-family: var(--mono);
|
|
176
|
+
font-size: 12px;
|
|
177
|
+
/* Truncated rather than wrapped: a row that grows a second line makes the whole list jump as it updates. */
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
text-overflow: ellipsis;
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
}
|
|
182
|
+
.seat,
|
|
183
|
+
.status,
|
|
184
|
+
.phase {
|
|
185
|
+
color: var(--fg-dim);
|
|
186
|
+
}
|
|
187
|
+
/* Four cells, one per named phase. Filled cells say where a run is, never how much of it is left. */
|
|
188
|
+
.bar {
|
|
189
|
+
display: inline-flex;
|
|
190
|
+
gap: 3px;
|
|
191
|
+
}
|
|
192
|
+
.bar i {
|
|
193
|
+
width: 9px;
|
|
194
|
+
height: 3px;
|
|
195
|
+
border-radius: 1px;
|
|
196
|
+
background: var(--line);
|
|
197
|
+
}
|
|
198
|
+
.bar i.on {
|
|
199
|
+
background: var(--run);
|
|
200
|
+
}
|
|
201
|
+
.done .bar i.on {
|
|
202
|
+
background: var(--ok);
|
|
203
|
+
}
|
|
204
|
+
.elapsed {
|
|
205
|
+
text-align: right;
|
|
206
|
+
color: var(--fg-dim);
|
|
207
|
+
}
|
|
208
|
+
.diff {
|
|
209
|
+
color: var(--fg-faint);
|
|
210
|
+
display: flex;
|
|
211
|
+
gap: 12px;
|
|
212
|
+
}
|
|
213
|
+
/*
|
|
214
|
+
* Unscoped, because these live inside the action cell now rather than a `.diff` wrapper. Scoping them to a
|
|
215
|
+
* parent that no longer exists left every diff painted the faint grey of the text around it.
|
|
216
|
+
*/
|
|
217
|
+
.add {
|
|
218
|
+
color: var(--ok);
|
|
219
|
+
}
|
|
220
|
+
.del {
|
|
221
|
+
color: var(--bad);
|
|
222
|
+
}
|
|
223
|
+
.quiet {
|
|
224
|
+
color: var(--warn);
|
|
225
|
+
}
|
|
226
|
+
/* Subordinate to the run above it: indented to the id column and never louder than the row it belongs to. */
|
|
227
|
+
/*
|
|
228
|
+
* A seat the plan did not ask for. The arrow is on the row so the substitution is visible at a glance, and
|
|
229
|
+
* the reason sits under it in the router's own words — a row that silently said `claude` under a plan that
|
|
230
|
+
* said `codex` is how someone stops trusting the rest of the screen.
|
|
231
|
+
*/
|
|
232
|
+
.moved {
|
|
233
|
+
color: var(--fg-faint);
|
|
234
|
+
white-space: nowrap;
|
|
235
|
+
}
|
|
236
|
+
.reroute {
|
|
237
|
+
grid-column: 2 / -1;
|
|
238
|
+
color: var(--warn);
|
|
239
|
+
padding: 0 0 9px;
|
|
240
|
+
margin-top: -3px;
|
|
241
|
+
font-family: var(--mono);
|
|
242
|
+
font-size: 12px;
|
|
243
|
+
}
|
|
244
|
+
.reroute::before {
|
|
245
|
+
content: "↳ moved: ";
|
|
246
|
+
color: var(--fg-faint);
|
|
247
|
+
}
|
|
248
|
+
.tool {
|
|
249
|
+
grid-column: 2 / -1;
|
|
250
|
+
color: var(--fg-faint);
|
|
251
|
+
padding: 0 0 9px;
|
|
252
|
+
margin-top: -3px;
|
|
253
|
+
font-family: var(--mono);
|
|
254
|
+
font-size: 12px;
|
|
255
|
+
border-bottom: 1px solid var(--line-soft);
|
|
256
|
+
}
|
|
257
|
+
.tool::before {
|
|
258
|
+
content: "└ ";
|
|
259
|
+
color: var(--line);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* The claims are the point of the product, so they get room that the tables do not. */
|
|
263
|
+
.claim {
|
|
264
|
+
display: grid;
|
|
265
|
+
grid-template-columns: 16px 1fr;
|
|
266
|
+
gap: 10px;
|
|
267
|
+
padding: 10px 14px;
|
|
268
|
+
border-bottom: 1px solid var(--line-soft);
|
|
269
|
+
}
|
|
270
|
+
.claim:last-child {
|
|
271
|
+
border-bottom: 0;
|
|
272
|
+
}
|
|
273
|
+
.claim .v {
|
|
274
|
+
font-family: var(--mono);
|
|
275
|
+
font-weight: 600;
|
|
276
|
+
}
|
|
277
|
+
.claim.confirmed .v {
|
|
278
|
+
color: var(--ok);
|
|
279
|
+
}
|
|
280
|
+
.claim.refuted .v {
|
|
281
|
+
color: var(--bad);
|
|
282
|
+
}
|
|
283
|
+
.claim.unclear .v {
|
|
284
|
+
color: var(--warn);
|
|
285
|
+
}
|
|
286
|
+
.claim .what {
|
|
287
|
+
font-weight: 500;
|
|
288
|
+
}
|
|
289
|
+
.claim.refuted .what {
|
|
290
|
+
color: var(--fg);
|
|
291
|
+
}
|
|
292
|
+
.claim .why {
|
|
293
|
+
color: var(--fg-dim);
|
|
294
|
+
font-family: var(--mono);
|
|
295
|
+
font-size: 12px;
|
|
296
|
+
margin-top: 3px;
|
|
297
|
+
}
|
|
298
|
+
.claim.refuted {
|
|
299
|
+
background: color-mix(in oklab, var(--bad) 7%, transparent);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/*
|
|
303
|
+
* What is waiting on a person. It sits above the runs because it is the only thing on this page that asks
|
|
304
|
+
* something of whoever is reading it.
|
|
305
|
+
*/
|
|
306
|
+
.waiting {
|
|
307
|
+
display: grid;
|
|
308
|
+
grid-template-columns: 16px minmax(120px, auto) 1fr auto;
|
|
309
|
+
gap: 4px 12px;
|
|
310
|
+
padding: 10px 14px;
|
|
311
|
+
border-bottom: 1px solid var(--line-soft);
|
|
312
|
+
align-items: baseline;
|
|
313
|
+
}
|
|
314
|
+
.waiting:last-child {
|
|
315
|
+
border-bottom: 0;
|
|
316
|
+
}
|
|
317
|
+
.waiting .mark {
|
|
318
|
+
font-weight: 600;
|
|
319
|
+
}
|
|
320
|
+
.waiting.ready .mark {
|
|
321
|
+
color: var(--ok);
|
|
322
|
+
}
|
|
323
|
+
.waiting.blocked .mark {
|
|
324
|
+
color: var(--warn);
|
|
325
|
+
}
|
|
326
|
+
/* Prose now, not an id: this column holds what the work was, and monospace made it read like a filename. */
|
|
327
|
+
.waiting .who {
|
|
328
|
+
color: var(--fg);
|
|
329
|
+
}
|
|
330
|
+
.waiting .what {
|
|
331
|
+
color: var(--fg-dim);
|
|
332
|
+
}
|
|
333
|
+
/*
|
|
334
|
+
* The page's only control, and it is meant to look like one: a real button, not a link pretending. It sits
|
|
335
|
+
* at the end of the row it belongs to, so nobody can approve one run while reading another's name.
|
|
336
|
+
*/
|
|
337
|
+
.approve {
|
|
338
|
+
font: inherit;
|
|
339
|
+
font-size: 12px;
|
|
340
|
+
color: var(--fg);
|
|
341
|
+
background: var(--panel);
|
|
342
|
+
border: 1px solid var(--line);
|
|
343
|
+
border-radius: 5px;
|
|
344
|
+
padding: 4px 11px;
|
|
345
|
+
cursor: pointer;
|
|
346
|
+
}
|
|
347
|
+
.approve:hover:not(:disabled) {
|
|
348
|
+
border-color: var(--fg-faint);
|
|
349
|
+
}
|
|
350
|
+
/* Armed: the next click writes to the ledger, and the ledger cannot be edited afterwards. */
|
|
351
|
+
.approve.armed {
|
|
352
|
+
color: var(--bg);
|
|
353
|
+
background: var(--ok);
|
|
354
|
+
border-color: var(--ok);
|
|
355
|
+
font-weight: 600;
|
|
356
|
+
}
|
|
357
|
+
.approve.failed {
|
|
358
|
+
color: var(--bad);
|
|
359
|
+
border-color: var(--bad);
|
|
360
|
+
background: transparent;
|
|
361
|
+
}
|
|
362
|
+
.approve:disabled {
|
|
363
|
+
cursor: default;
|
|
364
|
+
color: var(--fg-faint);
|
|
365
|
+
}
|
|
366
|
+
.approve:focus-visible {
|
|
367
|
+
outline: 2px solid var(--run);
|
|
368
|
+
outline-offset: 2px;
|
|
369
|
+
}
|
|
370
|
+
.waiting .blockers {
|
|
371
|
+
grid-column: 2 / -1;
|
|
372
|
+
color: var(--fg-dim);
|
|
373
|
+
font-size: 12px;
|
|
374
|
+
}
|
|
375
|
+
.waiting .blockers li {
|
|
376
|
+
margin: 2px 0 0;
|
|
377
|
+
}
|
|
378
|
+
.waiting ul {
|
|
379
|
+
margin: 0;
|
|
380
|
+
padding-left: 16px;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.crew {
|
|
384
|
+
display: flex;
|
|
385
|
+
flex-wrap: wrap;
|
|
386
|
+
gap: 8px 18px;
|
|
387
|
+
padding: 10px 14px;
|
|
388
|
+
}
|
|
389
|
+
.seatchip {
|
|
390
|
+
display: flex;
|
|
391
|
+
gap: 7px;
|
|
392
|
+
align-items: baseline;
|
|
393
|
+
font-family: var(--mono);
|
|
394
|
+
font-size: 12px;
|
|
395
|
+
}
|
|
396
|
+
.dot {
|
|
397
|
+
width: 6px;
|
|
398
|
+
height: 6px;
|
|
399
|
+
border-radius: 50%;
|
|
400
|
+
background: var(--fg-faint);
|
|
401
|
+
display: inline-block;
|
|
402
|
+
}
|
|
403
|
+
.dot.ready {
|
|
404
|
+
background: var(--ok);
|
|
405
|
+
}
|
|
406
|
+
.dot.off {
|
|
407
|
+
background: var(--fg-faint);
|
|
408
|
+
opacity: 0.4;
|
|
409
|
+
}
|
|
410
|
+
.seatchip .meta {
|
|
411
|
+
color: var(--fg-faint);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.empty {
|
|
415
|
+
padding: 22px 14px;
|
|
416
|
+
color: var(--fg-dim);
|
|
417
|
+
}
|
|
418
|
+
.empty code {
|
|
419
|
+
color: var(--fg);
|
|
420
|
+
background: var(--line-soft);
|
|
421
|
+
padding: 1px 5px;
|
|
422
|
+
border-radius: 3px;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/*
|
|
426
|
+
* A failed load must never look like an empty one. This bar is the difference between "nothing is happening"
|
|
427
|
+
* and "I cannot see what is happening", and the sixth non-negotiable turns on it.
|
|
428
|
+
*/
|
|
429
|
+
#offline {
|
|
430
|
+
display: none;
|
|
431
|
+
padding: 9px 20px;
|
|
432
|
+
background: color-mix(in oklab, var(--bad) 16%, var(--bg));
|
|
433
|
+
border-bottom: 1px solid var(--bad);
|
|
434
|
+
color: var(--fg);
|
|
435
|
+
font-family: var(--mono);
|
|
436
|
+
font-size: 12px;
|
|
437
|
+
}
|
|
438
|
+
body.stale #offline {
|
|
439
|
+
display: block;
|
|
440
|
+
}
|
|
441
|
+
body.stale main {
|
|
442
|
+
opacity: 0.5;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.sim {
|
|
446
|
+
display: inline-block;
|
|
447
|
+
margin-left: 8px;
|
|
448
|
+
padding: 1px 6px;
|
|
449
|
+
border: 1px solid var(--warn);
|
|
450
|
+
border-radius: 3px;
|
|
451
|
+
color: var(--warn);
|
|
452
|
+
font-size: 10px;
|
|
453
|
+
letter-spacing: 0.06em;
|
|
454
|
+
text-transform: uppercase;
|
|
455
|
+
vertical-align: 1px;
|
|
456
|
+
}
|
|
457
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
458
|
+
.mark.running {
|
|
459
|
+
animation: pulse 2s ease-in-out infinite;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
@keyframes pulse {
|
|
463
|
+
50% {
|
|
464
|
+
opacity: 0.45;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
</style>
|
|
468
|
+
</head>
|
|
469
|
+
<body>
|
|
470
|
+
<div id="offline"></div>
|
|
471
|
+
<header>
|
|
472
|
+
<span class="brand">fanout</span>
|
|
473
|
+
<span class="goal" id="goal"></span>
|
|
474
|
+
<span class="clock num" id="clock"></span>
|
|
475
|
+
</header>
|
|
476
|
+
<!--
|
|
477
|
+
What the page says before its first answer arrives.
|
|
478
|
+
Replaced by `draw` the moment there is a snapshot, and left alone if the daemon never answers — at which
|
|
479
|
+
point #offline says so. A screen that renders nothing cannot tell "still asking" from "nothing here", and
|
|
480
|
+
honest UI is the sixth non-negotiable rather than a nicety.
|
|
481
|
+
-->
|
|
482
|
+
<main id="main"><div class="empty">Asking the daemon what is happening…</div></main>
|
|
483
|
+
|
|
484
|
+
<script>
|
|
485
|
+
// Stamped into the page by the daemon: a browser cannot put an Authorization header on a navigation.
|
|
486
|
+
const TOKEN = "{{TOKEN}}";
|
|
487
|
+
|
|
488
|
+
/*
|
|
489
|
+
* What the approve button is in the middle of, per run.
|
|
490
|
+
*
|
|
491
|
+
* It lives out here because the page redraws itself four times a second from a whole snapshot, which throws
|
|
492
|
+
* away every element on it. A button that kept "are you sure?" in its own closure disarmed itself within
|
|
493
|
+
* 250ms and the second click landed on a fresh button — found by clicking it, not by reading it.
|
|
494
|
+
*/
|
|
495
|
+
const pending = new Map();
|
|
496
|
+
|
|
497
|
+
const el = (tag, cls, text) => {
|
|
498
|
+
const node = document.createElement(tag);
|
|
499
|
+
if (cls) node.className = cls;
|
|
500
|
+
if (text !== undefined) node.textContent = text;
|
|
501
|
+
return node;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const PHASES = ["reading", "coding", "testing", "reporting"];
|
|
505
|
+
const phaseBar = (phase) => {
|
|
506
|
+
const reached = phase ? PHASES.indexOf(phase) + 1 : 0;
|
|
507
|
+
const bar = el("span", "bar");
|
|
508
|
+
bar.title = phase ?? "no phase reported";
|
|
509
|
+
for (let i = 0; i < PHASES.length; i += 1) bar.append(el("i", i < reached ? "on" : null));
|
|
510
|
+
return bar;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/** Rounds down, always: a run is in the second it has finished, not the one it is working on. */
|
|
514
|
+
const duration = (ms) => {
|
|
515
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
516
|
+
const s = String(total % 60).padStart(2, "0");
|
|
517
|
+
const m = Math.floor(total / 60) % 60;
|
|
518
|
+
const h = Math.floor(total / 3600);
|
|
519
|
+
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${s}`;
|
|
520
|
+
return `${m}:${s}`;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const MARK = {
|
|
524
|
+
queued: ["◌", "idle"],
|
|
525
|
+
running: ["●", "running"],
|
|
526
|
+
done: ["✓", "done"],
|
|
527
|
+
merged: ["✓", "done"],
|
|
528
|
+
failed: ["✗", "bad"],
|
|
529
|
+
killed: ["✗", "bad"],
|
|
530
|
+
timeout: ["✗", "bad"],
|
|
531
|
+
conflict: ["!", "bad"],
|
|
532
|
+
dropped: ["·", "idle"],
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
let snapshot = null;
|
|
536
|
+
let lastOk = 0;
|
|
537
|
+
|
|
538
|
+
/*
|
|
539
|
+
* One agent, described the way a person watching would describe it — the same shape the terminal uses, on
|
|
540
|
+
* purpose. Who is working, what they were asked for, and what they are doing right now in their own words.
|
|
541
|
+
*
|
|
542
|
+
* The run id used to be the first thing on the row. It is the one piece here nobody reading a live mission
|
|
543
|
+
* wants: it identifies the run to the tools, not the work to a person. It moved to the row's tooltip, where
|
|
544
|
+
* it is still there when you are debugging and out of the way when you are not.
|
|
545
|
+
*/
|
|
546
|
+
function runRow(run, mission, seats, now) {
|
|
547
|
+
const wrap = document.createDocumentFragment();
|
|
548
|
+
const [glyph, tone] = MARK[run.status] ?? ["·", "idle"];
|
|
549
|
+
const row = el("div", `row ${tone}`);
|
|
550
|
+
row.title = `${run.runId} · ${run.status}${run.phase ? ` · ${run.phase}` : ""}`;
|
|
551
|
+
row.append(el("span", `mark ${tone}`, glyph));
|
|
552
|
+
|
|
553
|
+
const crew = seats.find((seat) => seat.id === run.seat.id);
|
|
554
|
+
const who = el("span", "who", crew ? crew.displayName : run.seat.id);
|
|
555
|
+
if (run.movedFrom) who.append(el("span", "moved", ` ←${run.movedFrom.seat}`));
|
|
556
|
+
row.append(who);
|
|
557
|
+
|
|
558
|
+
const line = (mission.plan?.lines ?? []).find((entry) => entry.id === run.lineId);
|
|
559
|
+
row.append(el("span", "task", line ? line.title : run.lineId));
|
|
560
|
+
|
|
561
|
+
/*
|
|
562
|
+
* What it is doing, in its own words, replaced by what it produced once it stops. A phase name says
|
|
563
|
+
* "coding"; the agent says "edit add the csv writer", and that is the thing worth a person's attention.
|
|
564
|
+
*/
|
|
565
|
+
const finished = run.endedAt !== null;
|
|
566
|
+
|
|
567
|
+
// Computed here from a real stamp, so the clock ticks without the daemon being asked again.
|
|
568
|
+
const started = run.startedAt ? Date.parse(run.startedAt) : null;
|
|
569
|
+
const ended = run.endedAt ? Date.parse(run.endedAt) : null;
|
|
570
|
+
const elapsed = started === null ? null : Math.max(0, (ended ?? now) - started);
|
|
571
|
+
|
|
572
|
+
/*
|
|
573
|
+
* One cell, holding whichever of the two is true right now: what the agent is doing, or what it produced.
|
|
574
|
+
* They never appear together, and giving each its own column left a finished row one cell short — the
|
|
575
|
+
* grid shifted everything after it and the diff wrapped onto a second line. Every row has the same cells.
|
|
576
|
+
*/
|
|
577
|
+
const said = el("span", "doing");
|
|
578
|
+
if (run.diffStat) {
|
|
579
|
+
said.append(el("span", "add", `+${run.diffStat.insertions}`));
|
|
580
|
+
said.append(document.createTextNode(" "));
|
|
581
|
+
said.append(el("span", "del", `−${run.diffStat.deletions}`));
|
|
582
|
+
} else if (run.files.length > 0) {
|
|
583
|
+
said.append(el("span", "quiet", `${run.files.length} file${run.files.length === 1 ? "" : "s"}`));
|
|
584
|
+
} else if (finished) {
|
|
585
|
+
/*
|
|
586
|
+
* A finished run with no diff recorded is not a run that changed nothing — it is a run whose diff
|
|
587
|
+
* nobody measured, which is what happens to a reworked run continuing in an earlier worktree. Saying
|
|
588
|
+
* "no changes" there is the exact failure the sixth non-negotiable names: a zero that reads as a fact.
|
|
589
|
+
*/
|
|
590
|
+
const unknown = el("span", "quiet", "—");
|
|
591
|
+
unknown.title = "no diff was recorded for this run";
|
|
592
|
+
said.append(unknown);
|
|
593
|
+
} else {
|
|
594
|
+
said.textContent = run.lastTool
|
|
595
|
+
? `${run.lastTool.tool}${run.lastTool.summary ? ` ${run.lastTool.summary}` : ""}`
|
|
596
|
+
: (run.phase ?? "starting up");
|
|
597
|
+
}
|
|
598
|
+
// Only a run that is actually working can be quiet; a queued one has not started and a finished one is over.
|
|
599
|
+
if (started !== null && ended === null) {
|
|
600
|
+
const silent = now - Date.parse(run.updatedAt);
|
|
601
|
+
if (silent >= 60000) said.append(el("span", "quiet", ` · quiet ${duration(silent)}`));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
row.append(said);
|
|
605
|
+
row.append(el("span", "elapsed num", elapsed === null ? "—" : duration(elapsed)));
|
|
606
|
+
wrap.append(row);
|
|
607
|
+
|
|
608
|
+
if (run.movedFrom) wrap.append(el("div", "reroute", run.movedFrom.reason));
|
|
609
|
+
|
|
610
|
+
return wrap;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/*
|
|
614
|
+
* The blockers are the daemon's words, not ours. `mergeReadiness` is the gate's judgement and there is one
|
|
615
|
+
* of it; a page that worked out its own answer would eventually tell someone a run was ready while the
|
|
616
|
+
* merge tool refused it.
|
|
617
|
+
*/
|
|
618
|
+
function waitingSection(waiting) {
|
|
619
|
+
const section = el("section");
|
|
620
|
+
const head = el("h2");
|
|
621
|
+
head.append(document.createTextNode("Waiting on you"));
|
|
622
|
+
const ready = waiting.filter((item) => item.ready).length;
|
|
623
|
+
if (waiting.length > 0) {
|
|
624
|
+
head.append(el("span", "note", ready > 0 ? `${ready} ready to merge` : "none ready yet"));
|
|
625
|
+
}
|
|
626
|
+
section.append(head);
|
|
627
|
+
|
|
628
|
+
if (waiting.length === 0) {
|
|
629
|
+
section.append(
|
|
630
|
+
el("div", "empty", "Nothing finished is waiting. Runs appear here when an agent stops."),
|
|
631
|
+
);
|
|
632
|
+
return section;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Ready first: they are the ones a person can act on in a second.
|
|
636
|
+
for (const item of [...waiting].sort((a, b) => Number(b.ready) - Number(a.ready))) {
|
|
637
|
+
const row = el("div", `waiting ${item.ready ? "ready" : "blocked"}`);
|
|
638
|
+
row.append(el("span", "mark", item.ready ? "✓" : "…"));
|
|
639
|
+
// The work, not the run id — the id is for the tools, and is on the row for anyone who needs it.
|
|
640
|
+
row.title = item.runId;
|
|
641
|
+
row.append(el("span", "who", item.task ?? item.runId));
|
|
642
|
+
row.append(
|
|
643
|
+
el(
|
|
644
|
+
"span",
|
|
645
|
+
"what",
|
|
646
|
+
item.ready
|
|
647
|
+
? "approved · ask Claude to merge"
|
|
648
|
+
: item.approvable
|
|
649
|
+
? `${item.seat} · reviewed and checked`
|
|
650
|
+
: `${item.seat} · needs work`,
|
|
651
|
+
),
|
|
652
|
+
);
|
|
653
|
+
if (item.approvable) row.append(approveButton(item));
|
|
654
|
+
if (!item.ready && !item.approvable) {
|
|
655
|
+
const list = el("ul");
|
|
656
|
+
for (const blocker of item.blockers) list.append(el("li", null, blocker));
|
|
657
|
+
const wrap = el("div", "blockers");
|
|
658
|
+
wrap.append(list);
|
|
659
|
+
row.append(wrap);
|
|
660
|
+
}
|
|
661
|
+
section.append(row);
|
|
662
|
+
}
|
|
663
|
+
return section;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/*
|
|
667
|
+
* The one control on this page, and the only place a person acts on the ledger without an agent in between.
|
|
668
|
+
*
|
|
669
|
+
* Two clicks rather than one. A single stray click would record the strongest evidence the gate accepts —
|
|
670
|
+
* and undoing it is not possible, because the ledger is append-only. The second click is not a dialog: a
|
|
671
|
+
* modal here would block every other update on the page while it waited.
|
|
672
|
+
*
|
|
673
|
+
* It approves and says so. It does not merge: the commit message has to follow the repository's own
|
|
674
|
+
* convention, which the lead writes, and a page that claimed to merge and then did not would be worse than
|
|
675
|
+
* a page that never offered.
|
|
676
|
+
*/
|
|
677
|
+
function approveButton(item) {
|
|
678
|
+
const button = el("button", "approve");
|
|
679
|
+
const state = pending.get(item.runId);
|
|
680
|
+
|
|
681
|
+
if (state?.failed) {
|
|
682
|
+
button.classList.add("failed");
|
|
683
|
+
button.textContent = state.failed;
|
|
684
|
+
} else if (state?.sending) {
|
|
685
|
+
button.disabled = true;
|
|
686
|
+
button.textContent = "Approving…";
|
|
687
|
+
} else if (state?.armedUntil > Date.now()) {
|
|
688
|
+
button.classList.add("armed");
|
|
689
|
+
button.textContent = "Approve — sure?";
|
|
690
|
+
} else {
|
|
691
|
+
button.textContent = "Approve";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
button.addEventListener("click", async () => {
|
|
695
|
+
const now = Date.now();
|
|
696
|
+
if (!(pending.get(item.runId)?.armedUntil > now)) {
|
|
697
|
+
// Arms for five seconds and then falls back on its own, so a page left open holds no live yes.
|
|
698
|
+
pending.set(item.runId, { armedUntil: now + 5000 });
|
|
699
|
+
draw();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
pending.set(item.runId, { sending: true });
|
|
704
|
+
draw();
|
|
705
|
+
try {
|
|
706
|
+
const response = await fetch("/approve", {
|
|
707
|
+
method: "POST",
|
|
708
|
+
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
|
|
709
|
+
body: JSON.stringify({ missionId: item.missionId, runId: item.runId }),
|
|
710
|
+
});
|
|
711
|
+
if (!response.ok) {
|
|
712
|
+
// The daemon's own words. A page that invented a friendlier message would be inventing a reason.
|
|
713
|
+
const body = await response.json().catch(() => ({}));
|
|
714
|
+
throw new Error(body.error ?? `the daemon answered ${response.status}`);
|
|
715
|
+
}
|
|
716
|
+
pending.delete(item.runId);
|
|
717
|
+
// Straight back to the daemon rather than waiting for the next tick: the row must change now.
|
|
718
|
+
await poll();
|
|
719
|
+
} catch (error) {
|
|
720
|
+
pending.set(item.runId, { failed: String(error.message ?? error) });
|
|
721
|
+
}
|
|
722
|
+
draw();
|
|
723
|
+
});
|
|
724
|
+
return button;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function claimsSection(check, now) {
|
|
728
|
+
const section = el("section");
|
|
729
|
+
const head = el("h2");
|
|
730
|
+
head.append(document.createTextNode("Claims"));
|
|
731
|
+
if (check?.simulated) head.append(el("span", "sim", "simulated"));
|
|
732
|
+
const note = el("span", "note");
|
|
733
|
+
if (check) {
|
|
734
|
+
// "0:00 ago" is technically true and reads as broken; under a minute, people say "just now".
|
|
735
|
+
const since = now - Date.parse(check.at);
|
|
736
|
+
note.textContent = `read cold by ${check.by.id} · ${since < 60000 ? "just now" : `${duration(since)} ago`}`;
|
|
737
|
+
}
|
|
738
|
+
head.append(note);
|
|
739
|
+
section.append(head);
|
|
740
|
+
|
|
741
|
+
if (!check) {
|
|
742
|
+
const empty = el("div", "empty");
|
|
743
|
+
empty.append(document.createTextNode("Nothing checked yet. State what you believe: "));
|
|
744
|
+
empty.append(el("code", null, 'fanout check "…"'));
|
|
745
|
+
section.append(empty);
|
|
746
|
+
return section;
|
|
747
|
+
}
|
|
748
|
+
if (!check.ran) {
|
|
749
|
+
section.append(el("div", "empty", "The reader could not run, so nothing here was checked."));
|
|
750
|
+
return section;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Refutations first: they are the only ones that ask anything of the reader.
|
|
754
|
+
const order = { refuted: 0, unclear: 1, confirmed: 2 };
|
|
755
|
+
const glyph = { confirmed: "✓", refuted: "✗", unclear: "?" };
|
|
756
|
+
for (const claim of [...check.claims].sort((a, b) => order[a.verdict] - order[b.verdict])) {
|
|
757
|
+
const row = el("div", `claim ${claim.verdict}`);
|
|
758
|
+
row.append(el("span", "v", glyph[claim.verdict]));
|
|
759
|
+
const body = el("div");
|
|
760
|
+
body.append(el("div", "what", claim.claim));
|
|
761
|
+
body.append(el("div", "why", claim.evidence));
|
|
762
|
+
row.append(body);
|
|
763
|
+
section.append(row);
|
|
764
|
+
}
|
|
765
|
+
return section;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function draw() {
|
|
769
|
+
const now = Date.now();
|
|
770
|
+
document.getElementById("clock").textContent = new Date(now).toTimeString().slice(0, 8);
|
|
771
|
+
|
|
772
|
+
// Say it is stale rather than letting an old picture pass for a current one.
|
|
773
|
+
document.body.classList.toggle("stale", lastOk !== 0 && now - lastOk > 4000);
|
|
774
|
+
// Before the first answer the page keeps its opening line; only a snapshot may replace it.
|
|
775
|
+
if (!snapshot) return;
|
|
776
|
+
|
|
777
|
+
const { state, seats } = snapshot;
|
|
778
|
+
const missions = Object.values(state.missions);
|
|
779
|
+
const mission = missions.find((m) => m.status === "running") ?? missions.at(-1) ?? null;
|
|
780
|
+
document.getElementById("goal").textContent = mission ? mission.goal : "";
|
|
781
|
+
|
|
782
|
+
const main = document.getElementById("main");
|
|
783
|
+
main.replaceChildren();
|
|
784
|
+
|
|
785
|
+
const crew = el("section");
|
|
786
|
+
crew.append(el("h2", null, "Crew"));
|
|
787
|
+
const chips = el("div", "crew");
|
|
788
|
+
for (const seat of seats) {
|
|
789
|
+
const chip = el("span", "seatchip");
|
|
790
|
+
const ready = seat.supported && seat.signedIn === "yes";
|
|
791
|
+
chip.append(el("span", `dot ${ready ? "ready" : "off"}`));
|
|
792
|
+
chip.append(el("span", null, seat.id));
|
|
793
|
+
const meta = [seat.version ?? "not installed", seat.plan ? seat.plan.name : null]
|
|
794
|
+
.filter(Boolean)
|
|
795
|
+
.join(" · ");
|
|
796
|
+
chip.append(el("span", "meta", meta));
|
|
797
|
+
chips.append(chip);
|
|
798
|
+
}
|
|
799
|
+
if (seats.length === 0) chips.append(el("span", "meta", "no agent CLIs found"));
|
|
800
|
+
crew.append(chips);
|
|
801
|
+
main.append(crew);
|
|
802
|
+
|
|
803
|
+
// Above the runs: it is the only thing on this page that asks something of whoever is reading it.
|
|
804
|
+
main.append(waitingSection(snapshot.waiting ?? []));
|
|
805
|
+
|
|
806
|
+
const runs = el("section");
|
|
807
|
+
const runsHead = el("h2");
|
|
808
|
+
runsHead.append(document.createTextNode("Runs"));
|
|
809
|
+
if (mission) {
|
|
810
|
+
// Counted, not restated: "running · 2 running" says one thing twice.
|
|
811
|
+
const runs = Object.values(mission.runs);
|
|
812
|
+
const tally = [
|
|
813
|
+
[runs.filter((r) => r.status === "running").length, "running"],
|
|
814
|
+
[runs.filter((r) => r.status === "queued").length, "queued"],
|
|
815
|
+
[runs.filter((r) => r.status === "done").length, "waiting for review"],
|
|
816
|
+
[runs.filter((r) => r.status === "merged").length, "merged"],
|
|
817
|
+
].filter(([n]) => n > 0);
|
|
818
|
+
runsHead.append(
|
|
819
|
+
el(
|
|
820
|
+
"span",
|
|
821
|
+
"note",
|
|
822
|
+
tally.length > 0 ? tally.map(([n, w]) => `${n} ${w}`).join(" · ") : mission.status,
|
|
823
|
+
),
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
runs.append(runsHead);
|
|
827
|
+
if (!mission || mission.runOrder.length === 0) {
|
|
828
|
+
const empty = el("div", "empty");
|
|
829
|
+
empty.append(document.createTextNode("No mission yet. Start one from Claude Code with "));
|
|
830
|
+
empty.append(el("code", null, "/fanout <goal>"));
|
|
831
|
+
runs.append(empty);
|
|
832
|
+
} else {
|
|
833
|
+
const grid = el("div", "runs");
|
|
834
|
+
for (const id of mission.runOrder) grid.append(runRow(mission.runs[id], mission, seats, now));
|
|
835
|
+
runs.append(grid);
|
|
836
|
+
}
|
|
837
|
+
main.append(runs);
|
|
838
|
+
|
|
839
|
+
const repo = Object.keys(state.claims ?? {})[0];
|
|
840
|
+
main.append(claimsSection(repo ? state.claims[repo] : null, now));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function poll() {
|
|
844
|
+
try {
|
|
845
|
+
const response = await fetch("/state", { headers: { authorization: `Bearer ${TOKEN}` } });
|
|
846
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
847
|
+
snapshot = await response.json();
|
|
848
|
+
lastOk = Date.now();
|
|
849
|
+
document.getElementById("offline").textContent = "";
|
|
850
|
+
} catch (error) {
|
|
851
|
+
document.getElementById("offline").textContent =
|
|
852
|
+
`Not reading the daemon (${error.message}). What you see below is the last picture, not the current one.`;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
poll();
|
|
857
|
+
setInterval(poll, 1000);
|
|
858
|
+
// Redrawn on its own beat so the clocks move smoothly whether or not the daemon answered.
|
|
859
|
+
setInterval(draw, 250);
|
|
860
|
+
draw();
|
|
861
|
+
</script>
|
|
862
|
+
</body>
|
|
863
|
+
</html>
|