cctop 0.24.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
# 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.

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.

## 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. Point a harness at it the way you would any stdio MCP
server:

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

Five tools, all read-only:

- **`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. Where something was already discussed or
  attempted, in any harness.
- **`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`, `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.

## 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.