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
mod access;
mod advise;
mod alert;
mod alias;
mod attach;
mod burn;
mod cache;
mod cast;
mod cli;
mod clipboard;
mod collide;
mod config;
mod convert;
mod doctor;
mod elog;
mod embed;
mod fingerprint;
mod fleet;
mod handoff;
mod hook;
mod inject;
mod insight;
mod loader;
mod mcp;
mod notify;
mod opencode;
mod peek;
mod pricing;
mod proc;
mod quota;
mod recall;
mod rmux;
mod sandbox;
mod serve;
mod session;
mod settings;
mod shim;
mod ssh_config;
mod sshfs;
mod trace;
mod ui;
mod update;
mod util;
mod wait;
mod watch;
mod why;
mod yolo;
use clap::Parser;
use std::io::IsTerminal;
/// mimalloc, rather than whichever allocator the platform came with.
///
/// Nearly everything cctop does at load is allocate: parsing JSON transcripts,
/// on every core at once. That makes the allocator the hot path rather than a
/// detail of it, and the Linux binaries we ship are static musl builds whose
/// allocator does not hold up under exactly that — many threads, small
/// allocations, all at the same time.
///
/// Measured on a machine with 2020 sessions, the same commit built against
/// glibc instead of musl: discovery took 0.17s where musl took 7.22s, and the
/// whole run 2.8s against 13.5s. Neither number is about parsing.
///
/// Replacing the allocator keeps what musl was chosen for — one static binary
/// that runs on any Linux — instead of trading it away for a glibc build with a
/// floor on how old a distribution may be.
#[global_allocator]
static ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() -> anyhow::Result<()> {
// `cctop run <agent> …` is handled before clap so the agent's own flags are
// never mistaken for cctop's — `cctop claude --help` must reach claude.
//
// A first argument that names an executable is the same thing without the
// `run`: cctop takes no positionals, so a command is the only thing it can
// be. Anything else (a typo, a stray word) falls through to clap's usage
// error rather than being exec'd.
//
// The two forms differ in what surrounds the agent. `cctop run claude` is a
// transparent stand-in for `claude` and hands over the terminal; `cctop
// claude` starts cctop with the agent attached inside it, which is the point
// of launching it through cctop at all.
// `cctop hook` is spawned by the agent itself, many times a session. It is
// answered before anything else is set up — no config, no pricing, no cache
// — because the agent is blocked until it returns.
//
// Every platform, deliberately. Delivery is a no-op where there are no unix
// sockets, but the *exit code* is not: `--install-hooks` writes the agent's
// settings file on any platform, and a `cctop hook` that fell through to
// clap would exit non-zero, which Claude Code reads as a decision to block
// the tool call and feed stderr back to the model. Answering here is what
// keeps the guarantee the hook module is built around — see its docs.
//
// `args_os`, because `args` panics on an argument that is not valid Unicode
// and this runs before any panic hook of cctop's own is installed: a panic
// here exits 101, which is the one answer the agent reads as "block". A hook
// invocation's first argument is the bare word `hook`, so comparing the OS
// string is all this needs, and the guard below covers the rest.
if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("hook")) {
// Installed here rather than left to `emit`, because reading argv is
// itself what can unwind. `emit` installs the same hook again, which is
// idempotent and leaves it safe to call on its own.
std::panic::set_hook(Box::new(|_| std::process::exit(0)));
let argv: Vec<String> = std::env::args().collect();
std::process::exit(hook::emit(&argv[2..]));
}
// The two halves of `cctop sandbox` that Claude Code runs, as often as
// `hook` and for the same reason answered before anything is set up:
// `--sandbox-exec` is every Bash call (and every hook, which it hands
// straight to bash), `--sandbox-guard` every file tool call. Flags rather
// than words because Claude Code splits its shell prefix at the last " -".
{
let mut argv = std::env::args_os().skip(1);
let first = argv.next();
let rest = || {
argv.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>()
};
match first.as_deref().and_then(std::ffi::OsStr::to_str) {
Some("--sandbox-exec") => std::process::exit(sandbox::exec(&rest())),
Some("--sandbox-guard") => std::process::exit(sandbox::guard(&rest())),
_ => {}
}
}
// Piped into `head`, `jq` or `less` that stops reading early, every
// `println!` in the non-interactive commands panics on the closed pipe and
// prints a backtrace hint under output that was otherwise fine — `-l`,
// `--json`, `log` and `why` all did. Restoring SIGPIPE's default would
// also fix it, and would also kill the TUI the first time a child's pipe
// closed under it, so instead exactly that one panic is answered with a
// quiet exit and every other panic still reaches the usual hook. After
// `hook`, which installs its own and must never be changed by this.
{
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// A formatted panic carries a `String`, a literal one a `&str`.
let payload = info.payload();
let message = payload
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| payload.downcast_ref::<&str>().copied());
let closed = message.is_some_and(|m| {
m.starts_with("failed printing to stdout") && m.contains("Broken pipe")
});
if closed {
std::process::exit(0);
}
previous(info);
}));
}
// `cctop doctor` is intercepted here for the same reason `run` and `attach`
// are: cctop takes no positionals, so clap would answer a bare word with a
// usage error. Before the `is_command` check below, so a stray `doctor`
// binary on PATH cannot shadow it.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("doctor") {
std::process::exit(doctor::run(&argv[2..]));
}
}
// `cctop why` alongside `doctor`, and for the same reason: a bare word, and
// cctop has no positionals for clap to read one as.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("why") {
std::process::exit(why::run(&argv[2..]));
}
}
// `cctop burn` alongside `doctor`, and for the same reason: a bare word,
// and cctop has no positionals for clap to read one as.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("burn") {
std::process::exit(burn::run(&argv[2..]));
}
}
// `cctop log` alongside `burn`, and for the same reason. It only ever
// reads a file, so it needs nothing set up before it — which is also why
// it can debug every other command line cctop has.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("log") {
std::process::exit(elog::run(&argv[2..]));
}
}
// `cctop optimize`, `compare` and `yield` alongside `doctor`, for the same
// reason: all are bare words and cctop has no positionals for clap to read
// one as. Every platform — none asks anything of the operating system.
{
let argv: Vec<String> = std::env::args().collect();
if let Some(word @ ("optimize" | "compare" | "yield")) = argv.get(1).map(String::as_str) {
std::process::exit(insight::run(word, &argv[2..]));
}
}
// `cctop recall` alongside `doctor`, and for the same reason: a bare word,
// and its query is positional, which cctop otherwise has none of.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("recall") {
std::process::exit(recall::run(&argv[2..]));
}
}
// `cctop wait` alongside `doctor`, and for the same reason: a bare word
// with a positional of its own. Its flags are clap's, parsed from the rest
// — a usage error exits 2, which is also what an unknown session exits
// with, so a script has one code for "you asked about nothing".
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("wait") {
let args = cli::WaitArgs::parse_from(
std::iter::once("cctop wait".to_string()).chain(argv[2..].iter().cloned()),
);
std::process::exit(wait::run(&args));
}
}
// `cctop as <account> <agent>` alongside `doctor`, and for the same reason.
// It is how a token account is launched, from the launcher or a shell.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("as") {
std::process::exit(quota::run_as(&argv[2..])?);
}
}
// `cctop sandbox <host>:<path> [claude args…]` alongside `as`, and for the
// same reason: a bare word, with the agent's own arguments after it.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("sandbox") {
std::process::exit(sandbox::run_held(&argv[2..])?);
}
}
// `cctop serve` is intercepted alongside `doctor`, and for the same reason:
// it is a bare word, and cctop has no positionals for clap to read one as.
{
let argv: Vec<String> = std::env::args().collect();
if argv.get(1).map(String::as_str) == Some("serve") {
std::process::exit(serve::run(&argv[2..])?);
}
}
let mut agent: Option<Vec<String>> = None;
{
let argv: Vec<String> = std::env::args().collect();
// `cctop attach` puts a running agent on this terminal directly, with no
// UI around it. Handled here for the same reason as `run`: it takes a
// positional, and cctop otherwise has none.
if argv.get(1).map(String::as_str) == Some("attach") {
std::process::exit(attach::run_terminal(&argv[2..])?);
}
match argv.get(1).map(String::as_str) {
Some("run") => std::process::exit(shim::run(&argv[2..])?),
Some(word) if !word.starts_with('-') && shim::is_command(word) => {
agent = Some(argv[1..].to_vec());
}
_ => {}
}
}
// Everything after the agent's name belongs to the agent, so clap must not
// be shown it; the UI wrapped around it runs on its defaults.
let args = match agent {
Some(_) => cli::Args::parse_from(["cctop"]),
None => cli::Args::parse(),
};
// Before anything else measurable happens, and in particular before the
// caches are touched: a trace that starts after the slow part is no trace.
if args.trace.is_some() {
trace::enable();
}
if let Some(staged) = &args.install_update {
return update::install_staged(&staged[0], &staged[1]);
}
if args.update {
return update::run(false);
}
if args.fetch_search_model {
embed::fetch::fetch()?;
return Ok(());
}
if args.hooks_status {
let cwd = std::env::current_dir().ok();
for (line, problem) in hook::status(cwd.as_deref(), None).lines() {
eprintln!("{} {line}", if problem { "!" } else { "·" });
}
return Ok(());
}
if let Some(scope) = args
.install_hooks
.as_deref()
.or(args.remove_hooks.as_deref())
{
let installing = args.install_hooks.is_some();
let cwd = std::env::current_dir().unwrap_or_default();
let Some(scope) = hook::Scope::parse(scope, &cwd) else {
anyhow::bail!("unknown scope '{scope}'; use `user` or `project`");
};
// One line per harness, including the ones that could not be done:
// they are separate files, and a `notify` slot that already belongs to
// somebody else must not undo the four installs that succeeded.
for line in match installing {
true => hook::install(&scope),
false => hook::remove(&scope),
} {
eprintln!("{line}");
}
eprintln!("Sessions already running keep their old hooks until restarted.");
return Ok(());
}
if args.install_alias || args.remove_alias {
let changed = if args.install_alias {
alias::install()
} else {
alias::remove()
};
match changed.as_slice() {
[] => eprintln!("No shell startup file needed changing."),
files => {
for f in files {
eprintln!("Updated {}", f.display());
}
eprintln!("Restart your shell, or source the file, to pick it up.");
}
}
return Ok(());
}
if let Some(profile) = &args.add_account {
return quota::add_account(profile);
}
if args.clear_cache && cache::clear_session_cache()? {
eprintln!("Cleared cctop session extraction cache.");
}
if let Some(scope) = args.install_mcp.as_deref() {
if !matches!(scope, "user" | "project") {
anyhow::bail!("unknown scope '{scope}'; use `user` or `project`");
}
for line in mcp::install(scope) {
eprintln!("{line}");
}
eprintln!("Agents already running pick it up when restarted.");
return Ok(());
}
if args.mcp {
// Nothing may be printed to stdout but JSON-RPC: the transport is the
// stream, and one stray line of logging desynchronises the client.
return mcp::serve();
}
if let Some(which) = &args.handoff {
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
cli::run_handoff(&sessions, which, &loader)?;
loader.store().save();
finish_trace(&args);
return Ok(());
}
// The non-interactive session modes together: each reads one session's own
// transcript, and none of them wants a cache that might be mid-walk.
if !args.convert.is_empty() || args.converted {
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
if args.converted {
cli::run_converted(&sessions, args.remove)?;
} else {
let (which, agent) = args.convert.split_at(1);
cli::run_convert(&sessions, which[0].as_str(), agent[0].as_str())?;
}
loader.store().save();
finish_trace(&args);
return Ok(());
}
if args.list
|| args.json
|| args.statusline
|| args.report.is_some()
|| args.chat.is_some()
|| args.export.is_some()
|| args.access.is_some()
{
// Non-interactive modes need pricing before they can print anything, so
// fetch synchronously. The TUI refreshes it on a background thread.
pricing::refresh_pricing_blocking();
let mut loader = loader::Loader::new();
let sessions = loader.load(args.plan);
if args.json {
cli::run_json(&sessions, args.plan, &loader)?;
} else if args.list {
cli::run_list(&sessions, args.plan);
} else if let Some(which) = &args.report {
cli::run_report(&sessions, which, args.plan, &loader)?;
} else if let Some(which) = &args.chat {
cli::run_chat(&sessions, which, args.before)?;
} else if let Some(which) = &args.export {
cli::run_export(&sessions, which, args.tool_output)?;
} else if let Some(which) = &args.access {
cli::run_access(&sessions, which, &loader)?;
} else {
cli::run_statusline(&sessions);
}
loader.store().save();
finish_trace(&args);
return Ok(());
}
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
// With no terminal there is no UI to wrap the agent in, but there is
// still an agent to run: `claude` is aliased to this, and a pipeline or
// a script must not be told to use --json instead.
if let Some(agent) = agent {
std::process::exit(shim::run(&agent)?);
}
anyhow::bail!("the interactive UI needs a TTY; use --list or --json instead");
}
// Both streams are known to be a TTY by here, and every non-interactive
// mode (--list, --json, --update, the alias flags, `run`, `attach`, `hook`)
// has already returned — so this is the only path that may prompt. CI is
// excluded even when it hands us a TTY: nobody is there to answer. So is
// `cctop <agent>`, where someone is waiting on an agent to start and
// already has, for this run, exactly what the alias would have given them.
let launching_agent = agent.is_some();
let mut prefs = cache::UiPrefs::load();
// Before the pricing fetch, the shim and the UI, because this is the one
// moment a replacement is free: nothing is open yet, so the new binary can
// be exec'd in place of this process and the session that follows is simply
// the new version. Every non-interactive mode has already returned above, so
// no script and no hook can reach this. It returns when there is nothing to
// do or nothing worked, and does not return at all when it worked.
//
// `cctop claude` is excluded for the reason the alias prompt below is: an
// agent is being waited on, and a download and a keypress between the
// command and the agent starting is not what was asked for.
let auto_update = !args.no_auto_update
&& !launching_agent
&& settings::Settings::load()
.auto_update
.unwrap_or(prefs.auto_update);
update::auto_at_startup(auto_update, &mut prefs);
// After the update offer: a process that is about to be replaced by a newer
// one has no business asking a question the new one would have to ask again.
if !launching_agent && std::env::var_os("CI").is_none() {
alias::ask_on_first_run(&mut prefs);
}
// Load whatever pricing is already cached so the first frame isn't zeroed
// while the network fetch is still in flight.
pricing::load_cached_pricing();
// Started before the UI so a failure to launch prints as an ordinary error
// rather than from inside the alternate screen.
let hosted = agent
.map(|agent| shim::host(&agent, None, ui::render::pane_size()))
.transpose()?;
let code = ui::run(&args, hosted)?;
// After the UI is down, so the message is not painted over by the alternate
// screen being restored.
finish_trace(&args);
std::process::exit(code)
}
/// Write the trace, if one was asked for, and say where it went.
///
/// The path is printed rather than merely returned because the whole point is
/// to hand the file to somebody: a report written somewhere the user has to go
/// looking for is one they will not send.
fn finish_trace(args: &cli::Args) {
let Some(requested) = args.trace.as_deref() else {
return;
};
let path = match requested {
"" => trace::default_path(),
given => std::path::PathBuf::from(given),
};
match trace::write_to(&path) {
Ok(()) => eprintln!("cctop: trace written to {}", path.display()),
Err(error) => eprintln!(
"cctop: could not write trace to {}: {error}",
path.display()
),
}
}