cctop 0.35.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
# Integrations

[← back to the README](../README.md)

Three ways cctop connects to something outside itself: letting the agents
report their own state, letting agents query cctop, and reading another
machine.

## Letting the agents tell cctop directly

Both of those are cctop guessing. The agents can just say it:

```bash
cctop --install-hooks           # for this user: every agent below
cctop --install-hooks project   # only for the project in this directory
cctop --remove-hooks            # take it back out; same scopes
cctop --hooks-status            # what is installed, and whether it works
```

Press `h` (or `F8`) in the UI for the same thing with the install and remove
keys on it, so none of this needs you to leave cctop or restart it.

One install covers five agents, each asked in its own dialect:

| Agent | Where it is written | What is written |
|---|---|---|
| **Claude Code** | `~/.claude/settings.json`, or `<project>/.claude/` | every event it raises but one — thirty of them, from `SessionStart` to `SessionEnd`. The exception is `WorktreeCreate`; see below |
| **Gemini CLI** | `~/.gemini/settings.json`, or `<project>/.gemini/` | eight — `BeforeAgent`, `AfterAgent`, `BeforeTool`, `AfterTool`, `Notification`, `SessionStart`, `SessionEnd`, `PreCompress` |
| **Cursor** | `~/.cursor/hooks.json`, or `<project>/.cursor/` | seven — `stop`, `beforeSubmitPrompt`, `beforeShellExecution`, `sessionStart`, `sessionEnd`, `preCompact`, `subagentStop` |
| **Codex** | `~/.codex/hooks.json`, or `<project>/.codex/`, and `~/.codex/config.toml` | ten hooks — `Stop`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `SessionStart`, `SessionEnd`, `PreCompact`, `PostCompact`, `SubagentStop` — and `notify = ["cctop", "hook", "codex"]` |
| **OpenCode** | `~/.config/opencode/plugins/cctop.ts`, or `<project>/.opencode/` | a plugin, since OpenCode extends by code rather than by command — written for whichever OpenCode is installed, see below |

OpenCode's plugin is the one file cctop cannot register with a command, and the
only integration whose shape is decided by somebody else's release. OpenCode 2
replaced the plugin API outright and does not load a version 1 plugin at all — it
says so in a log file and carries on — so the file cctop writes carries both: a
named export, which is what version 1 has always loaded, and a default export
with `setup` for version 2 and `server` for version 1.18.29 and newer. One file
is right on every version, which means an OpenCode upgraded under a cctop that
never runs again keeps reporting rather than going quiet. Both halves report the
same events under the same names, so nothing downstream of the plugin has to
know which one ran.

An OpenCode cctop launches also has its own tab strip turned off, for that run
only: cctop's tabs are the real ones, and a tab bar inside the pane is a second
and lesser account of the same fact. That one does depend on the version — the
strip is a version 2 thing — so cctop asks the binary which one it is. `cli.json`
is yours and is not touched: type `opencode` yourself and your tabs are there.

Codex is asked twice, because its two ways of saying things fail differently.
Its hook framework borrowed Claude Code's spelling wholesale — same event names,
same nested `hooks` object, same JSON on stdin — so cctop needed no new reader
for it, and it reports everything Claude Code does bar the two failure events
Codex has no use for: its `PostToolUse` fires for a command that exited non-zero
as well as one that succeeded, and a turn that dies has no second ending. But a
Codex hook is inert until a person has looked at it: run `/hooks` inside Codex
and trust them, or they deliver nothing. `notify` stays installed alongside for
exactly that reason. It says only that a turn finished, and it says it the
moment it is written.

Nothing on disk says whether you have trusted them — Codex records that against
a hash of the hook, somewhere it does not document — so cctop reads it from the
events instead. `notify` carries no permission mode and a hook carries one on
every event worth having, which makes a Codex row that knows how much it asks
before it acts a Codex whose hooks are firing. Until one has, the panel keeps
the reminder on Codex's line, and starting a Codex from the launcher says it
where it is a keystroke away rather than a thing to remember:

```
Started codex in ~/proj — run /hooks in it and trust cctop's to see it here
```

A Codex you reattached to is left out of that: it has been running since before
whatever is installed now, so the answer for it is a restart rather than a
keystroke.

Each hook runs `cctop hook <event>`; the plugin and Codex's `notify` hand the
event over as an argument instead. Whichever way it arrives, it is reduced to
the same three facts — which session, what happened, which directory — and sent
to every running cctop over a unix socket. The agents disagree about all three
spellings (`session_id`, `thread-id`, `conversation_id`, `sessionID`; a `cwd` or
a `workspace_roots` array), so each is read under every name anyone uses.

Half the list is there to *end* a state rather than start one, which is the
half that goes wrong when it is missing. Claude Code fires `PostToolUse` only
for a tool call that succeeded and `Stop` only for a turn that ended cleanly, so
without their partners — `PostToolUseFailure` and `StopFailure` — a grep that
matched nothing leaves a tool call in flight forever, and cctop draws a tool
call in flight over a still screen as a permission prompt waiting for you.
`PermissionRequest` is the same fact said properly: it fires the instant Claude
Code asks, where the `Notification` for the same prompt is on a six-second
timer and the tool-in-flight reading is a guess.

It fires when the prompt is *raised*, though, not when it is decided — and
Claude Code's auto mode decides most of them itself, by asking a model, which
takes a few seconds. So cctop holds a permission prompt for four seconds before
it counts as a question: no bell, no blinking tab, no attention card. If the
decision arrives first — `PermissionDenied`, or the tool simply running — the
newer event replaces the held one and nobody is disturbed at all. If nothing
answers, the prompt is yours after the four seconds. Nothing else is held:
an MCP elicitation and a plain notification have no second opinion coming.

cctop asks for all of them rather than a chosen few, so that teaching cctop
about an event is a change to cctop and not to everybody's settings file. Most
fire a handful of times in a session. `MessageDisplay` is the exception — once
per batch of streamed lines, several times per message — and it buys the one
thing nothing else says: a long answer with no tool calls in it raises nothing
at all between `UserPromptSubmit` and `Stop`, so a turn spent writing was a turn
cctop could only guess at from a screen that had stopped changing.

Some are worth naming for what they fix. `Elicitation` is an MCP server asking
*you* something mid-task: not a permission dialog, so `PermissionRequest` never
fires, and a session blocked on one used to read as idle. `TeammateIdle` is an
agent-team member finishing its turn — the team's `Stop`, one member at a time.
`UserPromptExpansion` covers the path `UserPromptSubmit` misses, where typing
`/skillname` expands into a prompt without submitting one. `CwdChanged` moves
the ground under a row, since the project a session is drawn against comes from
its working directory. And `PermissionDenied` is the only trace auto mode leaves
when it refuses a call.

**`WorktreeCreate` is the one cctop will not install**, and not because of cost.
It does not observe worktree creation — it *replaces* it. Claude Code stops
calling `git worktree` and takes the new path from the hook's stdout, and a hook
that produces no path fails worktree creation with an error. cctop's hook writes
nothing to stdout by construction, because a hook that prints is a hook that can
derail the session it watches — so registering this one would break
`claude --worktree` and every subagent isolated in a worktree. If you want cctop
to know about worktrees, that needs a hook that actually creates one.

If you installed before an event was added — `PostToolUseFailure`, or a whole
agent — that install is short exactly those events, and cctop fills it in on
the way up rather than only mentioning it in the panel. It is filled in *where
it stands*: an install naming a different cctop keeps that binary, because
events reach every cctop that is listening whoever fires them. Only an install
naming a binary that no longer exists is repointed at this one, there being
nothing left to respect. The OpenCode plugin is topped up the same way, by
being rewritten: cctop owns that file outright, and an old copy of it forwards
fewer events for the same reason an old settings file registers fewer hooks.

The same goes for an install that has every event but not in the form this
version writes — an older cctop's command line, timeout, matcher, or an event it
registered and this one no longer does. Those entries are rewritten into the
current form, at the binary they already name, and only cctop's: your own hooks
in the same file, in the same event, even in the same wrapper, are left as they
are. This runs at every start of the dashboard and of `cctop serve`, on a thread
of its own so nothing waits for it, and after `cctop --update`, run by the new
binary since only it knows the new form. When everything is already right it
reads each file once and writes nothing; anything it does change is said once in
the status line (or on stderr for `serve`). `cctop hook` itself never does this:
it stays as fast and as silent as below.

### `/yolo` in Claude Code

A user install of Claude Code's hooks also lets YOLO be switched without leaving
the session: `/yolo` switches it on for that session (every permission prompt is
allowed until the session's process ends), `/yolo off` switches it off, and
`/yolo status` says which. The answer appears at once, in place of the command:
no model turn runs and no tokens are spent.

Two pieces do it. `cctop yolo-hook UserPromptExpansion`, installed with the
matcher `yolo`, does the switching: Claude Code runs it when you type the
command, it writes the same switch the table does — for the session in the
payload and the agent process in `CLAUDE_PID` (Claude Code 2.1.214 or later) —
and it answers with a block whose reason is what is now true, so the command
never expands. And a skill, `~/.claude/skills/yolo/SKILL.md`, exists only so
Claude Code knows `/yolo` as a command; it runs nothing. If the hook did not
answer — hooks not reinstalled since an upgrade, or a Claude Code without the
`UserPromptExpansion` event — the skill's text tells the model to say so and
change nothing.

The skill is refreshed and removed with the hooks, never written over a `/yolo`
of your own (a skill of that name, or `~/.claude/commands/yolo.md`), and not
installed at project scope, since a command that hands out permission is not
one to check in.

Why the hook may switch YOLO on: Claude Code fires `UserPromptExpansion` only
for a command you typed. The model's Skill tool goes through `PreToolUse`
instead, and the skill sets `disable-model-invocation` besides. The command
line cannot make that distinction — run from a shell in the session, `cctop
yolo` sees the same environment and process tree whether you or the model ran
it — so `cctop yolo on` is refused everywhere, while `cctop yolo off` and
`cctop yolo status` still work from the session's shell. What remains is the
bar every other way in sits at: a model whose Bash calls are broadly allowed —
`Bash(*)` or bypass mode — could run `cctop yolo-hook` with a payload of its own
making, as it could write the switch file directly.

Cursor also reads Claude Code's `settings.json` of its own accord, so with both
installed each moment arrives twice; that costs a process spawn and nothing
else, since the second event says exactly what the first did. The same goes for
Codex once its hooks are trusted: `Stop` and `notify` both report the end of a
turn, and applying the same fact twice changes nothing.

A state cctop was told about is believed until the agent says otherwise, with
one exception: "I am working" has a shelf life of fifteen minutes. An agent
mid-turn says something again well inside that — the next tool starts, the tool
comes back, the turn ends — so silence that long means the event that would
have closed it never came, which is what a killed session, a closed terminal
and an interrupted turn all look like. "I am waiting on you" never expires,
because nothing else was going to say it again and you may be away for the
afternoon.

A reported turn beats a still screen: the green appears the instant the turn
ends rather than two seconds later, and the amber no longer waits for a
transcript to be written. `SessionStart` and `SessionEnd` do the same for the
table itself — a session you just started appears at once instead of at the next
poll, and one that has exited stops claiming a state it is no longer in.
Sessions already running keep their old hooks until they restart.

The installer merges into your settings rather than writing them, recognises its
own entries by their shape so it is idempotent and removable however the binary
is named, writes through a temporary file so an interrupted write cannot leave
you with no settings, and refuses outright if the file is not valid JSON — or,
for Codex, not valid TOML — rather than replacing it. Codex's config is edited
in place, so the comments and layout around it survive. Codex allows only one
`notify` program, so an entry that is not cctop's is reported and left alone.
Agents are independent of each other: a `notify` slot already spoken for, or a
settings file that will not parse, is one line of the report and does not undo
the installs that worked.

The OpenCode plugin is the one integration that runs *inside* an agent rather
than beside it, so it is the one place the exit-code guarantee below cannot
help. Its handler ignores everything it does not report, wraps the rest in a
`try`, and never waits for the process it starts. cctop writes that file whole
and deletes it whole — it is the only file here it owns outright.

`--hooks-status` and the `h` panel exist because none of this is visible
otherwise: an install that points at a cctop you have since moved or deleted
looks exactly like an agent with nothing to say. cctop repoints that one for you
on startup — there is no behaviour to preserve in a command that runs nothing —
but leaves an install pointing at a *different* cctop that does exist alone,
since that is a second install and not a fault.

**`cctop hook` cannot break your session.** An agent reads a hook's exit code as
a decision — exit 2 blocks the tool call — so this one exits 0 unconditionally,
writes nothing to stdout that could be read as a decision, and is bounded by a
250ms deadline covering the whole exchange, on a thread the process abandons if
it overruns. No cctop running, a
stale socket, malformed input, a wedged cctop, an outright panic: every one of
them is a silent, prompt success. Dropping an event is always cheaper than
stalling an agent.

Its stdout stays empty but for two answers that decide nothing: the documented
no-op `{"continue": true}` for `Stop` and `SubagentStop`, and — only if you turn
on `warn_agents` — a note on a file write that another running agent wrote the
same file moments ago. See
[Telling the second agent](the-table.md#telling-the-second-agent).

## Reading the agents' screens instead

Hooks can fail in ways that look like an agent with nothing to say: not
installed, installed at a binary that has since moved, a session started before
they were, a permission prompt announced six seconds late. For an agent running
in one of cctop's own tabs, there is a second way in:

```toml
[settings]
read_screen = true   # or toggle it with `,`
```

cctop then reads the bottom of each agent tab's screen on every frame and takes
the state from the key hints the agent draws there — a way to cancel a dialog
holding the turn, a way to interrupt a turn in flight. The words differ per agent, and sometimes collide: `Esc to cancel` is a
permission prompt in Claude Code and a turn in flight in Gemini CLI. So each
harness has its own list:

| Harness | Asking | Working |
|---|---|---|
| Claude Code | `Esc to cancel` | `esc to interrupt` |
| Codex | `Press enter to confirm or esc to cancel`, `enter confirm · esc skip`, … | `Working (12s • esc to interrupt)` |
| Gemini CLI | `Allow execution`, `Apply this change`, … | `esc to cancel` |
| OpenCode | `△ Permission required`, … | `esc interrupt`, … |
| Cursor | `Run this command?`, `Skip (esc or n)`, … | `ctrl+c to stop` |
| Devin | `Approve once` + `Esc cancel`, … | `esc to interrupt`, … |
| Droid | `Enter to select` + `Esc to cancel` | `esc to stop` |
| Pi | — | `Working...` |

Claude Code's (2.1.283) and Codex's (0.157.1) were captured from real screens;
the rest come from [herdr](https://github.com/herdrdev/herdr)'s detection
manifests. Idle has no phrase: a screen that matches neither column counts as
idle once it has been still for two seconds, since every one of these agents
ticks a timer while it works. A phrase would not do — Codex keeps `? for
shortcuts` on screen while it works, and draws a frame mid-turn with no working
line at all.

When the screen says something, it outranks the hooks and the transcript, for
the row, the tab bar and the bell. It is off by default because these phrases
are each agent's UI, not a contract — a release that rewords its footer goes
unread until cctop catches up.

The read reaches further than the tab you are looking at. A detached rmux tab's
screen is borrowed with `capture-pane` once a second — no client is attached, so
nothing resizes — and a standalone `cctop serve` does the same for every running
agent it can reach, through the `cctop run` shim's replay socket or the pane
rmux holds. That is what lets the page show *asking* — and the Allow and Deny
buttons Claude Code and Codex prompts get — for a session nobody has a terminal
open on. What it cannot see is an agent in a plain terminal of its own: no shim,
no pane, no screen to borrow.

## Writing on the footer

The footer is yours too:

```toml
[settings]
footer_hide = "sort, follow"    # drop hints and badges by name; "share" drops the corner
footer_note = "on-call shift"   # a static line, drawn like any other badge
footer_command = "date +%H:%M"  # a command; its first line shows, refreshed every 30s
```

`footer_hide` names the badges (`idle`, `age`, `filter`, `cost`, `marked`,
`follow`, `bell`, `remote`, `conflict`, `provider`, `update`, `quit`, `note`) and the key
hints (`Move`, `Actions`, `Filter`, `Mark`, `Sort`, `Quit`, …, as labelled),
and `share` for the corner that publishes the table. `footer_command` runs
through `timeout 5s sh -c`, in the background: a command that hangs or fails
simply shows nothing until the next run.

## Letting agents see each other

```bash
cctop --mcp
```

serves the Model Context Protocol on stdin/stdout, so an agent can ask cctop
what the *other* agents on the machine are doing — the thing none of them can
find out for themselves — and what any of them, past or present, already
worked out. Register it with the agents you use:

```bash
cctop --install-mcp            # Claude Code and Codex, every project
cctop --install-mcp project    # Claude Code, this directory only (.mcp.json)
```

That runs each agent's own `claude mcp add` / `codex mcp add` rather than
editing its config file, which Claude Code rewrites while it runs. Anything else
that speaks MCP takes the usual stanza, which the command also prints:

```json
{"mcpServers": {"cctop": {"command": "cctop", "args": ["--mcp"]}}}
```

The server tells the agent at start-up what it is for, through MCP's own
`instructions` field, which a client puts in the agent's instructions. That is
what makes an agent reach for `recall` unprompted, without cctop editing anybody's
`CLAUDE.md` or `AGENTS.md`.

Seven tools, all read-only:

- **`recall`** — the passages of past sessions, from any agent, that answer a
  question: why something was decided, what was found last time a test broke.
  Ranked by the query's words and by its meaning (when the search model is
  fetched), with the two rankings fused by rank, at most two passages from one
  session, each with its session id, agent, directory and date. The caller's own
  session is left out, since its conversation contains the question. The same
  thing is `cctop recall` on the command line.
- **`read_passage`** — one passage from a `recall` answer with its neighbours,
  to read a hit in context.

- **`list_sessions`** — every session, any harness: model, directory, branch,
  tokens, estimated cost, context occupancy, and whether it is still running.
  Filterable by `running_only` and by `directory`, which is how an agent asks
  who else is in this repo.
- **`check_conflicts`** — the `!` column, asked rather than read: give it a
  directory and the files you are about to change, and it answers with the
  running agents in the same repository and which of those files they have
  already written. The one question an agent cannot answer for itself and pays
  for getting wrong, since a lost edit arrives with no error attached.
- **`get_session_context`** — the same brief `O` writes, for one session.
- **`search_sessions`** — the full text of every transcript on the machine,
  with a snippet of each match: which *sessions* mention a string, literally.
  `recall` is usually the better question; this stays for an exact string.
- **`wait_for_session`** — [`cctop wait`]driving-agents.md#waiting-for-an-agent-to-finish
  as a tool: blocks until a session stops working and answers with the same
  JSON. For an agent that handed work to another and wants to pick up when it
  is done. Capped at five minutes a call (60 seconds unless asked), since a
  tool call holds the caller's turn; on a timeout it says so, and the agent
  calls again. Unlike the command it does not listen for hooks itself, so it
  hears a finished turn a second or two later, once a transcript or a running
  cctop has it.

Nothing here starts, stops, or types at anything. An agent that can *drive*
other agents is a much larger proposition than one that can *see* them, and the
visibility is the half with no downside.

## More than one machine

cctop is otherwise a monitor of one computer, which is the wrong shape if the
agents are not all on it — a laptop in front of you and a devbox doing the heavy work,
each with its own idea of what today cost.

```bash
cctop --host devbox --host flo@builder
CCTOP_HOSTS=devbox,flo@builder cctop     # the same, from the environment
```

Their sessions join the table with a **HOST** column saying where each one is,
and their spend joins every total and every window in the Overview. The column
appears only when a host is configured; on one machine it would be the word
`local` repeated down the screen.

The mechanism is the dullest one available: a thread per host runs
`ssh <host> cctop --json` every 15 seconds and reads what comes back. No daemon,
no port, no protocol of cctop's own, nothing to install on the far side beyond
the cctop that is already there — ssh has the authentication and the transport,
and `--json` is the wire format whether or not anyone sends it over a wire. A
host must therefore have cctop installed and your key must reach it without a
passphrase prompt (`BatchMode=yes`, so a host that would ask is an error rather
than a hang).

**If it says `command not found`,** that is the usual first result and it is not
a lie: `ssh host cctop` runs a *non-interactive* shell, which on most setups
skips the rc file that put `~/.local/bin` or a version manager's shim on `PATH`.
Name the binary instead:

```bash
cctop --host devbox:/usr/local/bin/cctop
```

Remote rows are **read-only**. `d`, `Ctrl+K`, `s`, `a`, `R` and `O` all refuse
them by name, because every one of them reaches into *this* machine — a signal to a
process, a transcript on disk, a pty — and the same path on this filesystem is a
different file. For the same reason the branch shown is the one that machine
read, not whatever happens to sit at that path here, and the `!` conflict column
carries the verdict that machine reached: each one detects its own overlaps,
being the only one that can see its own disk.

Only the Info panel is filled in for a remote session. Cost, Context, Tool
Activity and the rest are readings of a transcript that stays where it is, and
they say so rather than drawing zeroes.

Two readers do reach back across the wire. `i` opens the conversation view for
a remote row — the transcript is walked by the cctop on the far side and the
document comes back over the same ssh channel, never parsed at the local path.
And a `cctop serve` — whether standalone or the one `B` runs inside the TUI —
answers `/api/report`, `/api/chat` and `/api/access` for remote rows the same
way.

A host that stops answering keeps its last rows and says so in the footer
(`⚠ devbox: Permission denied`). Blanking them would be the stronger claim —
those agents have not stopped, cctop has merely lost sight of them.

### Keeping the far side up to date

The two ends are separate installs, and nothing ties their versions together —
a server can sit on 0.17.4 for months while the laptop moves on, still sending
rows, just without whatever came since. So each time cctop connects to a host
(once at the first read, and again after the host comes back from being
unreachable) it also runs `ssh <host> cctop --version`, over the same options
as the poll. Every cctop ever released answers that, which is why it is a
separate round trip rather than a field in `--json`: the remote that matters is
the old one, and the old one would not know to send it.

When the versions differ you hear about it once per host per run:

| The far side is… | What cctop shows |
|---|---|
| **older** | a toast; `↑devbox` in amber in the HOST column; a `cctop` line in Info; and **Update cctop on its host** in the row menu (Enter) |
| **newer** | a toast and an Info line saying it is *this* machine's cctop that is behind — `cctop --update` here |
| **missing** | a toast saying there is no cctop where ssh looks, and how to name one with `--host host:/path/to/cctop` |

**Update cctop on its host** is only offered for a host known to be behind, and
only ever runs after you say yes to a confirmation that shows the command:

```bash
ssh devbox -- cctop --update
```

It runs with no terminal (`ssh -T`, `BatchMode=yes`, a closed stdin), and
`--update` asks questions only on a terminal — so the far side never waits on a
prompt nobody can see. If the binary there sits in a directory only root can
write (the `/usr/local/bin` install), the update fails and cctop says so, with
the command to run yourself:

```bash
ssh -t devbox sudo cctop --update
```

cctop does not run sudo on another machine for you. The same comparison is in
`cctop doctor --host devbox`, which warns when either side is behind.

### An agent here, working there

`--host` watches agents that run somewhere else. `cctop sandbox` is the other
way round: the agent runs here, with your login, your hooks and this
dashboard, and what it *does* happens on another machine.

```bash
cctop sandbox devbox:/srv/api
cctop sandbox devbox:~/src/api --model opus     # anything after the path goes to the agent
cctop sandbox --agent opencode devbox:~/src/api
```

The TUI starts the same thing from its new-tab launcher: press `c` for the
directory, type `devbox:~/src/api` or pick `devbox:` from the hosts in your
`~/.ssh/config`, and the agent you pick runs there — see [tabs and
splits](driving-agents.md#tabs-and-splits).

**Which agents can.** An agent qualifies only if *every* command it runs can be
sent to the host, so that nothing lands on this machine by mistake:

| Agent | Remote | How |
|---|---|---|
| Claude Code | yes | every Bash call goes through `CLAUDE_CODE_SHELL_PREFIX`; a PreToolUse hook keeps the file tools on the mount, and points a host path at it |
| opencode | yes | its `shell` setting points at a cctop script (`OPENCODE_CONFIG_CONTENT`), with `permission.external_directory` set to `deny` to keep its file tools on the mount; started `--standalone` |
| your shell | yes | plain `ssh -t` into the directory; no mount |
| codex | no | runs `<your login shell> -lc …` with the shell taken from the passwd entry, and has no setting to change it; its own remote mode needs codex installed on the host |
| devin | no | no shell setting; a hook could rewrite commands, but one that fails lets the command run here |
| pi | no | not verified |

**Connecting from the launcher.** As soon as a host is in the directory field,
cctop starts one ControlMaster for it in the background with `BatchMode=yes`
and no terminal, so it can never stop to ask you something inside the TUI. The
socket is per host and per user, in cctop's runtime directory (`m-<hash>.sock`,
short enough for the 108-byte socket limit), and the `cctop sandbox` the launch
starts finds it there and rides it rather than connecting again. A host that
needs a password, a passphrase or a host-key answer is shown offline with that
reason, and the launch tab — which has a terminal — connects interactively
instead. Each directory listing is one command over the master with a
5-second timeout (`sh`, no GNU-only flags), remembered for the session; the
repository scan (`find ~ -maxdepth 4 -name .git`, hidden directories skipped) is
run once per host. When cctop exits it closes the masters it started, unless a
sandbox tab is still using one — then that tab closes it when it ends. A master
nobody is using exits on its own after ten idle minutes, which covers a cctop
that was killed.

**Which directories can be used.** Any directory on the host that exists and
that you can read and write. The launcher marks a suggestion that is not — `not
readable on the host`, `read-only on the host` — and `cctop sandbox` refuses it
before mounting, with the same reason; the agents that mount are greyed out for
it, and your shell, which mounts nothing, can still go there. Nothing about
*this* machine refuses a directory: where it is mounted here is cctop's
problem, not yours (see below).

Nothing is installed on the host. It needs `bash` and `setsid`, which every
Linux box has, and this machine needs `sshfs` and `fusermount3`. When `sshfs`
is missing and cctop is on a terminal, it asks:

```
sshfs is required. Install it now? [Y/n]
```

and on yes prints and runs the install for your package manager — `apt-get
install -y sshfs`, `dnf` or `yum install -y fuse-sshfs`, `pacman -S sshfs`,
`zypper install sshfs` or `apk add sshfs` — through `sudo` unless you are root,
so sudo asks for your password on the same terminal. On no, with a package
manager it does not know, or with no terminal to ask on, it prints the command
and exits non-zero. A sandbox opened from the TUI keeps a failure on screen
until you press Enter. `install.sh` can install sshfs too; see
[installing](install.md#let-the-script-do-it). Three ordinary pieces do the
work:

- **One ssh connection.** cctop starts an OpenSSH ControlMaster to the host and
  every later call rides it, so you authenticate once — a passphrase or a host
  key question is asked on your terminal before Claude starts. It is your own
  `ssh` reading your own `~/.ssh/config`, so host aliases, jump hosts and agent
  keys work as they do at a prompt.
- **The directory, mounted here.** sshfs mounts the host's `/srv/api` on this
  machine, where Read, Edit, Write, Glob and Grep — which run here — see the
  host's files. At the same path when it can be, under cctop's cache when it
  cannot; see [where the mount goes]#where-the-mount-goes.
- **A shell that is not here.** Claude Code runs every Bash command through
  `CLAUDE_CODE_SHELL_PREFIX`, which cctop sets to itself. Each command goes to
  the host over the connection, in the same directory, and its output, exit
  code and final directory come back — a `cd` sticks, as it would locally.
  Interrupting a command (Esc, or a timeout) closes its ssh channel, and the
  host stops the command's whole process group when that happens. opencode is
  given a two-line script as its shell instead, which sends `-c <command>` the
  same way and opens a terminal on the host for opencode's own terminal.

The file tools can only see the mount, so a PreToolUse hook installed for this
launch alone (through `--settings`; your settings files are not touched)
refuses them anywhere else, telling Claude to use Bash for that path instead.
opencode gets the equivalent from its own `external_directory` permission, set
to `deny` in the inline config for this launch alone. opencode's LSP servers,
formatters and MCP servers still run here, on the mounted files.
Claude Code's own files — `~/.claude` and its temporary directory — stay
reachable. Claude is also told, in its system prompt, which host it is on and
what runs where.

Hooks and the status line are not Bash commands, and they still run here:
`cctop hook` keeps reporting to the dashboard. The row shows its host as
`devbox⇄`, on the web page too, and its paths are never compared with a local
session's for the `!` conflict column — the same path on two machines is two
files.

When the agent exits — or cctop is sent SIGTERM or SIGHUP — the mount is
unmounted, the directories made for it are removed again, and the connection
closed. If cctop is killed outright the two helpers go with it, and a mount left
behind ("Transport endpoint is not connected") is cleared the next time a
sandbox starts on that path.

#### Where the mount goes

**At the same path, when it can be.** If the host's `/srv/api` is an empty
directory here, or missing and yours to create, it is mounted at `/srv/api`. A
path then names the same file to the file tools and to the host's shell, and
nothing is translated.

**Under cctop's cache, when it cannot.** The usual case is a home directory:
the host's is `/home/you-on-the-host`, and this machine has no such home and a
`/home` only root can write to. It is also what happens when the path is here
already and has files in it (the mount would hide them), is a mount point, or
sits inside a WSL Windows drive or a network share. The host's directory is then
mounted at

```
~/.cache/cctop/remote/<host>/<path on the host>
```

which needs no sudo and no setup. The place depends only on the host and the
path, so it is the same every time: Claude Code keeps its history per working
directory, and `claude --resume` finds yesterday's session there. A second
sandbox in the same directory while the first is running goes to
`<host>~2/…`. The tab says where it mounted.

The directory then has two names — this machine's (`L`, under the cache) and the
host's (`R`) — and cctop translates between them where a name crosses from one
machine to the other:

- **A command on its way to the host.** The directory it starts in, and every
  `L` in its text, become `R`: the model writes the paths it has seen, and it
  works in `L`. `L` is replaced only where it stands as a whole path or a path's
  start — `"L/src"`, `--dir=L`, `PATH=L/bin:…` — never inside a longer name. That
  is safe to do without parsing the shell because of what `L` is: a long path
  under `~/.cache/cctop/remote/`, which no command contains except to mean this
  directory.
- **The directory a command ended in,** on its way back, becomes `L` again, so
  a `cd` sticks as it would locally.
- **A file tool given the host's name.** The host's commands print `R` —
  `pwd`, `git rev-parse --show-toplevel`, a compiler's errors — and the model
  then hands that to Read or Edit. The guard hook rewrites the path to `L`
  (through `updatedInput`, so Claude Code still asks permission exactly as it
  would have for that file) instead of refusing it.

Output is never rewritten: what a command prints is the host's, byte for byte.
No environment variable is sent to the host, so there is none to translate.
opencode gets the command half; it has no hook that can rewrite a tool's input,
so an `R` path in its file tools is refused as outside its directory rather
than translated. And `R` is put in as it is: one with spaces or quotes in it,
where the model left `L` unquoted, changes how the host's shell splits the
command.

Limits worth knowing: Claude Code starts each command in its current directory
*here* before cctop moves it to the host, so a `cd` outside the mount carries
over to the next command only if that directory exists on this machine too; and
a command that leaves something running in the background is stopped with the
rest of its process group when it returns.

## Every user on the machine

Run cctop as root and it reads every user's sessions, not root's own — which on
most machines is an empty table, since nobody runs an agent as root. This is
what `htop` does with processes, for the same reason: the point of running a
monitor privileged is to see the machine rather than your corner of it.

```bash
sudo cctop                       # every user's sessions
sudo CCTOP_ALL_USERS=0 cctop     # root's own only, the way any other user sees it
CCTOP_ALL_USERS=1 cctop          # sweep without root, where the homes are readable anyway
CCTOP_HOMES=/export/people/ana:/export/people/bo cctop   # homes discovery cannot find
```

Homes come from `/etc/passwd` — root and the login accounts, service accounts
skipped — plus whatever sits under `/home`, which catches users served by LDAP
or SSSD rather than the local file. `$CCTOP_HOMES` names any the machine keeps
somewhere else entirely, `:`-separated as a `PATH` is. Each home is read for
every harness cctop knows — Claude Code and its profiles, Codex, Cursor, Gemini,
OpenCode, Pi, Windsurf and Devin — so another user's session has its cost,
tokens and context like your own, not a `$0.00` row.

Their running agents are matched to their transcripts the ordinary way, by
resume id or working directory, but only ever to *their* transcripts: a process
is tied to a user by its uid, and a session by the home it was read from. Two
people with a checkout at the same path — a shared `/srv/app` — never have one's
agent credited to the other's session. An agent whose owner has no home cctop
can read shows as a row of its own.

Rows gain a **USER** column naming whose session each one is, your own included.
It appears only while more than one user's sessions are on the table, and goes
again when they are not. `/user:ana` filters the table to that person's
sessions, and the [tree view](the-table.md#the-tree-view) puts a heading per user
above the repositories.

### Root mode is read-only for everyone else

cctop reads other users' homes and never writes to them. Its cache, UI
preferences, logs, recordings, hooks and shell aliases all go in root's own
home (`/root/.cache/cctop` and so on), and `d` on another user's session is
refused rather than deleting their transcript. That holds under a `sudo` that
kept your `$HOME` or `$XDG_*` variables, too: root's home is read from
`/etc/passwd`, an inherited directory pointing into someone else's home is
ignored, and that home is read as theirs, under their name. Stopping an agent
(`Ctrl-k`, confirmed first) is the one action that still reaches another user's
session: it signals a process and touches no file, and stopping a runaway
process is what root is for.

The one thing cctop declines to guess is identity — the Account line and the
`account` field in `--json` are read from your own credentials, so they are left
off another user's row rather than stamped with your email.