Toolbox

Cross-Container Peer Messaging: share a PID namespace through a toolbox-owned anchor

Status: accepted

Every figure below is as measured when this decision was taken — evidence for the choice, not a description of the repo today. Nothing here is kept in sync; current values live in the files that set them.

Claude Code's cross-session messaging (ListAgents / SendMessage) lets one session deliver a message to another on the same machine. Toolbox gives each workspace its own container, and upstream states the consequence plainly: "A container has its own filesystem, so a session inside it and a session on the host can't reach each other. Two sessions inside the same container can still message each other." Three conditions have to hold for two sessions to find one another, and toolbox satisfies exactly one of them today:

We make all three hold, on by defaultpeer_messaging: in the config (default true), with toolbox shell --peer=false / --peer as the per-run override, because the namespace is shared across workspaces and declining it for a single run has to be as cheap as leaving it on: a toolbox-owned anchor container holds a PID namespace that participating session containers join (PidMode: container:<anchor>), and a toolbox-owned Docker volume (toolbox-cc-socks) is mounted at /tmp/cc-socks so the sockets land in one shared directory. Sharing the namespace also makes pids unique by construction, which removes the registry collision rather than working around it.

The anchor runs the toolbox runtime image with its entrypoint overridden to a bare sleep, not a second minimal base image. The image is already on disk on any host that can open a shell and its layers are shared, so the anchor costs no extra disk — and, more to the point, there is no registry pull that can fail on an offline host, which is the very failure mode the degrade-on-missing-anchor rule below exists to absorb. It is AutoRemove: false (it must outlive the sessions referencing it), carries the toolbox- prefix so toolbox stop --all sweeps it up, and is filtered out of toolbox list, which enumerates shells.

The setting is folded into the container name. Mounts and HostConfig are fixed at ContainerCreate, so a session whose setting changed would otherwise reattach to an existing container carrying the old PidMode — and the failure is silent: the session starts, looks healthy, and simply sees no peers. The same reattach wart is tolerated for --share on named shells, where a wrong mount set shows up immediately as a missing directory; here nothing shows up at all.

The fold has to be injective, or it reintroduces exactly the collision it exists to prevent. The workspace branch seeds the hash (\x00peer=1), which is injective by construction; the named branch appends .peer to the sanitized name. The separator is the whole point: -peer would put toolbox shell infra --peer and toolbox shell infra-peer in the same container, and the second would silently inherit a shared PID namespace it never asked for. A . is legal in a Docker container name and SanitizeShellName cannot produce one.

Considered Options

Bind a host directory (~/.toolbox/cc-socks) instead of a volume. What this ADR originally specified, and what shipped in #796. Rejected after it was found broken on the primary platform: Docker Desktop for macOS serves host binds over virtiofs, where chmod(2) on a socket inode fails with EINVAL. Claude Code chmods each inbox socket right after binding it, so the listener never starts — the session publishes no messagingSocketPath and is unreachable, its own ListAgents included, with nothing on screen to say so. touch and chmod on a regular file both succeed there, which is why the gate stayed green. A named volume lives in the daemon's own filesystem on every platform, so the bind-then-chmod sequence behaves the same everywhere. The cost is that the directory is no longer inspectable from the host, and that a volume is created root-owned while the session container runs as the unprivileged host UID — hence the ownership init below.

Run both sessions in one container. Upstream's own answer, and it works today. Rejected as the general answer because it collapses the per-workspace isolation that is the point of toolbox: the second session would run against the first workspace's mounts, not its own.

Delegate with docker exec instead of messaging. DooD is already a default mount (docker-sock), so any session can already run claude -p inside another toolbox container, in the right workspace, with the shared credentials. Rejected because it starts a new process: it delegates work but cannot reach the live session, which is the feature being asked for. It remains the better tool for fire-and-forget delegation and needs no code.

Share only the socket directory, and send with our own command. Delivery would work — reaching the socket file is enough on Linux, where the auth line is optional. Discovery would not: the pid liveness check drops entries from another namespace, so ListAgents would not list the peers and SendMessage could not address them by name. That leaves half the feature resting on an undocumented wire format, for less than the full one costs.

Remote Control on both containers. The only supported path: each container connects and they see each other as sessions on other machines. Rejected as the default because the messages then travel through Anthropic servers and it requires a claude.ai sign-in as the active authentication — a heavy round trip for two containers on the same host. It stays the fallback for anyone unwilling to take on the risk below.

Consequences