cctop 0.28.0

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
//! Command-line parsing and the non-interactive output modes.

use crate::loader::Loader;
use crate::pricing::{Plan, Provider};
use crate::session::Session;
use crate::util;
use clap::Parser;
use serde::Serialize;

/// The command index printed after the options by `-h` and `--help` alike.
///
/// The bare words are intercepted in `main` before clap runs, so this text is
/// the only place clap's output admits they exist. One line per command — the
/// detail lives in each command's own --help, and in `long_about` above for
/// the flags that shape them.
const COMMANDS: &str = "\
Commands:
  cctop <agent> [args…]   Start claude, codex, opencode or pi on a pty cctop
                          owns, so the UI can watch it and type into it. Same
                          as `cctop run <agent>`; the args go to the agent.
  cctop attach [pid]      Put a running agent on this terminal — no pid lists
                          them. F12 detaches and leaves it running.
  cctop as <acct> <agent> Start an agent under a named account (see
                          --add-account).
  cctop sandbox <host>:<path> [args…]
                          Run Claude Code here with its Bash commands on
                          <host> over ssh, and <path> mounted here with sshfs.
  cctop serve            Serve the table, and a per-session report, to a
                          browser. Loopback and read-only by default; `serve
                          --help` for the flags. Handy on a phone.
  cctop wait <session>    Block until a session stops working — by id prefix,
                          tab name or pid. For one agent to wait on another.
  cctop doctor            Check this installation and say what is wrong with
                          it: sessions, pricing, hooks, and what `s` reaches.
                          --host also tests an ssh target.
  cctop why [session]     Why a row says a session is running, or is not:
                          every agent process, its session, the rule that
                          matched it.
  cctop optimize          What your sessions spent and did not get back.
                          Reads only.
  cctop compare           How each model did on the work you actually gave
                          it — one-shot rate, cost per file, cache hit.
  cctop yield             Whether what each session spent ended up in a
                          commit on the default branch. Reads only.
  cctop recall <query>    What past sessions, from any agent, said about
                          something — the passages, ranked. Agents get it
                          through --mcp; --install-mcp sets that up.
  cctop burn              What your subscription windows were paid for and
                          did not use.
  cctop log               Print the event stream CCTOP_LOG writes; -f follows
                          it.
  cctop --trace           Time each stage of a run, for a bug report about
                          slowness.

Each command takes --help for the details.";

#[derive(Parser, Debug)]
#[command(
    name = "cctop",
    about = "An htop-like monitor for AI coding agent sessions",
    // cctop takes no positionals — `run` and `attach` are intercepted in main
    // before clap sees them, so clap cannot know they exist and would otherwise
    // print a usage line claiming options are all there is.
    override_usage = "cctop [OPTIONS]\n       \
                      cctop <agent> [args…]\n       \
                      cctop attach [pid]\n       \
                      cctop as <account> <agent> [args…]\n       \
                      cctop sandbox <host>:<path> [claude args…]\n       \
                      cctop serve [--bind ADDR] [--port PORT]\n       \
                      cctop optimize | compare | yield | burn | log\n       \
                      cctop recall <query> [--read SESSION PASSAGE]\n       \
                      cctop wait <session> [--until …] [--timeout …]\n       \
                      cctop why [session]\n       \
                      cctop doctor",
    // The one-line summary is the whole of `-h`; the command list is the part
    // that says what cctop is *for*, so both -h and --help carry it — `help`'s
    // line count is short enough that it fits underneath.
    after_help = COMMANDS,
    // --help's own copy of the list: `after_help` is not shown there, and the
    // long description's prose names most of the commands without ever saying
    // they are commands.
    after_long_help = COMMANDS,
    long_about = "cctop — an htop-like monitor for AI coding agent sessions\n\n\
Tracks Claude Code, Codex, Cursor, Devin, Gemini CLI, OpenCode, Pi, and\n    Windsurf\n\
sessions on your machine, showing real-time cost estimation, token usage, tool\n\
invocations, and OS-level metrics.\n\n\
COST ESTIMATION\n  \
Cost figures are estimates based on per-token API pricing from the LiteLLM\n  \
database (cached locally for 24 hours). Many subscription plans — such as\n  \
Claude Max, Pro, or Team — charge a flat rate or bundle tokens differently,\n  \
so reported costs may not reflect your actual bill. Treat the $ column as a\n  \
rough indicator of resource consumption, not as an authoritative invoice.\n\n\
LAUNCHING AGENTS\n  \
`cctop <command> [args…]`, or `cctop run <command>`, starts the agent on a pty\n  \
cctop owns so the UI can type into it with `s`. Everything after the command,\n  \
flags included, goes to the agent. The first interactive run aliases the known\n  \
agents to this form in your shell startup files; --remove-alias undoes that.\n\n\
ATTACHING\n  \
Agents started that way can be watched and driven from anywhere. Press `a` in\n  \
the UI, or run `cctop attach [pid]` to put one on the terminal directly —\n  \
with no pid it lists what is running. F12 detaches and leaves it running. The\n  \
agent is resized to the smallest window watching it, and gets its size back\n  \
when that one detaches.\n\n\
RESUMING\n  \
`R` in the UI reopens any session in a tab of its own, by running its own\n  \
harness's resume command in the directory it was working in. Unlike `a` this\n  \
needs nothing of cctop at the time the session ran, so it reaches the sessions\n  \
started from anywhere — including ones that ended long ago.\n\n\
IN A BROWSER\n  \
`cctop serve` puts the same table on an HTTP port, streaming it over SSE, plus\n  \
a page per session: the conversation, what it edited, what it can reach, and\n  \
where its money went. From there it can send a prompt to a live session,\n  \
resume a dead one, or hand one to a different agent. It listens on 127.0.0.1\n  \
with a per-run access token in the URL, which is the whole credential — so\n  \
whoever holds the link can drive these agents. `--bind` is what puts it on the\n  \
network and `--tunnel` puts it on the internet, both saying so when they do;\n  \
`--no-actions` serves the pages without the buttons.\n\n\
SEARCHING\n  \
`/` filters on what the table shows plus the full working directory and the\n  \
branch; `Tab` in that prompt extends the search into the transcripts, which\n  \
reads them off disk and so is opt-in.\n\n\
DIAGNOSING\n  \
`cctop doctor` reports where sessions are read from and how many it found,\n  \
whether pricing loaded, which agent hooks are installed, and what `s` can\n  \
reach. It exits non-zero only for a real fault, so it is usable in a script.\n  \
`cctop doctor --host <host>` additionally makes the ssh round trip.\n  \
--trace answers the other question, which is why a run is slow. It times each\n  \
stage — discovery, transcript parsing, the cache, the pricing fetch — and\n  \
writes the totals to a file when cctop exits, for attaching to a bug report.\n  \
It carries counts and durations only: no session titles, project paths or\n  \
file names, and cctop's own paths are spelled with `~`.\n\n\
EVERY USER\n  \
Run as root and cctop reads every user's sessions rather than root's own,\n  \
naming whose each row is in the USER column; `/user:<name>` filters to one.\n  \
Other homes are only read: cctop writes nothing into them, and refuses to\n  \
delete another user's session. CCTOP_ALL_USERS=0 turns that\n  \
off, =1 turns it on without root, and CCTOP_HOMES names homes that are\n  \
neither in /etc/passwd nor under /home.\n\n\
NOTES\n  \
Session data is read from each agent's standard local session store.\n  \
UI preferences (active tab, sort order, filters) persist across runs.",
    version
)]
pub struct Args {
    /// List sessions in a table and exit
    #[arg(short, long)]
    pub list: bool,

    /// Dump full session data as JSON and exit
    #[arg(short, long)]
    pub json: bool,

    /// Billing plan for cost display: retail, max, or included
    #[arg(short, long, default_value = "retail", value_parser = parse_plan)]
    pub plan: Plan,

    /// Refresh interval in seconds
    ///
    /// $CCTOP_SETTLE_MS is the other half of the cadence: how long a
    /// transcript that was just written is left to finish being written
    /// before the rows are rebuilt from it (default 2000). 0 rebuilds on
    /// every refresh, at the cost of doing it throughout a live turn
    #[arg(short, long, default_value_t = 2.0, value_parser = parse_delay)]
    pub delay: f64,

    /// Clear persisted session extraction data before starting
    ///
    /// Keeps preferences and pricing
    #[arg(long)]
    pub clear_cache: bool,

    /// Replace this binary with the newest GitHub release and exit
    #[arg(long)]
    pub update: bool,

    /// Internal: the root half of `--update`. Installs the binary at PATH, which
    /// the user's half downloaded and verified, if it still hashes to SHA256
    #[arg(long, num_args = 2, value_names = ["PATH", "SHA256"], hide = true)]
    pub install_update: Option<Vec<String>>,

    /// Start on the version already installed, even if a newer one is known
    #[arg(long)]
    pub no_auto_update: bool,

    /// Write the agent aliases into your shell startup files and exit
    #[arg(long)]
    pub install_alias: bool,

    /// Remove the agent aliases from your shell startup files and exit
    #[arg(long)]
    pub remove_alias: bool,

    /// Ask the agents to report session events to cctop, and exit
    ///
    /// Claude Code, Gemini CLI, Cursor, Codex and OpenCode. Takes `user` (the
    /// default) or `project` for the current directory's settings
    #[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
    pub install_hooks: Option<String>,

    /// Stop the agents reporting events to cctop, and exit
    ///
    /// Same scopes as --install-hooks
    #[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
    pub remove_hooks: Option<String>,

    /// Report what is installed where and whether events arrive, then exit
    ///
    /// Also whether each hook still points at this binary
    #[arg(long)]
    pub hooks_status: bool,

    /// Print a context brief for a session as markdown, and exit
    ///
    /// Takes a session id or a unique prefix of one; with no argument, briefs
    /// the most recently active session
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
    pub handoff: Option<String>,

    /// Copy a session into another harness's store, and exit
    ///
    /// The copy is in the shape that harness's own resume command reads. Takes
    /// a session id or a unique prefix of one, then the harness to convert to
    /// — currently claude and codex, in either direction — optionally with an
    /// account, as `codex:work`, to write into that account's store. The copy keeps the
    /// session's own id where the receiving store has it free, so cctop can
    /// tell the two apart as one piece of work
    #[arg(
        long,
        num_args = 1..=2,
        value_names = ["SESSION", "AGENT"],
        default_missing_value = ""
    )]
    pub convert: Vec<String>,

    /// List the sessions converted as a handoff, and exit
    ///
    /// --remove deletes the copies, leaving the sessions they came from
    #[arg(long)]
    pub converted: bool,

    /// With --converted, remove the converted copies rather than listing them
    #[arg(long, requires = "converted")]
    pub remove: bool,

    /// Print one line for a status bar — tmux, waybar, a shell prompt — and
    /// exit
    ///
    /// How many agents are working, how many are waiting on you, and the
    /// current spend rate
    #[arg(long)]
    pub statusline: bool,

    /// Print one session's report as JSON, and exit
    ///
    /// The document `cctop serve` answers /api/report/<id> with. Takes a
    /// session id or a unique prefix; with no argument, the most recently
    /// active session. A serve answers for a remote row by running this on
    /// the machine the session lives on, over the same ssh channel the row
    /// arrived by
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
    pub report: Option<String>,

    /// Print one session's conversation as JSON, and exit
    ///
    /// /api/chat/<id> on a serve. With --before, the window of turns ends just
    /// before that sequence number, which is how older turns are reached
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
    pub chat: Option<String>,

    /// With --chat, end the returned window before this turn's sequence number
    #[arg(long, requires = "chat", value_name = "SEQ")]
    pub before: Option<usize>,

    /// Print one session's whole conversation as markdown, and exit
    ///
    /// Every turn, the words verbatim and each tool call on a line, headed by a
    /// note that it is context rather than instructions — made to paste into
    /// another agent, an issue or a doc. Takes a session id or a unique
    /// prefix; with no argument, the most recently active session. Thinking is
    /// left out, and tool results too unless --tool-output asks for them
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
    pub export: Option<String>,

    /// With --export, include each tool call's result, cut at 800 characters
    #[arg(long, requires = "export")]
    pub tool_output: bool,

    /// Print what one session can reach, as JSON, and exit
    ///
    /// Instructions, skills, MCP servers — /api/access/<id> on a serve
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "SESSION")]
    pub access: Option<String>,

    /// Download the model the topical search needs (about 30 MB, once), then
    /// exit
    ///
    /// Until this is run, `/` searches transcripts literally and nothing
    /// reaches the network
    #[arg(long)]
    pub fetch_search_model: bool,

    /// Register `cctop --mcp` with Claude Code and Codex, and exit
    ///
    /// Through each agent's own `mcp add`. Takes `user` (the default) or
    /// `project` for the current directory. The agents can then recall what
    /// past sessions decided, and see what the running ones are doing
    #[arg(long, num_args = 0..=1, default_missing_value = "user", value_name = "SCOPE")]
    pub install_mcp: Option<String>,

    /// Serve the Model Context Protocol on stdin/stdout, and exit
    ///
    /// So an agent can ask what the other agents on this machine are doing.
    /// Read-only
    #[arg(long)]
    pub mcp: bool,

    /// Add a Claude account, and exit
    ///
    /// Asks whether it is a full login — its own ~/.claude-<name> via `claude
    /// auth login`, everything works — or a token from `claude setup-token`,
    /// which shares ~/.claude history but has no Remote Control or claude.ai
    /// connectors. Piped, reads one token from stdin. Takes the first
    /// account's name; defaults to `default`. Launch under one with `p` in the
    /// launcher, or `cctop as <name> claude`
    #[arg(long, num_args = 0..=1, default_missing_value = "default", value_name = "PROFILE")]
    pub add_account: Option<String>,

    /// Also show the sessions on another machine, read over ssh. Repeatable
    ///
    /// Takes `[user@]host`, or `[user@]host:/path/to/cctop` where cctop is not
    /// on the PATH a non-interactive ssh gets. $CCTOP_HOSTS adds more, comma
    /// separated. Remote rows are read-only: cctop acts only on this machine
    #[arg(long = "host", value_name = "HOST")]
    pub hosts: Vec<String>,

    /// Time each stage of the run and write the totals to a file on exit
    ///
    /// For sending to a bug report. Takes a path; with no argument, writes
    /// beside the cache and prints where. Carries counts and durations only —
    /// no session titles, project paths or file names
    #[arg(long, num_args = 0..=1, default_missing_value = "", value_name = "FILE")]
    pub trace: Option<String>,
}

/// `cctop wait`'s own flags.
///
/// A parser of its own, like `serve`'s, because `wait` is a bare word and
/// [`Args`] takes no positionals — `main` intercepts the word and hands the
/// rest here. Derived rather than hand-rolled because this one is meant to be
/// called from scripts, where a clap usage error that names the flag is worth
/// more than the few lines saved.
#[derive(Parser, Debug)]
#[command(
    name = "cctop wait",
    about = "Block until a session stops working",
    long_about = "Block until a session stops working, then exit.\n\n\
Reads what the dashboard reads: the transcripts, the agents' own hooks \
(heard live while waiting), and what a cctop recorded on the agent's rmux \
session. Useful for one agent to wait on another, or for a script that has \
just typed a prompt at one.",
    after_help = "Exit status: 0 when the condition is met, 1 when the session ended \
first without meeting it, 2 when the target names no session (or more than \
one), 124 on timeout."
)]
pub struct WaitArgs {
    /// The session: a prefix of its id, the name of its tab, or a pid in its
    /// process tree
    pub target: String,

    /// What to wait for
    #[arg(long, value_enum, default_value = "any-stop")]
    pub until: crate::wait::Until,

    /// Give up after this long: 90, 30s, 10m, 2h. 0 waits forever
    #[arg(long, default_value = "10m", value_parser = crate::wait::parse_timeout)]
    pub timeout: std::time::Duration,

    /// Print the outcome as one line of JSON on stdout
    #[arg(short, long)]
    pub json: bool,
}

fn parse_plan(s: &str) -> Result<Plan, String> {
    Plan::parse(s)
        .ok_or_else(|| format!("unsupported plan '{s}'; use one of: included, max, retail"))
}

fn parse_delay(s: &str) -> Result<f64, String> {
    let v: f64 = s.parse().map_err(|_| "must be a number".to_string())?;
    // `Duration::from_secs_f64` panics for values that do not fit its seconds
    // field, so reject them here along with non-finite values.
    if !v.is_finite() || !(1.0..(u64::MAX as f64)).contains(&v) {
        return Err("must be a finite number from 1 up to the maximum duration (seconds)".into());
    }
    Ok(v)
}

// ---------------------------------------------------------------------------
// --list
// ---------------------------------------------------------------------------

/// Column widths shared by the header and the data rows so they stay aligned.
const W_IDX: usize = 3;
const W_AGE: usize = 5;
const W_TOK: usize = 7;
const W_COST: usize = 9;

/// Fixed width consumed before the flexible session/model columns.
const fn fixed_width() -> usize {
    (W_IDX + 2) + W_AGE + 2 + W_AGE + 2 + W_TOK + 2 + W_TOK + 2 + W_COST + 2
}

/// Split the remaining width between the session label and the model name.
fn flex_widths(width: usize) -> (usize, usize) {
    let remaining = width.saturating_sub(fixed_width()).max(16);
    let model_w = (remaining / 3).clamp(8, 22);
    let label_w = remaining.saturating_sub(model_w + 2).max(8);
    (label_w, model_w)
}

fn format_row(index: usize, s: &Session, label: &str, width: usize) -> String {
    let now = chrono::Utc::now();
    let (label_w, model_w) = flex_widths(width);
    let cost = match s.total_cost {
        _ if !s.cost_available => "—".into(),
        _ if s.cost_is_free => "FREE".into(),
        Some(c) => util::compact_usd(c),
        None => "incl".into(),
    };

    format!(
        // Index is right-aligned so columns don't shift once it reaches 10.
        "{index:>W_IDX$}. {:>W_AGE$}  {:>W_AGE$}  {:>W_TOK$}  {:>W_TOK$}  {:>W_COST$}  {:<label_w$}  {}",
        util::relative_age(&s.started_at, &now),
        util::relative_age(&s.last_active, &now),
        util::compact_tokens(s.input_tokens),
        util::compact_tokens(s.output_tokens),
        cost,
        util::truncate(label, label_w),
        util::truncate(&s.model, model_w),
    )
    .trim_end()
    .to_string()
}

fn print_group(
    name: &str,
    sessions: &[&Session],
    start_index: usize,
    cost_label: &str,
    width: usize,
) {
    println!("{name}:");
    if sessions.is_empty() {
        println!("  (none)");
        return;
    }
    let (label_w, _) = flex_widths(width);
    println!(
        "{:W_IDX$}  {:>W_AGE$}  {:>W_AGE$}  {:>W_TOK$}  {:>W_TOK$}  {:>W_COST$}  {:<label_w$}  model",
        " ", "start", "last", "in", "out", cost_label, "session"
    );
    let labels = util::abbreviate_paths(
        &sessions
            .iter()
            .map(|s| s.label_source.clone())
            .collect::<Vec<_>>(),
    );
    for (i, (s, label)) in sessions.iter().zip(labels).enumerate() {
        let display = s.title.clone().unwrap_or(label);
        // No column of its own here: --list has a fixed layout, and naming the
        // owner in front of the label costs nothing when there is no owner.
        let display = match &s.owner {
            Some(user) => format!("{user}: {display}"),
            None => display,
        };
        println!("{}", format_row(start_index + i + 1, s, &display, width));
    }
}

/// The provider groups `--list` prints, in the order it prints them.
///
/// [`Provider::ALL`], so there is one list of harnesses rather than two that
/// have to be kept in step. This one had grown by insertion and read as
/// Codex, Claude, Cursor, Devin, OpenCode, Pi, Gemini, Windsurf — an order no
/// comment claimed to mean anything, and doctor's parsers section above its
/// partner listed the same eight differently. Group order is a presentation
/// choice rather than a fact about any harness, so it follows the enum.
pub fn list_order() -> impl Iterator<Item = Provider> {
    Provider::ALL.into_iter()
}

pub fn run_list(sessions: &[Session], plan: Plan) {
    let width = crossterm::terminal::size()
        .map(|(w, _)| w as usize)
        .unwrap_or(100)
        .max(60);
    let cost_label = if plan == Plan::Retail { "est" } else { "cost" };

    let mut offset = 0;
    for provider in list_order() {
        let group: Vec<&Session> = sessions.iter().filter(|s| s.provider == provider).collect();
        if group.is_empty() {
            continue;
        }
        if offset > 0 {
            println!();
        }
        print_group(provider.display_name(), &group, offset, cost_label, width);
        offset += group.len();
    }
}

// ---------------------------------------------------------------------------
// --json
// ---------------------------------------------------------------------------

#[derive(Serialize)]
pub struct JsonAccount {
    #[serde(skip_serializing_if = "Option::is_none")]
    email: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    organization: Option<String>,
}

#[derive(Serialize)]
pub struct JsonCost {
    available: bool,
    /// `null` when the plan bundles this provider's usage.
    total: Option<String>,
    included: bool,
    /// Recorded usage that priced at nothing, as with a free model. Distinct
    /// from `available: false`, which is a provider that records no usage.
    free: bool,
    /// Spend in the current local clock hour, which is what this key has
    /// always meant; kept so older readers and peers go on getting it.
    this_hour: f64,
    /// Spend in the last 60 minutes, rolling — the `$/1H` column's figure.
    ///
    /// A new key rather than a new meaning for `this_hour`: a peer reading an
    /// older cctop finds this missing and falls back, where a changed meaning
    /// would be read wrong without anything saying so.
    last_hour: f64,
    today: f64,
    /// Smoothed live spend rate, USD per minute.
    per_min: f64,
    /// `YYYY-MM-DD` -> USD, trimmed to [`JSON_DAYS`] days.
    ///
    /// Present so a reader can compute the same spend windows the overview
    /// shows rather than only a lifetime total. Trimmed because a session
    /// running for months would otherwise carry a bucket per day of it, and no
    /// window here looks back further.
    by_day: std::collections::BTreeMap<String, f64>,
    /// `YYYY-MM-DDTHH` -> USD, trimmed to [`JSON_HOURS`] hours.
    by_hour: std::collections::BTreeMap<String, f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    breakdown: Option<crate::session::Costs>,
}

/// How far back the per-day and per-hour buckets in `--json` reach.
///
/// One more than the longest window anything downstream computes — the
/// overview's calendar month and its 30-day rolling total for days, today's
/// 24 hourly buckets for hours.
const JSON_DAYS: usize = 31;
const JSON_HOURS: usize = 48;

/// Flatten a `key -> model -> USD` bucket map, keeping the newest `keep` keys.
///
/// Keys sort lexicographically in time order in both spellings cctop uses
/// (`YYYY-MM-DD` and `YYYY-MM-DDTHH`), so "newest" is the tail of a sort.
fn trimmed_buckets(
    buckets: &std::collections::HashMap<String, std::collections::HashMap<String, f64>>,
    keep: usize,
) -> std::collections::BTreeMap<String, f64> {
    let mut keys: Vec<&String> = buckets.keys().collect();
    keys.sort();
    keys.iter()
        .rev()
        .take(keep)
        .map(|k| ((*k).clone(), buckets[*k].values().sum()))
        .collect()
}

#[derive(Serialize)]
pub struct JsonTokens {
    input: u64,
    output: u64,
    total: u64,
    detail: crate::session::Tokens,
}

#[derive(Serialize)]
pub struct JsonActivity {
    tool_count: u64,
    /// Calls the transcript reported as failed. Absent — rather than zero —
    /// where the harness records no per-call outcome, since the two mean very
    /// different things to anything totalling them up.
    #[serde(skip_serializing_if = "Option::is_none")]
    tool_errors: Option<u64>,
    /// Compactions the session has been through. Claude Code only.
    #[serde(skip_serializing_if = "Option::is_none")]
    compactions: Option<u32>,
    tools: std::collections::HashMap<String, u64>,
    skill_count: u64,
    skills: std::collections::HashMap<String, u64>,
    web_fetch_count: u64,
    web_fetches: Vec<String>,
    web_search_count: u64,
    web_searches: Vec<String>,
    mcp_tool_count: u64,
    mcp_tools: Vec<String>,
    lines_added: u64,
    lines_removed: u64,
}

#[derive(Serialize)]
pub struct JsonSession {
    provider: &'static str,
    surface: &'static str,
    /// What the status dot says: `working`, `waiting` for the user, or `error`
    /// on an API failure. Independent of `running`, which is about a process.
    state: &'static str,
    /// What an `asking` session wants to do, when its hook or its screen said.
    #[serde(skip_serializing_if = "Option::is_none")]
    asking_for: Option<String>,
    /// Present, and true, when what an `asking` session holds is a question
    /// with choices rather than a permission prompt: Allow and Deny do not
    /// apply to it.
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    asking_question: bool,
    /// Present when cctop allows every permission prompt this session raises:
    /// since when, and what it has allowed. See [`crate::yolo`].
    #[serde(skip_serializing_if = "Option::is_none")]
    yolo: Option<crate::yolo::Entry>,
    session_id: String,
    started_at: String,
    last_active: String,
    project: Option<String>,
    title: Option<String>,
    /// Login name of the user the session belongs to, when cctop is reading
    /// every user's homes and this one is not the reader's own.
    #[serde(skip_serializing_if = "Option::is_none")]
    user: Option<String>,
    /// Which Claude profile — which `$CLAUDE_CONFIG_DIR` — the session was read
    /// out of. Absent for every other harness, none of which has the concept.
    #[serde(skip_serializing_if = "Option::is_none")]
    profile: Option<String>,
    /// Where the session is working when `cctop sandbox` launched it: the
    /// agent runs here, its commands and `path` are on `host`.
    #[serde(skip_serializing_if = "Option::is_none")]
    sandbox: Option<JsonSandbox>,
    #[serde(skip_serializing_if = "Option::is_none")]
    account: Option<JsonAccount>,
    model: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    harness: Option<String>,
    /// Branch checked out in the working directory, read on the machine the
    /// session is on — which is why it is carried rather than looked up by
    /// whoever reads this.
    #[serde(skip_serializing_if = "Option::is_none")]
    branch: Option<String>,
    /// How much the session asks before it acts, when its own hooks said.
    /// Absent for a session with no cctop hooks installed — nothing in a
    /// transcript records this, so it cannot be inferred.
    #[serde(skip_serializing_if = "Option::is_none")]
    permission: Option<&'static str>,
    models: Vec<String>,
    plan: &'static str,
    running: bool,
    /// CPU and resident memory across the session's process tree. Absent where
    /// no per-session process exists to measure.
    #[serde(skip_serializing_if = "Option::is_none")]
    process: Option<JsonProcess>,
    cost: JsonCost,
    tokens: JsonTokens,
    /// Token rate per minute, smoothed the same way the `TOK/m` column is.
    tokens_per_min: f64,
    activity: JsonActivity,
    #[serde(skip_serializing_if = "Option::is_none")]
    rates: Option<crate::session::CodexRates>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    subagents: Vec<crate::session::Subagent>,
    #[serde(skip_serializing_if = "Option::is_none")]
    context: Option<crate::session::ContextUsage>,
    /// Other running agents on this session's ground. Absent when there are
    /// none, which is the ordinary case.
    #[serde(skip_serializing_if = "Option::is_none")]
    conflict: Option<JsonConflict>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<String>,
}

#[derive(Serialize)]
pub struct JsonSandbox {
    host: String,
    path: String,
}

#[derive(Serialize)]
pub struct JsonProcess {
    cpu: f32,
    memory: u64,
    command: String,
    /// Processes in the tree, which is what the `pids` figure in the UI counts.
    pids: usize,
}

#[derive(Serialize)]
pub struct JsonConflict {
    /// `file` when a peer has written a file this session also wrote,
    /// `directory` when they merely share a repository.
    level: &'static str,
    /// Session ids of the peers, not keys: an id is what every other field
    /// here is addressed by.
    peers: Vec<String>,
    files: Vec<String>,
}

/// Resolve `which` to one session — a full id, an unambiguous prefix, or the
/// empty string for the most recently active — or bail saying why.
///
/// Shared by every flag that prints one session's detail (`--handoff`,
/// `--report`, `--chat`, `--export`, `--access`), so they all answer an ambiguous prefix
/// the same way and all agree on what no argument means.
fn find_session<'a>(sessions: &'a [Session], which: &str) -> anyhow::Result<&'a Session> {
    let matched: Vec<&Session> = match which.is_empty() {
        true => {
            // `max_by_key` on the timestamps rather than on load order: the
            // loader groups by provider, so "last in the list" is whichever
            // provider sorted last, not whichever session ran last.
            sessions
                .iter()
                .max_by_key(|s| s.last_active.clone())
                .into_iter()
                .collect()
        }
        false => sessions
            .iter()
            .filter(|s| s.session_id.starts_with(which))
            .collect(),
    };

    match matched.as_slice() {
        [only] => Ok(*only),
        [] if which.is_empty() => anyhow::bail!("no sessions found"),
        [] => anyhow::bail!("no session id starts with '{which}'"),
        // Listing them is what makes the error actionable — a prefix is only
        // ambiguous relative to sessions the user cannot see from here.
        many => anyhow::bail!(
            "'{which}' matches {} sessions:\n{}",
            many.len(),
            many.iter()
                .map(|s| format!("  {} ({})", s.session_id, s.provider.as_str()))
                .collect::<Vec<_>>()
                .join("\n")
        ),
    }
}

/// Print one session's context brief to stdout.
///
/// The non-interactive half of `O` in the UI, and the half a script can use:
/// the brief is plain markdown on stdout, so piping it into another agent's own
/// prompt flag needs nothing of cctop beyond this call.
pub fn run_handoff(sessions: &[Session], which: &str, loader: &Loader) -> anyhow::Result<()> {
    let session = find_session(sessions, which)?;
    // The brief is built out of the tool history, which the cache does not
    // carry, so this is one of the two callers that needs a real parse.
    let data = loader.store().session_data_fresh(session);
    let brief = crate::handoff::build(session, Some(&data));
    // Printed *and* written: the record of the conversation is a file, and a
    // brief that named one it had not left would send its reader looking.
    print!("{}", crate::handoff::rendered(&brief));
    Ok(())
}

// ---------------------------------------------------------------------------
// --convert
// ---------------------------------------------------------------------------

/// Copy a session into another harness's store so its own resume command can
/// pick the conversation up, and say where it went.
///
/// The transcript form of a handoff: where `--handoff` writes a brief for an
/// agent to read, this writes the conversation itself in the shape the
/// receiving harness reads back. That is worth doing only for a pair cctop can
/// transcode between, and `crate::convert` is what knows which those are —
/// OpenCode keeps its transcripts in SQLite, so a conversion to or from it
/// answers "not yet" rather than half-doing it.
pub fn run_convert(sessions: &[Session], which: &str, agent: &str) -> anyhow::Result<()> {
    // A converted copy is excluded: converting one would hand over a
    // conversation cctop had already transcoded, losing whatever the first
    // conversion dropped, and the source it names is the session to convert
    // instead. Said here rather than left to chance, because the copy shares
    // its source's id and a bare prefix matches both.
    let session = find_original(sessions, which)?;
    if !crate::convert::convertible_session(session) {
        anyhow::bail!(
            "{} has no transcript on this machine cctop can convert",
            session.provider.as_str()
        );
    }
    // `codex:spare` names the account as well as the harness: the copy is
    // written for whoever resumes it, and a session moved because one login is
    // out of window has to land where the other login looks.
    let (agent, account) = match agent.split_once(':') {
        Some((agent, account)) => (agent, Some(account)),
        None => (agent, None),
    };
    let target = crate::pricing::Provider::parse(agent)
        .ok_or_else(|| anyhow::anyhow!("{agent} is not a harness cctop knows"))?;
    let profile = match account {
        Some(name) => Some(
            crate::config::launchable_named(target, name).ok_or_else(|| {
                anyhow::anyhow!("{agent} has no account named '{name}' on this machine")
            })?,
        ),
        None => None,
    };
    if !crate::convert::convertible(session.provider, target) {
        anyhow::bail!(
            "cctop cannot convert {} sessions into {}",
            session.provider.as_str(),
            target.as_str()
        );
    }
    let transcript = session
        .data_file
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("that session has no transcript on this machine"))?;
    let home = match (profile, target) {
        (Some(profile), _) => profile.dir.clone(),
        (None, crate::pricing::Provider::Claude) => crate::config::CLAUDE_CONFIG_DIR.clone(),
        (None, crate::pricing::Provider::Codex) => crate::config::CODEX_HOME.clone(),
        _ => anyhow::bail!("that harness has no store cctop writes into"),
    };
    let written = crate::convert::convert(session.provider, transcript, target, &home)
        .ok_or_else(|| anyhow::anyhow!("the transcript could not be converted"))?;
    // The id, because it is usually the session's own and the receiving store
    // may be a long way from the one it came from. `--json` and the tests read
    // this line, so it is the machine-readable part of the answer.
    println!(
        "{} {} {} {}",
        target.as_str(),
        written.session_id,
        if written.id_kept { "kept" } else { "renamed" },
        written.path.display()
    );
    Ok(())
}

#[cfg(test)]
mod convert_tests {
    use super::*;
    use crate::session::Session;

    /// A session with just enough shape for the resolution rules to act on.
    fn session(id: &str, provider: crate::pricing::Provider, converted: bool) -> Session {
        let mut s = Session::new(provider, id.into());
        s.label_source = "/tmp/proj".into();
        s.started_at = "2026-09-28T00:00:00.000Z".into();
        s.last_active = "2026-09-28T00:00:00.000Z".into();
        if converted {
            s.converted_from = Some(crate::convert::Provenance {
                harness: "claude".into(),
                session_id: id.into(),
                session_path: "/tmp/proj/source.jsonl".into(),
                converted_at: "2026-09-28T00:00:00.000Z".into(),
            });
        }
        s
    }

    const ID: &str = "5049dbcd-8ef7-412f-bf57-589e61c41d0e";

    #[test]
    fn a_prefix_matching_a_session_and_a_copy_of_it_resolves_to_the_session() {
        let sessions = vec![
            session(ID, crate::pricing::Provider::Claude, false),
            session(ID, crate::pricing::Provider::Codex, true),
        ];
        // Without the originals-first rule this is an ambiguity error, which is
        // the wrong complaint: the user named a session, and the copy is not it.
        assert_eq!(find_original(&sessions, "5049d").unwrap().session_id, ID);
        assert!(
            find_original(&sessions, ID)
                .unwrap()
                .converted_from
                .is_none()
        );
    }

    #[test]
    fn an_id_that_is_only_a_copy_says_where_the_session_really_is() {
        let sessions = vec![session(ID, crate::pricing::Provider::Codex, true)];
        let why = find_original(&sessions, ID).unwrap_err().to_string();
        assert!(why.contains("copy of a claude session"), "{why}");
        // Not "no session id starts with" — the id is right there.
        assert!(!why.contains("no session id starts with"), "{why}");
    }

    #[test]
    fn an_id_matching_nothing_says_so_plainly() {
        let sessions = vec![session(ID, crate::pricing::Provider::Claude, false)];
        let why = find_original(&sessions, "zzz").unwrap_err().to_string();
        assert_eq!(why, "no session id starts with 'zzz'");
    }

    #[test]
    fn no_argument_takes_the_most_recent_original() {
        let mut older = session(ID, crate::pricing::Provider::Claude, false);
        older.last_active = "2026-09-28T00:00:00.000Z".into();
        let mut newer = session(
            "aa4ff133-cde9-470f-aaff-1fd6ac2da49e",
            crate::pricing::Provider::Codex,
            false,
        );
        newer.last_active = "2026-09-29T00:00:00.000Z".into();
        let want = newer.session_id.clone();
        let sessions = vec![older, newer];
        assert_eq!(find_original(&sessions, "").unwrap().session_id, want);
    }

    #[test]
    fn only_copies_and_no_argument_says_rather_than_picking_one() {
        let sessions = vec![session(ID, crate::pricing::Provider::Codex, true)];
        let why = find_original(&sessions, "").unwrap_err().to_string();
        assert!(why.contains("copy of a claude session"), "{why}");
    }
}

// ---------------------------------------------------------------------------
// --converted
// ---------------------------------------------------------------------------

/// Resolve `which` to the session a conversion should read, preferring the
/// original over any copy made from it.
///
/// A converted session carries its source's id, so the same prefix matches both
/// and [`find_session`] would report the pair as ambiguous — a message about an
/// id that is genuinely shared, sent to a command that has an obvious answer.
/// The original is the one to convert: it holds the accounting the copy dropped
/// and is the session the user is thinking of.
fn find_original<'a>(sessions: &'a [Session], which: &str) -> anyhow::Result<&'a Session> {
    // `find_session`'s own rules — empty means the most recently active, a
    // prefix otherwise, several matches is an error — applied to the originals
    // alone. Filtering first is the whole point: a copy shares its source's id,
    // so the ambiguity has to be judged with the copies out of the way.
    if which.is_empty() {
        // Most recently active rather than first found, for the reason
        // `find_session` gives: the loader groups by provider, so load order is
        // whichever provider sorted last.
        return sessions
            .iter()
            .filter(|s| s.converted_from.is_none())
            .max_by_key(|s| s.last_active.clone())
            .ok_or_else(|| no_original(sessions, which));
    }
    let matched: Vec<&Session> = sessions
        .iter()
        .filter(|s| s.converted_from.is_none() && s.session_id.starts_with(which))
        .collect();
    let found = match matched.as_slice() {
        [only] => *only,
        [] => return Err(no_original(sessions, which)),
        many => anyhow::bail!(
            "'{which}' matches {} sessions:\n{}",
            many.len(),
            many.iter()
                .map(|s| format!("  {} ({})", s.session_id, s.provider.as_str()))
                .collect::<Vec<_>>()
                .join("\n")
        ),
    };
    Ok(found)
}

/// Why no original session matched, said in terms of the copy that did.
///
/// Without this the command would report "no session id starts with" for an id
/// that is right there, one directory over, and the user would be sent looking
/// for a typo rather than told the session they have is a converted copy.
fn no_original(sessions: &[Session], which: &str) -> anyhow::Error {
    let copy = sessions.iter().find(|s| {
        s.converted_from.is_some() && (which.is_empty() || s.session_id.starts_with(which))
    });
    match copy {
        Some(copy) => {
            let source = copy.converted_from.as_ref().expect("filtered above");
            anyhow::anyhow!(
                "{} is a copy of a {} session that is not on this machine — \
                 convert that one where it lives",
                copy.session_id,
                source.harness
            )
        }
        None if which.is_empty() => anyhow::anyhow!("no sessions found"),
        None => anyhow::anyhow!("no session id starts with '{which}'"),
    }
}

/// List the sessions cctop converted as a handoff, or remove them.
///
/// A converted copy is an ordinary session to the harness that reads it, which
/// is the point — but it is not work anybody did here, and a list of sessions
/// that grows every time somebody hands work over is a list nobody reads. This
/// is how they are found again, and how the copies are cleared out.
pub fn run_converted(sessions: &[Session], remove: bool) -> anyhow::Result<()> {
    let converted: Vec<&Session> = sessions
        .iter()
        .filter(|s| s.converted_from.is_some())
        .collect();
    if converted.is_empty() {
        println!("no converted sessions");
        return Ok(());
    }
    for session in &converted {
        let from = session.converted_from.as_ref().expect("filtered above");
        // The path is the transcript itself, which is the thing `--remove`
        // deletes. A row with no file is one a store keeps elsewhere, and
        // saying so beats printing a directory as though it were a file.
        let where_ = match session.data_file.as_deref() {
            Some(path) => path.display().to_string(),
            None => "no transcript on this machine".to_string(),
        };
        println!(
            "{} {} from {} {} ({where_})",
            session.provider.as_str(),
            session.session_id,
            from.harness,
            from.session_id,
        );
    }
    if !remove {
        return Ok(());
    }
    for session in &converted {
        // A session with a process behind it is somebody's *current* work now:
        // they resumed the copy and carried on, and deleting it would take the
        // transcript out from under a running agent.
        if session.is_running() {
            println!(
                "kept {} — a {} is using it",
                session.session_id,
                session.provider.as_str()
            );
            continue;
        }
        let Some(path) = session.data_file.as_deref() else {
            continue;
        };
        match std::fs::remove_file(path) {
            Ok(()) => println!("removed {}", path.display()),
            // The source is gone, the transcript lives on, and one failure
            // should not stop the rest of the list.
            Err(e) => println!("could not remove {}: {e}", path.display()),
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// --statusline
// ---------------------------------------------------------------------------

/// Print the one line a status bar shows, and exit.
///
/// `--list` answers "what is running" for a person and `--json` for a program;
/// this answers it for a tmux `status-right` or a waybar module, which has
/// room for a line and already knows how to keep one fresh. The counts use the
/// same state names the status dot and the `--json` document do.
pub fn run_statusline(sessions: &[Session]) {
    println!("{}", statusline(sessions));
}

/// The line `run_statusline` prints, split out so the tests can read it.
fn statusline(sessions: &[Session]) -> String {
    use crate::session::ActivityState;
    let live: Vec<&Session> = sessions.iter().filter(|s| s.is_running()).collect();
    if live.is_empty() {
        return "no agents running".to_string();
    }
    let mut parts = Vec::new();
    for (state, word) in [
        (ActivityState::Working, "working"),
        (ActivityState::WaitingForInput, "waiting"),
        (ActivityState::Asking, "asking"),
        (ActivityState::ApiError, "error"),
    ] {
        let n = live.iter().filter(|s| s.activity_state == state).count();
        if n > 0 {
            parts.push(format!("{n} {word}"));
        }
    }
    // The rate is the number a line refreshed every few seconds is there to
    // move: today's total is in the table for whoever wants it.
    let per_hour = live.iter().map(|s| s.cost_per_min * 60.0).sum::<f64>();
    if per_hour >= 0.005 {
        parts.push(format!("{}/h", util::adaptive_usd(per_hour)));
    }
    parts.join(" · ")
}

// ---------------------------------------------------------------------------
// --report / --chat / --access: the per-session documents, as commands
// ---------------------------------------------------------------------------
//
// The same three documents `cctop serve` answers `/api/report`, `/api/chat`
// and `/api/access` with. They exist as commands so the serve on *this*
// machine can answer for a remote row by asking the cctop that actually has
// the transcript — `ssh host cctop --report <id>` over the channel `--host`
// already opens — rather than by reading a path on the wrong filesystem.

/// Print one session's report as JSON, and exit.
pub fn run_report(
    sessions: &[Session],
    which: &str,
    plan: Plan,
    loader: &Loader,
) -> anyhow::Result<()> {
    let session = find_session(sessions, which)?;
    let data = loader.store().session_data_fresh(session);
    let report = crate::serve::report::build(session, &data, plan);
    println!("{}", serde_json::to_string(&report)?);
    Ok(())
}

/// Print one session's conversation as JSON, and exit.
pub fn run_chat(sessions: &[Session], which: &str, before: Option<usize>) -> anyhow::Result<()> {
    let session = find_session(sessions, which)?;
    // Deliberately not the cache: a conversation is the text the cache drops.
    let conversation = crate::serve::chat::build(session, before);
    println!("{}", serde_json::to_string(&conversation)?);
    Ok(())
}

/// Print one session's whole conversation as markdown, and exit.
///
/// `/api/chat/<id>/markdown` on a serve, and what that route runs on a remote
/// row's machine. The whole transcript, not the page's window: see
/// [`crate::serve::chat::whole`].
pub fn run_export(sessions: &[Session], which: &str, tool_output: bool) -> anyhow::Result<()> {
    let session = find_session(sessions, which)?;
    let conversation = crate::serve::chat::whole(session);
    let options = crate::serve::export::Options { tool_output };
    print!(
        "{}",
        crate::serve::export::render(session, &conversation, options)
    );
    Ok(())
}

/// Print what one session can reach — instructions, skills, MCP servers —
/// as JSON, and exit.
pub fn run_access(sessions: &[Session], which: &str, loader: &Loader) -> anyhow::Result<()> {
    let session = find_session(sessions, which)?;
    let data = loader.store().session_data_fresh(session);
    println!(
        "{}",
        serde_json::to_string(&crate::access::build(session, Some(&data)))?
    );
    Ok(())
}

/// Resolve a collision's peer keys back to the session ids the rest of the
/// document is addressed by. A key a caller cannot look up is worse than no
/// entry, so an unresolvable one is dropped rather than printed raw.
fn json_conflict(sessions: &[Session], c: &crate::collide::Collision) -> JsonConflict {
    JsonConflict {
        level: match c.level {
            crate::collide::Overlap::File => "file",
            crate::collide::Overlap::Directory => "directory",
        },
        peers: c
            .peers
            .iter()
            .filter_map(|key| sessions.iter().find(|s| &s.key() == key))
            .map(|s| s.session_id.clone())
            .collect(),
        files: c.files.clone(),
    }
}

/// Build the `--json` document for a set of sessions.
///
/// Split out from [`run_json`] because the same document is the wire format for
/// two other readers now: `--host`, which parses it back off an ssh pipe, and
/// the web dashboard, which streams it to a browser. Anything that is true of
/// the printed JSON has to stay true of theirs, so there is one builder rather
/// than three that drift.
pub fn json_sessions(
    sessions: &[Session],
    plan: Plan,
    store: &crate::cache::Store,
) -> Vec<JsonSession> {
    let claude_account = crate::quota::claude_account();
    let codex_account = crate::quota::codex_account();
    let collisions = crate::collide::detect(sessions);
    let now = chrono::Utc::now();

    sessions
        .iter()
        .map(|s| {
            let data = store.session_data(s);
            let m = &data.metrics;
            let included = s.cost_available && plan.includes(s.provider);
            // The credentials read here are this user's. Another user's
            // session is signed in as whoever they are, and stamping the
            // reader's own email on their row would be a wrong answer where
            // no answer is the true one.
            let account = match s.provider {
                Provider::Claude if s.owner.is_none() => claude_account.as_ref(),
                Provider::Codex if s.owner.is_none() => codex_account.as_ref(),
                Provider::Claude | Provider::Codex => None,
                Provider::Cursor
                | Provider::Devin
                | Provider::Gemini
                | Provider::OpenCode
                | Provider::Pi
                | Provider::Windsurf => None,
            }
            .map(|a| JsonAccount {
                email: a.email.clone(),
                organization: a.organization.clone(),
            });

            JsonSession {
                provider: s.provider.as_str(),
                state: match s.activity_state {
                    crate::session::ActivityState::Working => "working",
                    crate::session::ActivityState::WaitingForInput => "waiting",
                    crate::session::ActivityState::Asking => "asking",
                    crate::session::ActivityState::ApiError => "error",
                },
                asking_for: s.asking_for.clone(),
                asking_question: s.asking_question,
                yolo: s.yolo.as_deref().cloned(),
                surface: match s.surface {
                    crate::session::Surface::Cli => "cli",
                    crate::session::Surface::Editor => "editor",
                    crate::session::Surface::DesktopCode => "desktop-code",
                    crate::session::Surface::DesktopCowork => "desktop-cowork",
                },
                session_id: s.session_id.clone(),
                started_at: s.started_at.clone(),
                last_active: s.last_active.clone(),
                project: (!s.label_source.is_empty()).then(|| s.label_source.clone()),
                title: s.title.clone(),
                user: s.owner.clone(),
                profile: s.profile.clone(),
                sandbox: s.sandbox.as_deref().map(|sandbox| JsonSandbox {
                    host: crate::sandbox::host_of(sandbox).to_string(),
                    path: sandbox
                        .get(crate::sandbox::host_of(sandbox).len() + 1..)
                        .unwrap_or_default()
                        .to_string(),
                }),
                account,
                model: (!s.model.is_empty()).then(|| s.model.clone()),
                harness: (!s.harness.is_empty()).then(|| s.harness.clone()),
                branch: crate::ui::columns::branch_of(s),
                permission: s.permission.map(crate::hook::Permission::label),
                models: data.models.clone(),
                plan: plan.as_str(),
                running: s.is_running(),
                process: s.process.as_ref().map(|p| JsonProcess {
                    cpu: p.cpu,
                    memory: p.memory,
                    command: p.command.clone(),
                    pids: p.pids,
                }),
                cost: JsonCost {
                    available: s.cost_available,
                    total: (s.cost_available && !included).then(|| util::money(data.costs.total)),
                    included,
                    free: s.cost_is_free,
                    this_hour: s.cost_clock_hour(&now),
                    last_hour: s.cost_hour,
                    today: s.cost_today,
                    per_min: s.cost_per_min,
                    by_day: trimmed_buckets(&s.costs_by_day, JSON_DAYS),
                    by_hour: trimmed_buckets(&s.costs_by_hour, JSON_HOURS),
                    breakdown: (s.cost_available && !included).then(|| data.costs.clone()),
                },
                tokens: JsonTokens {
                    input: s.input_tokens,
                    output: s.output_tokens,
                    total: s.input_tokens + s.output_tokens,
                    detail: data.tokens.clone(),
                },
                tokens_per_min: s.tokens_per_min,
                activity: JsonActivity {
                    tool_count: m.tool_count,
                    tool_errors: s.provider.records_tool_outcomes().then_some(m.tool_errors),
                    compactions: (s.provider == Provider::Claude).then_some(data.compactions),
                    tools: m.tools.clone(),
                    skill_count: m.skill_count,
                    skills: m.skills.clone(),
                    web_fetch_count: m.web_fetch_count,
                    web_fetches: m.web_fetches.clone(),
                    web_search_count: m.web_search_count,
                    web_searches: m.web_searches.clone(),
                    mcp_tool_count: m.mcp_tool_count,
                    mcp_tools: m.mcp_tools.clone(),
                    lines_added: m.lines_added,
                    lines_removed: m.lines_removed,
                },
                rates: data.rates,
                subagents: data.subagents.clone(),
                context: s.context,
                conflict: collisions.get(&s.key()).map(|c| json_conflict(sessions, c)),
                error: data.error.clone(),
            }
        })
        .collect()
}

pub fn run_json(sessions: &[Session], plan: Plan, loader: &Loader) -> anyhow::Result<()> {
    let out = json_sessions(sessions, plan, loader.store());
    println!("{}", serde_json::to_string_pretty(&out)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn cli_definition_is_valid() {
        Args::command().debug_assert();
        WaitArgs::command().debug_assert();
    }

    /// `cctop wait`'s defaults are the ones a script wants without thinking:
    /// any stop, ten minutes — and every flag takes the spelling the docs use.
    #[test]
    fn wait_parses_its_target_and_flags() {
        use crate::wait::Until;
        let parse = |argv: &[&str]| {
            WaitArgs::try_parse_from(std::iter::once("cctop wait").chain(argv.iter().copied()))
        };
        let plain = parse(&["3f2a"]).expect("a bare target");
        assert_eq!(plain.target, "3f2a");
        assert_eq!(plain.until, Until::AnyStop);
        assert_eq!(plain.timeout, std::time::Duration::from_secs(600));
        assert!(!plain.json);

        let full =
            parse(&["reviewer", "--until", "done", "--timeout", "30s", "-j"]).expect("every flag");
        assert_eq!(full.until, Until::Done);
        assert_eq!(full.timeout, std::time::Duration::from_secs(30));
        assert!(full.json);
        assert_eq!(
            parse(&["1", "--until", "any-stop"]).unwrap().until,
            Until::AnyStop
        );
        assert!(parse(&["1", "--timeout", "0"]).unwrap().timeout.is_zero());

        assert!(parse(&[]).is_err(), "no target");
        assert!(parse(&["1", "--until", "never"]).is_err());
        assert!(parse(&["1", "--timeout", "soon"]).is_err());
        // A usage error exits 2, which is also an unknown session's code.
        assert_eq!(parse(&[]).unwrap_err().exit_code(), 2);
    }

    #[test]
    fn delay_floor_enforced() {
        assert!(parse_delay("0.5").is_err());
        assert!(parse_delay("abc").is_err());
        assert!(parse_delay("NaN").is_err());
        assert!(parse_delay("inf").is_err());
        assert!(parse_delay("-inf").is_err());
        assert!(parse_delay(&f64::MAX.to_string()).is_err());
        assert_eq!(parse_delay("2.5").unwrap(), 2.5);
    }

    #[test]
    fn plan_parsing_rejects_unknown() {
        assert!(parse_plan("max").is_ok());
        assert!(parse_plan("nonsense").is_err());
    }

    #[test]
    fn clear_cache_flag_is_accepted() {
        let args = Args::try_parse_from(["cctop", "--clear-cache"]).expect("valid args");
        assert!(args.clear_cache);
    }

    /// The line a status bar is handed: live counts in the words the status
    /// dot uses, the burn rate while money is moving, and a quiet answer when
    /// nothing is running — stale rows must not count.
    #[test]
    fn the_statusline_names_live_states_and_the_burn_rate() {
        use crate::session::ActivityState;
        let live = |state| {
            let mut s = Session::new(crate::pricing::Provider::Claude, "x".into());
            s.inferred_running = true;
            s.activity_state = state;
            s
        };

        assert_eq!(statusline(&[]), "no agents running");
        assert_eq!(
            statusline(&[Session::new(crate::pricing::Provider::Claude, "x".into())]),
            "no agents running",
            "a stale transcript is not an agent at work"
        );

        let mut working = live(ActivityState::Working);
        working.cost_per_min = 0.20; // $12/h
        let line = statusline(&[
            working,
            live(ActivityState::WaitingForInput),
            live(ActivityState::WaitingForInput),
            live(ActivityState::Asking),
        ]);
        assert!(line.contains("1 working"), "{line}");
        assert!(line.contains("2 waiting"), "{line}");
        assert!(line.contains("1 asking"), "{line}");
        assert!(line.contains("$12"), "{line}");
    }
}