Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Integrations

← back to the README

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, and cctop sets that up itself: on start it installs its hooks, for this user, into every agent whose config directory exists here, and keeps them current on every start after. Removing them by hand (--remove-hooks) is remembered, so they stay out until --install-hooks puts them back. By hand, for the rest:

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:

AgentWhere it is writtenWhat 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.tomlten 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.

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:

[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:

HarnessAskingWorking
Claude CodeEsc to cancelesc to interrupt
CodexPress enter to confirm or esc to cancel, enter confirm · esc skip, …Working (12s • esc to interrupt)
Gemini CLIAllow execution, Apply this change, …esc to cancel
OpenCode△ Permission required, …esc interrupt, …
CursorRun this command?, Skip (esc or n), …ctrl+c to stop
DevinApprove once + Esc cancel, …esc to interrupt, …
DroidEnter to select + Esc to cancelesc 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’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.

The footer is yours too:

[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), share for the corner that publishes the table, and link for a remote tab’s connection beside it (☁ 118ms · 2↻ · ×4.1: round trip, reconnects, compression; green, amber or red by round trip). 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

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:

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:

{"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 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.

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:

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
oldera toast; ↑devbox in amber in the HOST column; a cctop line in Info; and Update cctop on its host in the row menu (Enter)
newera toast and an Info line saying it is this machine’s cctop that is behind — cctop --update here
missinga 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:

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:

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.

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.

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:

AgentRemoteHow
Claude Codeyesevery 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
opencodeyesits 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 shellyesplain ssh -t into the directory; no mount
codexnoruns <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
devinnono shell setting; a hook could rewrite commands, but one that fails lets the command run here
pinonot 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. 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.
  • 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.

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