cctop 0.16.12

An htop-like terminal monitor for AI coding agent sessions on Linux (Claude Code, Codex, Cursor, Devin, Gemini CLI, OpenCode, Pi, Windsurf)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
<title>cctop</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<!-- theme-color follows the dark --bg, the scheme a phone is most likely to
     be showing this page in; favicon and manifest are plain same-origin
     routes, so they carry no token of their own. -->
<meta name="theme-color" content="#16151a">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="manifest" href="/manifest.webmanifest">
<style>__CCTOP_CSS__
  .totals { display: flex; gap: 18px; flex-wrap: wrap; align-items: baseline; }
  .totals b { font-family: var(--mono); font-weight: 600; }
  .totals .k { color: var(--faint); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }

  /* How much of each provider's subscription window is already gone — the
     same question the spend totals answer, asked of the plan rather than the
     card. A server without the route leaves it empty, and empty collapses to
     nothing rather than holding a blank line open. */
  .quota {
    flex-basis: 100%; display: flex; gap: 4px 14px; flex-wrap: wrap;
    align-items: center; font-size: 11px; color: var(--faint);
  }
  .quota:empty { display: none; }
  .quota .qp { display: inline-flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
  .quota .qwho { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
  .quota .qw { white-space: nowrap; }
  .quota .qw b { font-family: var(--mono); font-variant-numeric: tabular-nums; font-weight: 600; color: var(--dim); }
  .quota .ctx { width: 28px; height: 4px; }

  .controls { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
  input[type=search] {
    flex: 1 1 200px; min-width: 0; padding: 7px 11px; font: inherit; font-size: 14px;
    background: var(--panel); color: var(--ink);
    border: 1px solid var(--line); border-radius: 8px;
  }
  input[type=search]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  button {
    padding: 7px 11px; font: inherit; font-size: 13px; cursor: pointer;
    background: var(--panel); color: var(--dim);
    border: 1px solid var(--line); border-radius: 8px;
  }
  button[aria-pressed=true] { color: var(--accent); border-color: var(--accent); }
  select {
    padding: 7px 9px; font: inherit; font-size: 13px; cursor: pointer;
    background: var(--panel); color: var(--dim);
    border: 1px solid var(--line); border-radius: 8px;
  }
  /* Starting a fresh agent from the page — the launcher the terminal has on
     `n`, carried to the one place a phone can reach. */
  form.launch { display: flex; gap: 8px; align-items: center; margin: 0; }
  form.launch[hidden] { display: none; }
  form.launch input[type=text] {
    width: 180px; padding: 7px 11px; font: inherit; font-size: 14px;
    background: var(--panel); color: var(--ink);
    border: 1px solid var(--line); border-radius: 8px;
  }
  form.launch .said { font-size: 12px; }

  /* Sessions waiting on a person get their own block at the top. The whole
     reason to look at this on a phone is to find them. */
  .attention { border-color: var(--amber); margin-bottom: 16px; }
  .attention h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
                  color: var(--amber); padding: 11px 14px 0; }

  .rows { display: block; }
  .row {
    display: grid; gap: 2px 10px; padding: 11px 14px; border-top: 1px solid var(--line);
    grid-template-columns: 8px minmax(0, 1fr) auto;
    text-decoration: none; color: inherit; cursor: pointer;
  }
  .row:first-of-type { border-top: 0; }
  .row:hover, .row:focus-visible { background: color-mix(in srgb, var(--accent) 7%, transparent); }
  /* The keyboard mark sits a shade above hover rather than replacing it:
     the pointer and the selection are separate claims about a row, and both
     can be on it at once. */
  .row.sel { background: color-mix(in srgb, var(--accent) 12%, transparent); }
  .row.sel:hover, .row.sel:focus-visible { background: color-mix(in srgb, var(--accent) 18%, transparent); }
  .row .dot { margin-top: 7px; }
  .row .name { min-width: 0; font-weight: 500; }
  .row .name .title { color: var(--dim); font-weight: 400; }
  .row .name .pin { color: var(--faint); font-weight: 400; margin-right: 4px; }
  /* Bulk-selected, as distinct from keyboard-selected: the mark is a ✓ and a
     bar on the edge rather than a background, so a row can carry both at once
     and still read as two different claims. */
  .row .name .tick { color: var(--accent); font-weight: 400; margin-right: 4px; }
  .row.picked { box-shadow: inset 3px 0 0 var(--accent); }
  /* "Something new since you opened the chat" — cctop-seen is report.html's
     record, read here and never written. */
  .row .name .newdot {
    display: inline-block; width: 6px; height: 6px; border-radius: 50%;
    background: var(--accent); margin-left: 6px; vertical-align: 1px;
  }
  .row .meta { grid-column: 2; display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--faint); }
  /* The transcript line a search matched, quoted under the row's own metadata
     on a line of its own — the row matched nothing visible, so this is the
     only place that says why it is on screen at all. */
  .row .meta .snip { flex: 1 1 100%; min-width: 0; }
  .row .figures { grid-row: 1 / span 2; grid-column: 3; text-align: right; font-family: var(--mono);
                  font-variant-numeric: tabular-nums; font-size: 13px; white-space: nowrap; }
  .row .figures .sub { font-size: 11px; color: var(--faint); }
  .trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

  /* The context bar, the one figure that is a proportion rather than a count. */
  .ctx { display: inline-block; width: 46px; height: 5px; border-radius: 3px;
         background: var(--line); overflow: hidden; vertical-align: middle; }
  .ctx i { display: block; height: 100%; background: var(--dim); }

  /* A session that is waiting, with the box for answering it. The row stays a
     link to the whole session; the form sits under it rather than inside it,
     because a form inside an anchor is neither valid nor operable. */
  .wanting { position: relative; border-top: 1px solid var(--line); }
  .wanting:first-child { border-top: 0; }
  /* The extra right padding is the gutter the dismiss button sits in. It
     cannot be inside the row — a button inside an anchor is not valid — so
     the row narrows a little to make room for it instead. */
  .wanting .row { border-top: 0; padding-right: 38px; }
  .wanting .dismiss {
    position: absolute; top: 9px; right: 8px; padding: 3px 7px;
    font-size: 14px; line-height: 1; border-radius: 6px;
    color: var(--faint); background: transparent; border-color: transparent;
  }
  .wanting .dismiss:hover { color: var(--ink); border-color: var(--line); }
  form.say { display: flex; gap: 8px; padding: 0 14px 11px 32px; }
  form.say input {
    flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 6px 9px;
    border-radius: 7px; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
  }
  form.say input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  form.say button {
    font: inherit; font-size: 13px; padding: 0 13px; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--accent); background: var(--accent); color: var(--panel);
  }
  form.say button:disabled { opacity: .5; cursor: default; }
  /* The one-word replies. Quieter than Send — bordered rather than filled —
     because they answer for you, and the filled button stays the deliberate
     act. */
  form.say .chip {
    font: inherit; font-size: 12px; padding: 0 10px; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--line); background: var(--panel); color: var(--dim);
    flex: 0 0 auto;
  }
  form.say .said { font-size: 12px; align-self: center; color: var(--faint); }
  form.say .said.bad { color: var(--red); }
  .ctx i.hot { background: var(--amber); }
  .ctx i.full { background: var(--red); }

  /* The bulk-select affordance: a checkbox in a gutter the row's left padding
     widens to make, so it never sits on the status dot. Quiet until hovered
     or checked — marking is the exception, not the default. Drawn only when
     the serve may act; a read-only page never gets the wrappers. */
  .pickrow { position: relative; }
  #rows .pickrow { border-top: 1px solid var(--line); }
  #rows .pickrow:first-of-type { border-top: 0; }
  .pickrow .row { border-top: 0; padding-left: 30px; }
  .pickrow .pick {
    position: absolute; left: 9px; top: 14px; margin: 0;
    accent-color: var(--accent); opacity: .4; cursor: pointer;
  }
  .pickrow:hover .pick, .pickrow .pick:checked, .pickrow .pick:focus-visible { opacity: 1; }

  /* Transcript-search results stand in for the table while the card is open.
     The table is only hidden, so closing puts back exactly what was there. */
  .find { position: relative; }
  .find h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
             color: var(--faint); padding: 11px 14px 0; }
  .find .dismiss {
    position: absolute; top: 9px; right: 8px; padding: 3px 7px;
    font-size: 14px; line-height: 1; border-radius: 6px;
    color: var(--faint); background: transparent; border-color: transparent;
  }
  .find .dismiss:hover { color: var(--ink); border-color: var(--line); }
  .empty.bad { color: var(--red); }

  /* The bulk bar: nothing while the set is empty, then the few verbs the set
     answers to, between the table and the footer. */
  .bulk {
    display: flex; gap: 6px 10px; align-items: center; flex-wrap: wrap;
    margin-top: 12px; font-size: 13px; color: var(--dim);
  }
  .bulk[hidden] { display: none; }
  .bulk .sep { color: var(--faint); }
  .bulk form { display: flex; gap: 8px; flex: 1 1 260px; min-width: 0; margin: 0; }
  .bulk form[hidden] { display: none; }
  .bulk input[type=text] {
    flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px; padding: 6px 9px;
    border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink);
  }
  .bulk input[type=text]:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
  .bulk button:disabled { opacity: .5; cursor: default; }
  .bulk .said { font-size: 12px; }
  .bulk .said.bad { color: var(--red); }

  /* A phone gets the same page: the controls wrap, the table stacks its
     figures under the metadata instead of squeezing the name column, and the
     answer box lets its input take a line of its own. */
  @media (max-width: 700px) {
    .wrap { padding: 12px 10px 48px; }
    .controls { gap: 6px; }
    form.launch { flex-basis: 100%; flex-wrap: wrap; }
    form.launch input[type=text] { flex: 1 1 auto; width: auto; min-width: 0; }
    .row { grid-template-columns: 8px minmax(0, 1fr); }
    .row .figures {
      grid-row: auto; grid-column: 2; text-align: left; white-space: normal;
      display: flex; gap: 4px 14px; flex-wrap: wrap; margin-top: 3px;
    }
    form.say { flex-wrap: wrap; }
    form.say input { flex: 1 1 150px; }
  }

  footer { margin-top: 20px; font-size: 12px; color: var(--faint);
           display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
  footer .hint { font-size: 11px; }
  .live { display: inline-flex; align-items: center; gap: 6px; }
</style>
<!-- Synchronous, before anything paints: a stored theme choice has to be on
     <html> already, or a reader who picked dark sees a light frame first. -->
<script>__CCTOP_THEME__</script>

<div class="wrap">
  <header class="top">
    <h1>cctop<span class="v mono">__CCTOP_VERSION__</span></h1>
    <div class="spacer"></div>
    <div class="totals" id="totals"></div>
    <div class="quota" id="quota"></div>
  </header>

  <div id="banners"></div>

  <div class="controls">
    <input type="search" id="filter" placeholder="Filter on project, model, branch, title…" autocomplete="off" spellcheck="false"
           title="Type to filter the table · Enter to search every transcript">
    <button id="gsearch" title="Search every transcript for the filter text">Search transcripts</button>
    <button id="running" aria-pressed="false" title="Show only sessions with a live process">Running</button>
    <button id="bell" aria-pressed="false" title="Notify when a session starts waiting on you">Notify</button>
    <select id="sort" title="Order the table">
      <option value="recent">Recent</option>
      <option value="cost">Cost</option>
      <option value="tokens">Tokens</option>
      <option value="context">Context</option>
    </select>
    <form id="launch" class="launch" hidden>
      <select id="launch-agent" title="Which agent to start"></select>
      <input id="launch-cwd" type="text" placeholder="directory — default ~" autocomplete="off" spellcheck="false">
      <button type="submit" title="Start the agent, in the multiplexer">Start</button>
      <span id="launch-said" class="said"></span>
    </form>
  </div>

  <section class="card find" id="find" hidden>
    <h2 id="find-heading"></h2>
    <button class="dismiss" id="find-close" type="button" title="Back to the session table"
            aria-label="Back to the session table">×</button>
    <div class="rows" id="find-rows"></div>
  </section>

  <div id="tableview">
    <section class="card attention" id="attention" hidden>
      <h2 id="attention-heading"></h2>
      <div class="rows" id="attention-rows"></div>
    </section>

    <section class="card">
      <div class="rows" id="rows"></div>
      <div class="empty" id="empty">Waiting for the first refresh…</div>
    </section>
  </div>

  <div class="bulk" id="bulk" hidden>
    <span id="bulk-count"></span>
    <span class="sep">·</span>
    <button id="bulk-send" type="button" title="Type one line at every selected session">send…</button>
    <span class="sep">·</span>
    <button id="bulk-resume" type="button" title="Resume every selected session">resume</button>
    <span class="sep">·</span>
    <button id="bulk-clear" type="button" title="Empty the selection">clear</button>
    <form id="bulk-form" hidden>
      <input id="bulk-text" type="text" maxlength="4000" autocomplete="off" spellcheck="false"
             placeholder="One line, sent to each selected session">
      <button type="submit">Send</button>
    </form>
    <span class="said" id="bulk-said"></span>
  </div>

  <footer>
    <span class="live"><span class="dot idle" id="link"></span><span id="link-text">connecting…</span></span>
    <a id="analytics-link" href="/analytics">analytics</a>
    <span class="spacer"></span>
    <span id="counts"></span>
    <span class="hint" id="keys">j/k select · Enter opens · p pins · / filters · Enter in the filter searches transcripts</span>
    <a id="insight-optimize" href="/insight/optimize">optimize</a>
    <a id="insight-compare" href="/insight/compare">compare</a>
  </footer>
</div>

<script>
"use strict";
const TOKEN = "__CCTOP_TOKEN__";
const QUERY = TOKEN ? "?t=" + encodeURIComponent(TOKEN) : "";
// Whether this run serves the routes that act on a session. Substituted by the
// server, so the answer box is never drawn for a page that could not send it.
const CAN_ACT = "__CCTOP_ACTIONS__";

// The token reaches the script embedded in the page, so once it is running the
// `?t=` in the address bar is only a credential sitting in history, in
// screenshots, and in any link copied without thinking. Drop it as soon as the
// page is up. Some embedded contexts refuse replaceState — then the URL stays
// as it arrived, which is the most that can be done there anyway.
try {
  const here = new URL(location.href);
  if (here.searchParams.has("t")) {
    here.searchParams.delete("t");
    history.replaceState(null, "", here.pathname + here.search + here.hash);
  }
} catch (e) {}

// Every string from a transcript — titles, branches, paths, model names — is
// written through textContent or this. None of it is trusted markup, and a
// project directory is perfectly free to be called `<img onerror=…>`.
const el = (tag, cls, text) => {
  const node = document.createElement(tag);
  if (cls) node.className = cls;
  if (text !== undefined && text !== null) node.textContent = String(text);
  return node;
};

const money = (n) => {
  if (n === null || n === undefined) return "";
  const v = Number(n);
  if (!isFinite(v)) return "";
  if (v === 0) return "$0";
  if (v < 0.01) return "<$0.01";
  return "$" + (v < 10 ? v.toFixed(2) : Math.round(v).toLocaleString());
};

const tokens = (n) => {
  const v = Number(n) || 0;
  if (v >= 1e9) return (v / 1e9).toFixed(1) + "G";
  if (v >= 1e6) return (v / 1e6).toFixed(1) + "M";
  if (v >= 1e3) return (v / 1e3).toFixed(1) + "k";
  return String(v);
};

const ago = (iso) => {
  const then = Date.parse(iso);
  if (!isFinite(then)) return "";
  const secs = Math.max(0, (Date.now() - then) / 1000);
  if (secs < 60) return Math.floor(secs) + "s";
  if (secs < 3600) return Math.floor(secs / 60) + "m";
  if (secs < 86400) return Math.floor(secs / 3600) + "h";
  return Math.floor(secs / 86400) + "d";
};

// A reset timestamp as a wall-clock time — "resets 14:05" is read at a glance,
// where "in 40m" asks the reader to do the sum against a clock they may not be
// looking at.
const hhmm = (secs) => {
  if (!secs) return "";
  const d = new Date(Number(secs) * 1000);
  if (!isFinite(d.getTime())) return "";
  return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
};

// The cost a row shows. `total` arrives as a six-decimal string — the JSON
// document is exact on purpose — so it is reformatted here rather than printed,
// which would put `$0.000000` in a column four characters wide.
//
// `incl`, `—` and a figure are three different claims: the plan bundles this,
// the provider records no usage at all, and here is what it cost. None of them
// may be rendered as either of the others.
const rowCost = (s) => {
  if (s.cost.included) return "incl";
  if (!s.cost.available) return "";
  if (s.cost.total === null || s.cost.total === undefined) return "";
  return money(Number(s.cost.total));
};

// The home directory, so paths under it read as `~/…` the way they do in the
// terminal. Substituted by the server rather than derived here, because a
// browser cannot know it — and this page may be open on a different machine.
const HOME = "__CCTOP_HOME__";

// A working directory is often deep enough to fill a phone's width on its own,
// and the part that says which checkout this is lives at the end. The whole
// path stays on the element's title.
const shortPath = (path) => {
  const full = String(path);
  if (HOME && full.startsWith(HOME + "/")) return "~" + full.slice(HOME.length);
  if (HOME && full === HOME) return "~";
  const parts = full.split("/").filter(Boolean);
  return parts.length <= 2 ? full : "…/" + parts.slice(-2).join("/");
};

// Model names arrive fully qualified from gateways and proxies
// (`vendor/publisher/model`). The last segment is the part anyone reads.
const shortModel = (model) => {
  const parts = String(model).split("/").filter(Boolean);
  return parts.length ? parts[parts.length - 1] : model;
};

// When each session's chat was last opened: `{id: {seq, at}}` written by
// report.html, read here to mark rows with activity newer than the visit.
// Every access is behind try/catch — a private window throws on the read, and
// a shape from a version that stored it differently must not kill the render.
const readSeen = () => {
  try {
    return JSON.parse(localStorage.getItem("cctop-seen")) || {};
  } catch (e) {
    return {};
  }
};

let sessions = [];
let filter = "";
let runningOnly = false;
let sortBy = "recent";
// Transcript-search hits, kept with the query that produced them. The gate
// `searchFor === filter` wherever they are read means a stale answer — or one
// for a query the box no longer holds — shows nothing.
let searchFor = "";
let searchHits = new Map();
let searchTimer = 0;
// The keyboard selection is a session id, not a node: render() rebuilds every
// row, so the mark is re-applied to whichever new row carries the id — and
// dropped when none does.
let selId = null;
// Sessions pinned to the top of the table, and the state each attention row
// was last dismissed in — a dismissed session returns to the card on its own
// once it wants something new. Both persist through the cctop-dash store.
let pins = new Set();
let dismissed = {};
// The bulk-selection set: session ids marked for a send/resume across several
// rows at once. Deliberately not in the store — a mark that survived a reload
// could act on a session the reader no longer remembers choosing.
let picked = new Set();
// Sessions by id, for the two places that know an id and want its row's
// words: transcript hits joining back to their metadata, and the bulk bar
// naming the sessions it acted on.
let sessionById = new Map();
// report.html's "you last opened this chat at" record, refreshed every render
// because a chat open in another tab keeps rewriting it. Read, never written.
let seenNow = {};

// How the table is ordered, by what the reader picked.
//
// Every one of these is descending, because every one of them is a question of
// the form "which is the most" — the most recent, the most expensive, the
// fullest window. An ascending sort would answer a question nobody asks of this
// table.
//
// `recent` is what the server already sends, and re-sorting it here rather than
// leaning on that keeps the order a property of the page: a payload that ever
// arrives in a different order does not silently change what "Recent" means.
const ORDER = {
  recent: (a, b) => String(b.last_active).localeCompare(String(a.last_active)),
  cost: (a, b) => (Number(b.cost.total) || 0) - (Number(a.cost.total) || 0),
  tokens: (a, b) => (Number(b.tokens.total) || 0) - (Number(a.tokens.total) || 0),
  // A session whose harness reports no window sorts last rather than as zero:
  // "nothing is known" is not "the window is empty".
  context: (a, b) => share(b) - share(a),
};
const share = (s) => (s.context && s.context.max ? s.context.used / s.context.max : -1);
// What each session's state was last render, so a *transition* into waiting can
// be told from a session that has been waiting since before the page opened.
// Without it every refresh would re-notify about the same idle agent.
let previousState = new Map();
let firstRender = true;

const matches = (s, needle) => {
  if (!needle) return true;
  const hay = [
    s.project, s.title, s.model, s.harness, s.branch, s.provider,
    s.session_id, s.user, s.state,
  ].filter(Boolean).join(" ").toLowerCase();
  return needle.split(/\s+/).filter(Boolean).every((word) => hay.includes(word));
};

function contextBar(s) {
  const box = el("span", "ctx");
  const ctx = s.context;
  if (!ctx || !ctx.max) return null;
  const pct = Math.min(100, (ctx.used / ctx.max) * 100);
  const fill = el("i");
  fill.style.width = pct.toFixed(1) + "%";
  if (pct >= 90) fill.className = "full";
  else if (pct >= 70) fill.className = "hot";
  box.appendChild(fill);
  box.title = Math.round(pct) + "% of the context window";
  return box;
}

function rowFor(s) {
  const row = el("a", "row");
  row.href = "/session/" + encodeURIComponent(s.session_id) + QUERY;
  row.setAttribute("role", "listitem");
  // The id the keyboard selection navigates by — the only handle left once
  // the node itself is thrown away on the next render.
  row.dataset.id = s.session_id;
  if (s.session_id === selId) row.classList.add("sel");
  if (picked.has(s.session_id)) row.classList.add("picked");

  row.appendChild(el("span", "dot " + (s.running || s.state === "error" ? s.state : "idle")));

  const name = el("div", "name trunc");
  if (pins.has(s.session_id)) name.appendChild(el("span", "pin", ""));
  if (picked.has(s.session_id)) name.appendChild(el("span", "tick", ""));
  name.appendChild(el("span", null, s.project ? shortPath(s.project) : s.session_id.slice(0, 8)));
  if (s.title) {
    name.appendChild(el("span", "title", " · " + s.title));
  }
  // New activity since the chat was last opened — the marker needs both a
  // visit on record and something after it, so a session never opened and one
  // read to the end both stay unmarked.
  const seen = seenNow[s.session_id] || seenNow[s.provider + ":" + s.session_id];
  if (seen && Date.parse(s.last_active) > Number(seen.at)) {
    const dot = el("span", "newdot");
    dot.title = "activity since you last opened this session";
    name.appendChild(dot);
  }
  // The full path is worth having, but not worth the width. `title` is also
  // what a screen reader reads out, which is the same trade.
  if (s.project) name.title = s.project;
  row.appendChild(name);

  const meta = el("div", "meta");
  const tag = (text, cls) => { if (text) meta.appendChild(el("span", cls || null, text)); };
  // Harness first, then the model: on a machine running several agents the
  // model alone does not say who ran the session — `opus-5` under claude and
  // under a devin row are different sessions.
  tag(s.provider);
  if (s.model) tag(shortModel(s.model));
  if (s.branch) tag(s.branch);
  tag(ago(s.last_active) + " ago");
  if (s.running && s.state === "waiting") tag("waiting on you", "pill warn");
  // Louder than "waiting on you", and deliberately so: that one is your move
  // whenever you get to it, this one is an agent stopped mid-tool until you say.
  if (s.running && s.state === "asking") tag("needs permission", "pill bad");
  // "api error" in the present tense is a claim about now. For a stopped
  // session it is something that happened, and the row's last-active time is
  // already saying when.
  if (s.state === "error") tag(s.running ? "api error" : "ended on an api error", "pill bad");
  // A session with a quarter of its tool calls failing is retrying something
  // that will not work, and paying for every attempt.
  if (s.activity.tool_errors > 0 && s.activity.tool_count > 0) {
    const rate = s.activity.tool_errors / s.activity.tool_count;
    if (rate >= 0.25) tag(Math.round(rate * 100) + "% tool errors", "pill bad");
  }
  if (s.conflict) tag(s.conflict.level === "file" ? "same file as another agent" : "same repo as another agent", "pill warn");
  if (s.user) tag(s.user);
  // Which Claude login this ran under. Absent for a machine with one profile
  // and for every harness that has no such concept, so the tag appears exactly
  // where it distinguishes something.
  if (s.profile && s.profile !== "default") tag(s.profile);
  const bar = contextBar(s);
  if (bar) meta.appendChild(bar);
  // A transcript-search hit, quoted last and on its own line. It shows for a
  // session the metadata filter matched too — the snippet says what the
  // transcript holds, which the metadata never could.
  if (searchFor === filter && searchHits.has(s.session_id)) {
    meta.appendChild(el("span", "snip trunc", "" + searchHits.get(s.session_id) + ""));
  }
  row.appendChild(meta);

  const figures = el("div", "figures");
  figures.appendChild(el("div", null, rowCost(s)));
  figures.appendChild(el("div", "sub", tokens(s.tokens.total) + " tok"));
  row.appendChild(figures);

  return row;
}

// --- talking to the server -------------------------------------------------

// What went wrong, in words worth showing. A cctop error is short and arrives
// as text/plain; anything else in the body was written by something between
// this page and the server — a tunnel whose far end has gone answers with a
// whole HTML error page, and that page used to land on screen verbatim.
async function problem(response) {
  const kind = (response.headers.get("content-type") || "").split(";")[0].trim();
  const said = kind === "text/plain" ? (await response.text()).trim() : "";
  if (said) return said.length > 400 ? said.slice(0, 400) + "" : said;
  if (response.status >= 502 && response.status <= 504) return "cctop is not answering";
  if (response.status === 401 || response.status === 403) return "this link is no longer authorised";
  return "the server answered " + response.status;
}

// The body as JSON, or a sentence saying why it is not. A 200 is not a promise
// of JSON: a captive portal or a proxy answers with an HTML page and a good
// status, and the parser's complaint about it is not something to show anyone.
async function asJson(response) {
  const text = await response.text();
  try {
    return JSON.parse(text);
  } catch (e) {
    throw new Error("whatever answered this page, it was not cctop");
  }
}

// Every request the page makes. A dropped connection rejects the fetch itself
// with nothing in it worth reading, so it is named here instead.
async function ask(url, init) {
  let response;
  try {
    response = await fetch(url, init);
  } catch (e) {
    throw new Error("cctop is unreachable");
  }
  if (!response.ok) throw new Error(await problem(response));
  return response;
}

// Answering a waiting agent from the list, which is the whole point of the
// card it sits in: a page that says "this one needs you" and cannot be replied
// to has shown someone a problem and kept the fix.
//
// One line, because that is what submitting to a pty is — the server refuses a
// newline rather than sending half of a paragraph.
function answerBox(s) {
  if (!CAN_ACT || !s.running) return null;
  const form = el("form", "say");
  const input = el("input");
  input.type = "text";
  input.maxLength = 4000;
  input.autocomplete = "off";
  input.placeholder = "Answer this session…";
  const send = el("button", null, "Send");
  send.type = "submit";
  const said = el("span", "said", "");
  // The one POST, shared by the typed answer and the one-tap chips below: a
  // chip is the Send button with the word already chosen — same route, same
  // disabled-while-in-flight, same error line.
  const post = async (text, button) => {
    button.disabled = true;
    said.className = "said";
    said.textContent = "";
    try {
      await ask(
        "/api/act/send/" + encodeURIComponent(s.session_id) + QUERY,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ text }),
        },
      );
      said.textContent = "sent";
      return true;
    } catch (e) {
      said.className = "said bad";
      said.textContent = String(e.message || e);
      return false;
    } finally {
      button.disabled = false;
    }
  };
  // The commonest answers to a waiting agent are single words, and one tap on
  // a phone beats typing any of them. Only the states with an obvious word
  // get chips — anything else is a real answer and wants the box.
  const QUICK = {
    asking: [["Yes", "yes"], ["No", "no"]],
    waiting: [["Continue", "continue"]],
  };
  for (const [label, text] of QUICK[s.state] || []) {
    const chip = el("button", "chip", label);
    chip.type = "button";
    chip.addEventListener("click", () => { post(text, chip); });
    form.appendChild(chip);
  }
  form.appendChild(input);
  form.appendChild(send);
  form.appendChild(said);
  // A picture pasted into the box, which is the one way an image reaches an
  // agent on a machine you are only sshed into: a terminal carries text and
  // never an image, but a browser reads a real one off the clipboard. The
  // bytes go over this connection, land in a file on the agent's machine, and
  // the box is filled with the path — which is how every one of these agents
  // reads an image. Text pastes are left entirely alone.
  input.addEventListener("paste", (event) => {
    const items = Array.from(event.clipboardData?.items || []);
    const picture = items.find((i) => i.type && i.type.startsWith("image/"));
    if (!picture) return;
    const file = picture.getAsFile();
    if (!file) return;
    event.preventDefault();
    said.className = "said";
    said.textContent = "filing the image…";
    const reader = new FileReader();
    reader.onerror = () => {
      said.className = "said bad";
      said.textContent = "could not read that image";
    };
    reader.onload = async () => {
      try {
        const response = await ask(
          "/api/act/image/" + encodeURIComponent(s.session_id) + QUERY,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ data: String(reader.result) }),
          },
        );
        const filed = await response.json();
        // Appended rather than replacing: the sentence about the picture is
        // usually already half typed by the time it is pasted.
        const room = input.value && !input.value.endsWith(" ") ? " " : "";
        input.value = input.value + room + filed.path + " ";
        input.focus();
        said.textContent = "image filed on that machine";
      } catch (e) {
        said.className = "said bad";
        said.textContent = String(e.message || e);
      }
    };
    // A data: URL, which is the spelling the server already recognises from
    // the terminal side.
    reader.readAsDataURL(file);
  });
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const text = input.value.trim();
    if (!text) return;
    if (await post(text, send)) input.value = "";
  });
  return form;
}

// A row in the attention card: the link, the box for replying to it, and a
// way to wave it away.
function wantingNode(s) {
  const box = el("div", "wanting");
  box.appendChild(pickableRow(s));
  const say = answerBox(s);
  if (say) box.appendChild(say);
  // Dismissal is recorded against the state that was dismissed, not the
  // session — so the row is back the moment it wants something new, and the
  // session's place in the main table is never touched.
  const dismiss = el("button", "dismiss", "×");
  dismiss.type = "button";
  dismiss.title = "Dismiss until its state changes";
  dismiss.setAttribute("aria-label", dismiss.title);
  dismiss.addEventListener("click", () => {
    dismissed[s.session_id] = s.state;
    store.write();
    render();
  });
  box.appendChild(dismiss);
  return box;
}

function render() {
  const shown = sessions
    // A session whose metadata matches nothing still belongs when the
    // transcript search says the words are inside it — the snippet is how the
    // row explains itself.
    .filter((s) => matches(s, filter) || (searchFor === filter && searchHits.has(s.session_id)))
    .filter((s) => !runningOnly || s.running)
    .sort(ORDER[sortBy] || ORDER.recent);

  // The selection outlives the rebuild below only while its session is still
  // on screen — a session that filtered away or ended takes the mark with it.
  if (selId && !shown.some((s) => s.session_id === selId)) selId = null;
  // The bulk set holds while its sessions exist, not while they are shown:
  // filtering is a view, and a mark that a keystroke wiped would make picking
  // a row and then looking for the next one impossible. A session that leaves
  // the list entirely — ended, or walked away on the server's side — drops
  // its mark, since acting on it is no longer possible anyway.
  for (const id of picked) {
    if (!sessions.some((s) => s.session_id === id)) picked.delete(id);
  }
  // Re-read on every render rather than once at load: a chat opened in
  // another tab since the last pass writes the record this reads.
  seenNow = readSeen();

  // Anything a person has to answer, first and on its own.
  //
  // Live sessions only. A session that hit an API error in May and has not run
  // since is history, not a call to action — promoting it puts four dead rows
  // above the one agent actually waiting, which is the opposite of what this
  // block is for. The row keeps its red dot either way; that is the table
  // saying what happened, which is a different claim from "answer me".
  // A dismissal holds only for the state it was made in: waiting → asking →
  // working → waiting again is a new call to action, and an old × never
  // silences it.
  const wanting = shown.filter(
    (s) =>
      s.running &&
      (s.state === "waiting" || s.state === "asking" || s.state === "error") &&
      dismissed[s.session_id] !== s.state,
  );
  // The whole point of keeping this page in a pinned or background tab is
  // noticing when a session starts waiting. The tab cannot ring a bell, but
  // its title can announce the count — and it asks for no notification
  // permission to do it.
  document.title = wanting.length ? "(" + wanting.length + ") cctop" : "cctop";
  const rest = shown.filter((s) => !wanting.includes(s));
  // Pins lift a session within the table only — the card's order belongs to
  // who needs answering, not to who was starred. The sort is stable, so the
  // chosen order holds inside the pinned half and the unpinned alike.
  rest.sort((a, b) => Number(pins.has(b.session_id)) - Number(pins.has(a.session_id)));

  const attention = document.getElementById("attention");
  const attentionRows = document.getElementById("attention-rows");
  // Rebuilt on every refresh, so a half-typed answer would be thrown away
  // twice a second — along with the focus and the cursor, which is worse: the
  // rest of the word goes into a box that no longer exists. What is typed, what
  // was focused and where the caret sat all cross over.
  const typed = new Map();
  let focused = null;
  let caret = 0;
  for (const form of attentionRows.querySelectorAll("form.say")) {
    const input = form.querySelector("input");
    if (!form.dataset.session) continue;
    if (input.value) typed.set(form.dataset.session, input.value);
    if (document.activeElement === input) {
      focused = form.dataset.session;
      caret = input.selectionStart;
    }
  }
  attentionRows.replaceChildren(...wanting.map((s) => {
    const node = wantingNode(s);
    const form = node.querySelector("form.say");
    if (!form) return node;
    form.dataset.session = s.session_id;
    const input = form.querySelector("input");
    const carried = typed.get(s.session_id);
    if (carried) input.value = carried;
    if (focused === s.session_id) {
      input.focus();
      input.setSelectionRange(caret, caret);
    }
    return node;
  }));
  attention.hidden = wanting.length === 0;
  document.getElementById("attention-heading").textContent =
    wanting.length === 1 ? "1 session needs you" : wanting.length + " sessions need you";

  document.getElementById("rows").replaceChildren(...rest.map(pickableRow));
  const empty = document.getElementById("empty");
  empty.hidden = shown.length > 0;
  if (shown.length === 0 && sessions.length > 0) empty.textContent = "Nothing matches that filter.";

  const running = sessions.filter((s) => s.running).length;
  document.getElementById("counts").textContent =
    sessions.length + " sessions · " + running + " running" +
    (shown.length !== sessions.length ? " · " + shown.length + " shown" : "");

  const totals = document.getElementById("totals");
  const today = sessions.reduce((sum, s) => sum + (s.cost.today || 0), 0);
  const hour = sessions.reduce((sum, s) => sum + (s.cost.this_hour || 0), 0);
  totals.replaceChildren();
  for (const [label, value] of [["today", money(today)], ["this hour", money(hour)]]) {
    const box = el("span");
    box.appendChild(el("b", null, value));
    box.appendChild(document.createTextNode(" "));
    box.appendChild(el("span", "k", label));
    totals.appendChild(box);
  }

  renderBulk();
}

// A desktop notification for the moment a session crosses into waiting — the
// same event the terminal's `w` rings a bell for, and the reason this page is
// worth having open on a second screen at all.
const bell = document.getElementById("bell");
let notifying = false;
bell.addEventListener("click", async () => {
  if (notifying) {
    notifying = false;
    bell.setAttribute("aria-pressed", "false");
    return;
  }
  if (!("Notification" in window)) {
    bell.textContent = "No notifications";
    bell.disabled = true;
    return;
  }
  // Must be inside the click: browsers refuse a permission prompt that no
  // gesture asked for, and refusing it once is remembered.
  const granted = await Notification.requestPermission();
  notifying = granted === "granted";
  bell.setAttribute("aria-pressed", String(notifying));
  if (!notifying) bell.title = "Your browser refused notification permission for this page";
});

function announce(next) {
  if (!notifying || firstRender) return;
  for (const s of next) {
    const was = previousState.get(s.session_id);
    if (was && was !== "asking" && s.state === "asking") {
      new Notification("Needs permission", {
        body: (s.project || s.session_id.slice(0, 8)) + (s.title ? " · " + s.title : ""),
        tag: s.session_id,
      });
    }
    if (was && was !== "waiting" && s.state === "waiting") {
      new Notification("Waiting on you", {
        body: (s.project || s.session_id.slice(0, 8)) + (s.title ? " · " + s.title : ""),
        tag: s.session_id,
      });
    }
  }
}

function apply(next) {
  announce(next);
  previousState = new Map(next.map((s) => [s.session_id, s.state]));
  sessions = next;
  sessionById = new Map(next.map((s) => [s.session_id, s]));
  firstRender = false;
  render();
}

// --- subscription windows --------------------------------------------------

// The other half of "can I afford to let this run": not what the sessions
// spent, but how much of each provider's window is already gone. It moves in
// minutes rather than milliseconds, so it polls on its own slow clock instead
// of riding the session stream — and a server old enough to lack the route
// leaves the strip empty, which the CSS folds away entirely.
function renderQuota(data) {
  const out = [];
  for (const provider of Object.keys(data || {})) {
    for (const entry of data[provider] || []) {
      // The provider name plus the profile when there is more than the one —
      // the same distinction the session rows make.
      const who = provider + (entry.profile && entry.profile !== "default" ? "·" + entry.profile : "");
      if (entry.status === "ok") {
        const box = el("span", "qp");
        box.appendChild(el("span", "qwho", who));
        for (const w of entry.windows || []) {
          const item = el("span", "qw");
          const pct = Math.max(0, Math.min(100, Number(w.pct) || 0));
          item.appendChild(el("span", null, w.label + " "));
          item.appendChild(el("b", null, Math.round(pct) + "%"));
          const bar = el("span", "ctx");
          const fill = el("i");
          fill.style.width = pct.toFixed(1) + "%";
          // The context bar's thresholds, reused: a window nearly spent reads
          // exactly like a context nearly full.
          if (w.limit_reached || pct >= 90) fill.className = "full";
          else if (pct >= 70) fill.className = "hot";
          bar.appendChild(fill);
          item.appendChild(document.createTextNode(" "));
          item.appendChild(bar);
          const at = hhmm(w.resets_at);
          if (at) item.title = w.label + " resets " + at;
          box.appendChild(item);
        }
        out.push(box);
      } else {
        // A status earns a mention only when it asks something of the reader:
        // "sign in again" and "wait until" can be acted on, while a billing
        // state or a provider that never answered is just noise in a header.
        const resets = (entry.windows || []).map((w) => w.resets_at).find(Boolean);
        const note =
          entry.status === "expired" ? "sign-in expired" :
          entry.status === "not_signed_in" ? "not signed in" :
          entry.status === "rate_limited" ? "rate-limited" + (resets ? " until " + hhmm(resets) : "") :
          "";
        if (!note) continue;
        const box = el("span", null, who + ": " + note);
        if (entry.detail) box.title = entry.detail;
        out.push(box);
      }
    }
  }
  document.getElementById("quota").replaceChildren(...out);
}

async function pollQuota() {
  try {
    renderQuota(await ask("/api/quota" + QUERY).then(asJson));
  } catch (e) {
    // A failed poll leaves whatever was last shown. The strip answers a
    // nice-to-have question; it never earns a banner.
  }
}
pollQuota();
setInterval(pollQuota, 60000);

// --- transcript search -------------------------------------------------------

// Rides next to the metadata filter, never in place of it: a query of a few
// characters asks the server which transcripts mention it, and a session whose
// row matched nothing visible still shows, quoting the line that did. Any
// failure just means no extra rows — the filter keeps working against a server
// that has no search route at all.
async function transcriptSearch(q) {
  let data;
  try {
    data = await ask(
      "/api/search?q=" + encodeURIComponent(q) + (TOKEN ? "&t=" + encodeURIComponent(TOKEN) : ""),
    ).then(asJson);
  } catch (e) {
    return;
  }
  // The box may have moved on while the request was in flight; hits for a
  // query it no longer holds would surface rows that match nothing.
  if (q !== filter) return;
  searchFor = q;
  searchHits = new Map();
  for (const hit of data.hits || []) {
    if (hit && hit.session_id && hit.snippet && !searchHits.has(hit.session_id)) {
      searchHits.set(hit.session_id, String(hit.snippet));
    }
  }
  render();
}

// --- the transcript-results card ---------------------------------------------

// The filter asks "which of these sessions"; the card answers "which
// transcripts hold these words at all" — every session the server knows, not
// only the rows on screen. Enter in the filter or the button beside it opens
// it; Escape or its × puts the table back, which was only hidden, never torn
// down — so the attention card, the marks and a half-typed answer all survive.
const findCard = document.getElementById("find");
const findRows = document.getElementById("find-rows");
const findHeading = document.getElementById("find-heading");
const tableview = document.getElementById("tableview");
const findOpen = () => !findCard.hidden;

function closeFind() {
  findCard.hidden = true;
  tableview.hidden = false;
}

// One hit, drawn like a session row but linked to the chat with its find box
// already holding the query — `?find=` is the report page's spelling for that.
// The route's answer carries only the id and the snippet; the project, title
// and provider are the session's own, joined back from the table's data.
function findRowFor(hit, q) {
  const id = hit.session_id || hit.id || "";
  const s = sessionById.get(id);
  const row = el("a", "row");
  row.href =
    "/session/" + encodeURIComponent(id) +
    (QUERY ? QUERY + "&find=" : "?find=") + encodeURIComponent(q);
  row.setAttribute("role", "listitem");
  row.dataset.id = id;
  row.appendChild(
    el("span", "dot " + (s && (s.running || s.state === "error") ? s.state : "idle")),
  );
  const name = el("div", "name trunc");
  const project = (s && s.project) || hit.project;
  name.appendChild(el("span", null, project ? shortPath(project) : id.slice(0, 8)));
  const title = (s && s.title) || hit.title;
  if (title) name.appendChild(el("span", "title", " · " + title));
  if (project) name.title = project;
  row.appendChild(name);
  const meta = el("div", "meta");
  const provider = (s && s.provider) || hit.provider;
  const model = (s && s.model) || hit.model;
  if (provider) meta.appendChild(el("span", null, provider));
  if (model) meta.appendChild(el("span", null, shortModel(model)));
  if (s && s.last_active) meta.appendChild(el("span", null, ago(s.last_active) + " ago"));
  if (hit.snippet) {
    meta.appendChild(el("span", "snip trunc", "" + hit.snippet + ""));
  }
  row.appendChild(meta);
  return row;
}

async function openFind() {
  const q = filterBox.value.trim();
  if (!q) {
    filterBox.focus();
    return;
  }
  tableview.hidden = true;
  findCard.hidden = false;
  findHeading.textContent = "Transcript search — “" + q + "";
  // The server floors short queries rather than answering them; the card says
  // so instead of asking and reporting an empty answer as "nothing mentions".
  if (q.length < 3) {
    findRows.replaceChildren(
      el("div", "empty", "Three characters or more to search transcripts."),
    );
    return;
  }
  findRows.replaceChildren(el("div", "empty", "Searching every transcript…"));
  let hits;
  try {
    const data = await ask(
      "/api/search?q=" + encodeURIComponent(q) +
        (TOKEN ? "&t=" + encodeURIComponent(TOKEN) : ""),
    ).then(asJson);
    // The route answers {"hits": [...]}; a bare array is accepted too — it is
    // the shape an earlier version of the route was described as having.
    hits = Array.isArray(data) ? data : data.hits || [];
  } catch (e) {
    if (findOpen()) {
      findRows.replaceChildren(el("div", "empty bad", String(e.message || e)));
    }
    return;
  }
  // Closed while the search was in flight: the card's state belongs to the
  // view that asked for it, and that view is gone.
  if (!findOpen()) return;
  if (!hits.length) {
    findRows.replaceChildren(el("div", "empty", "No transcript mentions that."));
    return;
  }
  findRows.replaceChildren(...hits.map((h) => findRowFor(h, q)));
}

// --- bulk selection ------------------------------------------------------------

// `x` on the keyboard-selected row, or the checkbox in the row's gutter, marks
// a session for a verb that takes several at once. A serve that may not act
// gets none of this: no marks, no bar, no wiring — there would be nothing
// behind it to run.
function pickableRow(s) {
  const row = rowFor(s);
  if (!CAN_ACT) return row;
  const wrap = el("div", "pickrow");
  const box = el("input", "pick");
  box.type = "checkbox";
  box.checked = picked.has(s.session_id);
  box.title = "Select for a bulk action";
  box.setAttribute(
    "aria-label",
    box.title + "" + (s.project ? shortPath(s.project) : s.session_id.slice(0, 8)),
  );
  box.addEventListener("click", () => togglePick(s.session_id));
  wrap.appendChild(box);
  wrap.appendChild(row);
  return wrap;
}

function togglePick(id) {
  if (picked.has(id)) picked.delete(id);
  else picked.add(id);
  render();
}

const bulkBar = document.getElementById("bulk");
const bulkForm = document.getElementById("bulk-form");
const bulkText = document.getElementById("bulk-text");
const bulkSaid = document.getElementById("bulk-said");
// One bulk run at a time; the buttons are disabled for the flight, so this is
// a guard against a keypress landing between the click and the disable.
let bulkBusy = false;

function renderBulk() {
  if (!CAN_ACT) return;
  bulkBar.hidden = picked.size === 0;
  document.getElementById("bulk-count").textContent = picked.size + " selected";
}

// How a selected row is named in the summary — its project, or the id's head
// when there is none, the same choice the row itself makes.
const nameOf = (id) => {
  const s = sessionById.get(id);
  return s && s.project ? shortPath(s.project) : id.slice(0, 8);
};

// One verb against every marked session, one request at a time: these type at
// real terminals and start real agents, and a parallel burst is the mistake
// the answer box's one-POST-at-a-time already avoids. The summary names the
// failures rather than counting them — "2 failed" still has to be answered by
// hand, while the names are the answer.
async function bulkAct(verb, text) {
  if (bulkBusy || picked.size === 0) return;
  bulkBusy = true;
  const buttons = bulkBar.querySelectorAll("button");
  for (const b of buttons) b.disabled = true;
  bulkSaid.className = "said";
  bulkSaid.textContent = "";
  const done = [];
  const failed = [];
  for (const id of picked) {
    try {
      await ask("/api/act/" + verb + "/" + encodeURIComponent(id) + QUERY, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        // The route insists on a JSON object even when the verb takes no
        // arguments — a resume carries an empty one.
        body: JSON.stringify(verb === "send" ? { text } : {}),
      });
      done.push(id);
    } catch (e) {
      failed.push(nameOf(id) + "" + String(e.message || e));
    }
  }
  bulkSaid.textContent =
    (verb === "send" ? "sent to " : "resumed ") + done.length +
    (failed.length
      ? " · failed: " + failed.map((f) => f.split("")[0]).join(", ")
      : "");
  bulkSaid.title = failed.join("\n");
  bulkSaid.className = failed.length ? "said bad" : "said";
  if (!failed.length && verb === "send") {
    bulkText.value = "";
    bulkForm.hidden = true;
  }
  for (const b of buttons) b.disabled = false;
  bulkBusy = false;
}

if (CAN_ACT) {
  document.getElementById("bulk-send").addEventListener("click", () => {
    bulkForm.hidden = !bulkForm.hidden;
    if (!bulkForm.hidden) bulkText.focus();
  });
  document.getElementById("bulk-resume").addEventListener("click", () => bulkAct("resume"));
  document.getElementById("bulk-clear").addEventListener("click", () => {
    picked.clear();
    bulkForm.hidden = true;
    bulkSaid.textContent = "";
    render();
  });
  bulkForm.addEventListener("submit", (event) => {
    event.preventDefault();
    const text = bulkText.value.trim();
    if (text) bulkAct("send", text);
  });
  bulkText.addEventListener("keydown", (event) => {
    if (event.key === "Escape") bulkForm.hidden = true;
  });
}

// --- the live connection ---------------------------------------------------

const link = document.getElementById("link");
const linkText = document.getElementById("link-text");
function setLink(state, text) {
  link.className = "dot " + state;
  linkText.textContent = text;
}

// The analytics page needs the same credential in its URL or it lands on 403.
document.getElementById("analytics-link").href = "/analytics" + QUERY;

let source;
function connect() {
  source = new EventSource("/api/events" + QUERY);
  source.addEventListener("sessions", (event) => {
    setLink("working", "live");
    try { apply(JSON.parse(event.data)); } catch (e) { setLink("error", "bad payload"); }
  });
  // EventSource reconnects on its own, but only from a stream that broke. An
  // answer that was never a stream — the HTML error page a tunnel serves once
  // its far end is gone — closes it for good, and the page would sit on
  // "reconnecting…" for ever. So a closed source is reopened here, on a timer,
  // because a stale table that looks live is the one failure this page must
  // not have.
  source.addEventListener("error", () => {
    if (source.readyState !== EventSource.CLOSED) {
      setLink("waiting", "reconnecting…");
      return;
    }
    setLink("error", "cctop is unreachable");
    setTimeout(connect, 5000);
  });
  source.addEventListener("open", () => setLink("working", "live"));
}

ask("/api/hosts" + QUERY)
  .then(asJson)
  .then((failed) => {
    const banners = document.getElementById("banners");
    for (const [host, why] of failed) {
      banners.appendChild(el("div", "banner", host + " could not be read: " + why));
    }
  })
  .catch(() => {});

// The launcher, offered only where this run can act — a read-only link or a
// --no-actions serve has nothing behind the button, so it is never drawn.
if (CAN_ACT) {
  ask("/api/agents" + QUERY)
    .then(asJson)
    .then((r) => {
      const known = r.agents || [];
      if (!r.actions || !known.length) return;
      const form = document.getElementById("launch");
      const pick = document.getElementById("launch-agent");
      const cwd = document.getElementById("launch-cwd");
      const said = document.getElementById("launch-said");
      const go = form.querySelector("button");
      for (const agent of known) pick.appendChild(el("option", null, agent));
      form.hidden = false;
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        go.disabled = true;
        said.className = "said";
        said.textContent = "";
        try {
          const response = await ask("/api/launch" + QUERY, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ agent: pick.value, cwd: cwd.value.trim() }),
          });
          const done = await asJson(response);
          said.textContent = done.message || "started";
        } catch (e) {
          said.className = "said bad";
          said.textContent = String(e.message || e);
        } finally {
          go.disabled = false;
        }
      });
    })
    .catch(() => {});
}

// --- the controls ------------------------------------------------------------

const filterBox = document.getElementById("filter");
const sortBox = document.getElementById("sort");
const runningBtn = document.getElementById("running");

// The controls survive a reload: a filter someone typed is the question they
// were asking, and a refresh that forgets it makes them ask again. One key,
// one small object, every access behind try/catch — private windows and
// embedded contexts throw on localStorage, and the page must not die on it.
// The bell is not in here on purpose: notification permission belongs to the
// browser, and re-arming it silently would claim a yes the reader gave for
// one sitting, not for ever.
const store = {
  read() {
    try {
      return JSON.parse(localStorage.getItem("cctop-dash")) || {};
    } catch (e) {
      return {};
    }
  },
  write() {
    try {
      localStorage.setItem(
        "cctop-dash",
        JSON.stringify({
          filter: filterBox.value,
          sort: sortBy,
          running: runningOnly,
          pins: [...pins],
          dismissed,
        }),
      );
    } catch (e) {}
  },
};

// Restored as if it had just been typed and picked: the box and the variable
// it feeds get the value together, and a long-enough query goes back to the
// transcript search so its snippet rows come back with it.
const prefs = store.read();
if (typeof prefs.filter === "string" && prefs.filter) {
  filterBox.value = prefs.filter;
  filter = prefs.filter.trim().toLowerCase();
  if (filter.length >= 3) transcriptSearch(filter);
}
if (prefs.sort && ORDER[prefs.sort]) {
  sortBy = prefs.sort;
  sortBox.value = sortBy;
}
if (prefs.running) {
  runningOnly = true;
  runningBtn.setAttribute("aria-pressed", "true");
}
// Stored objects from before pins and dismissals existed have neither key;
// both simply stay empty.
if (Array.isArray(prefs.pins)) pins = new Set(prefs.pins);
if (prefs.dismissed && typeof prefs.dismissed === "object") {
  dismissed = prefs.dismissed;
}

filterBox.addEventListener("input", (e) => {
  filter = e.target.value.trim().toLowerCase();
  // The metadata filter answers instantly; the transcript is asked only once
  // typing pauses, and only for a query long enough to mean something. A box
  // back under three characters just stops showing hits — the cached ones are
  // gated on the query anyway, so nothing needs clearing.
  clearTimeout(searchTimer);
  if (filter.length >= 3 && filter !== searchFor) {
    searchTimer = setTimeout(() => transcriptSearch(filter), 400);
  }
  render();
  store.write();
});
sortBox.addEventListener("change", (e) => {
  sortBy = e.target.value;
  store.write();
  render();
});
runningBtn.addEventListener("click", (e) => {
  runningOnly = !runningOnly;
  e.currentTarget.setAttribute("aria-pressed", String(runningOnly));
  store.write();
  render();
});
document.getElementById("gsearch").addEventListener("click", openFind);
document.getElementById("find-close").addEventListener("click", closeFind);

// Every page-level key shares this guard: none of them may fire while the
// reader is typing — in the filter itself, the sort select, an answer box, or
// anything editable.
const typing = (t) =>
  t instanceof HTMLElement &&
  (t.isContentEditable || /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName));

// `/` reaches the filter from anywhere, Escape inside it clears and leaves.
document.addEventListener("keydown", (e) => {
  if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return;
  if (typing(e.target)) return;
  e.preventDefault();
  filterBox.focus();
  if (filterBox.value) filterBox.select();
});
filterBox.addEventListener("keydown", (e) => {
  // Enter in the filter is the transcript search: the metadata match keeps
  // answering as typing happens, but asking every transcript is deliberate —
  // a keypress, not a debounce.
  if (e.key === "Enter") {
    e.preventDefault();
    openFind();
    return;
  }
  if (e.key !== "Escape") return;
  e.preventDefault();
  // Cleared by running the same path as typing it empty: `filter`, the
  // debounce and the transcript-hit gating all follow the box, so nothing —
  // snippets included — lingers after it.
  filterBox.value = "";
  filterBox.dispatchEvent(new Event("input"));
  filterBox.blur();
});

// j/k walk the rows in the order the page shows them — the attention card
// first, then the table — and Enter follows whichever is marked. The rows
// are anchors, so "open" is just a click.
// Only rows on screen count: while the search-results card is open the table
// is display:none, and its rows must not take j/k or Enter behind its back.
const listedRows = () =>
  [...document.querySelectorAll(".row[data-id]")].filter((r) => r.offsetParent !== null);

function moveSelection(dir) {
  const rows = listedRows();
  if (!rows.length) return;
  const at = rows.findIndex((row) => row.dataset.id === selId);
  // From nothing, j lands on the first row and k on the last; past either
  // end the mark just stays where it is.
  const next =
    at < 0
      ? (dir > 0 ? rows[0] : rows[rows.length - 1])
      : rows[Math.min(rows.length - 1, Math.max(0, at + dir))];
  selId = next.dataset.id;
  for (const row of rows) row.classList.toggle("sel", row === next);
  next.scrollIntoView({ block: "nearest" });
}

document.addEventListener("keydown", (e) => {
  if (e.ctrlKey || e.metaKey || e.altKey || typing(e.target)) return;
  if (e.key === "j" || e.key === "ArrowDown") {
    e.preventDefault();
    moveSelection(1);
  } else if (e.key === "k" || e.key === "ArrowUp") {
    e.preventDefault();
    moveSelection(-1);
  } else if (e.key === "Enter" && selId) {
    // A focused link or button already owns Enter; the selection borrows it
    // only when nothing else is answering.
    if (/^(A|BUTTON)$/.test(e.target.tagName)) return;
    const row = listedRows().find((r) => r.dataset.id === selId);
    if (row) {
      e.preventDefault();
      row.click();
    }
  } else if (e.key === "p" && selId) {
    if (pins.has(selId)) pins.delete(selId);
    else pins.add(selId);
    store.write();
    render();
  } else if (e.key === "x" && selId && CAN_ACT) {
    togglePick(selId);
  } else if (e.key === "Escape") {
    // The results card takes Escape first — it is a view over the table, and
    // closing it is the smaller undo. Only then does Escape unmark the row.
    if (findOpen()) {
      closeFind();
    } else if (selId) {
      selId = null;
      for (const row of listedRows()) row.classList.remove("sel");
    }
  }
});

// The header's last control is not in the markup: the button comes from the
// shared theme script inlined above, so a page served without it has one
// fewer button rather than an error. It lands after the totals — #quota
// claims a whole flex line, so this closes the first.
const themeButton = window.themeToggle && window.themeToggle();
if (themeButton) document.getElementById("quota").before(themeButton);

// The hint names only keys that do something on this page: a read-only serve
// never draws the bulk bar, so it never mentions the mark key either.
document.getElementById("keys").textContent =
  "j/k select · Enter opens · p pins · / filters" +
  (CAN_ACT ? " · x marks" : "") +
  " · Enter in the filter searches transcripts";

// Ages are relative and nothing else changes between snapshots, so a slow tick
// keeps "4m ago" honest without waiting on the next refresh.
setInterval(() => { if (sessions.length) render(); }, 15000);

// The plain-text reports the server renders on request. The token goes in the
// href because the address bar may no longer be holding it.
for (const [id, path] of [["insight-optimize", "/insight/optimize"], ["insight-compare", "/insight/compare"]]) {
  document.getElementById(id).href = path + QUERY;
}

connect();
</script>