Toolbox

Troubleshooting

Symptoms, causes, and fixes for the failure modes users actually hit. Bridge-specific issues live in bridge troubleshooting.

Config or port edits don't take effect

Symptom: you edited .toolbox.yaml (mounts, env, proximo) or re-ran toolbox shell -p <port> / -B, but the running container behaves as before.

Cause: mounts, port bindings, and env vars are fixed at ContainerCreate — Docker accepts no post-hoc changes, and toolbox shell reattaches to an existing container instead of recreating it.

Fix: toolbox stop, then re-run toolbox shell …. The container is disposable; all persistent state lives on the ~/.toolbox/ binds, so nothing is lost.

Port already in use on the host

Symptom: toolbox shell -p <port> fails at container start with a Docker "address already in use" error.

Cause: another host process (or another toolbox container) already binds that host port.

Fix: find and stop the listener (lsof -i :<port> on macOS/Linux) or publish a different host port (-p <other>:<port>). If the conflict is another workspace's toolbox container, toolbox stop <name|dir> it first.

"unknown mount name" at startup

Symptom: toolbox shell fails immediately with an error about a mounts: entry referencing an unknown name.

Cause: a patch entry (no target:) names a mount that isn't a default and isn't a user entry — usually a typo. Failing loud at Plan() is deliberate, so typos surface immediately instead of silently appending a broken mount.

Fix: check the spelling against the default names in mounts (toolbox mounts list shows the effective set; the CLI suggests close matches). To add a genuinely new mount, set target: too (append form).

npx reports ECOMPROMISED or Lock compromised

Symptom: an npx invocation that spends long enough installing a package aborts with ECOMPROMISED / Lock compromised, commonly on Docker Desktop.

Cause: the container predates the container-local _npx mount, or an explicit mount still places /home/toolbox/.npm/_npx on a host filesystem whose inode identity is unstable. npm checks that identity while holding its install lock and treats a change as compromise.

Fix: run toolbox stop --all and reopen the shell so its fixed-at-create mount set includes the _npx tmpfs. If you explicitly mount _npx, remove that entry or point it at storage with stable inode identity. The old default cache data is safe to remove while no sessions are running:

$ rm -rf ~/.toolbox/npm-cache/_npx

This removes only regenerable npx installation trees; npm's reusable _cacache remains persistent.

Nerd Font placeholders in the prompt

Symptom: the starship prompt shows ? / ▢ replacement glyphs instead of icons (git branch, kubernetes, language logos).

Cause: the host terminal has no Nerd Font configured — glyphs are rendered by the host terminal, not the container.

Fix: install a Nerd Font on the host and select it in your terminal profile, e.g. brew install --cask font-jetbrains-mono-nerd-font. (Background on why the bundled prompt pins specific glyphs: shell-start internals.)

xdg-open, code, or OAuth login does nothing

See bridge troubleshooting — the usual causes are the bridge daemon not installed (toolbox bridge install), not running (toolbox bridge status), or a version-skewed host CLI. For OAuth callbacks that never reach the in-container CLI, see the loopback bridge.

herdr plays no sound when an agent finishes

Symptom: herdr's agent-state chimes never sound, with nothing on screen to say why. ~/.config/herdr/herdr-client.log carries sound playback failed … no mp3-capable audio player available, naming all five players it tried.

Cause: the container has no audio backend and none of the five players herdr spawns, no /dev/snd and no sound-server socket. The playback is handed to the host instead, over the bridge.

Fix: install the bridge (toolbox bridge install on the host — toolbox bridge status if it is already installed), and make sure the host CLI is new enough to serve /sound: an older daemon answers 404 and the shim fails through exactly as if no player existed. Then check the herdr side, ui.sound.enabled and the optional ui.toast.delivery banner — agent sounds documents both, and a running herdr server needs herdr server reload-config before it sees a config edit.

Sounds still only sometimes? Two rules drop a chime, and the daemon log says which one did. herdr's own predicate is the first, and it is not the bridge's: Request (an agent waiting on a human) always fires, while Done fires only when the pane's tab is inactive or the terminal window is unfocused. Panes in the same tab share that state, so a single-tab split layout stays quiet on Done while you are looking at it. The bridge adds the second: a chime that arrives while a player is still running is dropped — sound: skipped — because a chime lasts seconds and two players on one output device replay the same MP3 out of phase, heard as one garbled chime rather than as two.

A chime that garbles or cuts is the third case, and the reaping goroutine's line is where it is read: sound: player done after 1.9s next to a two-second MP3 puts the truncation past the player, in the host's output device, while sound: player failed after 12ms with the player's own stderr quoted after it puts it in the payload or the device the player opened. sound: ok alone only says the request arrived.

herdr opens in /home/toolbox instead of the workspace

Symptom: the toolbox shell lands in the mounted workspace, but herdr launched from there opens on a workspace labelled ~ sitting in /home/toolbox. Relaunching from the right directory changes nothing.

Cause: the launch cwd only ever creates herdr's first workspace. ~/.config/herdr/sessions/$HERDR_SESSION/session.json persists the workspace list with absolute cwds, and on restore herdr logs restored session already has workspaces; ignoring startup cwd — the directory you launched from is discarded. A persisted cwd this container does not mount then draws saved pane cwd does not exist, falling back to HOME: that workspace reopens on /home/toolbox, labelled ~. herdr saves the fallback, and /home/toolbox exists, so the warning never fires again — the stray workspace is restored on every start, and any launch that focuses it starts you there.

Fix: herdr workspace list to find the one labelled ~, then herdr workspace close <workspace-id>. The next save drops it and the workspace you care about is the only one left to restore.

Check before closing. herdr api snapshot prints every pane's live cwd, so you can tell the stray workspace from a real one that merely looks idle. Both log lines above live in that session's own server log, ~/.config/herdr/sessions/$HERDR_SESSION/herdr-server.log. If the whole list is disposable, herdr server stop and delete that session's session.json — with nothing to restore, the next launch honours the launch cwd again.

This is not the cross-project variant. ~/.config/herdr is one host-global bind shared by every toolbox container, which is why sessionplan names each herdr session after the workspace (HERDR_SESSION, internal/sessionplan/plan.go) — unnamed, every container reopened whatever another project saved last, a path it does not mount, and landed on /home/toolbox by the same fallback. That keeps other projects out of your session; it does nothing about a path inside your own session that stopped existing — a deleted clone, a pruned worktree, a renamed directory.

Stale local branches pile up after merges

Symptom: git branch lists many local branches whose PRs were already merged — squash-merged branches (the local copy isn't recognised as merged) and leftover worktree-agent-* branches from agent worktrees.

Cause: a squash merge rewrites history, so git branch -d refuses the branch as "not fully merged"; the remote branch is gone but the local tracking branch lingers. Agent worktrees leave throwaway branches behind once their worktree is removed.

Fix: run git prune-dead (baked into the image, invoked as a git subcommand). It git fetch --prunes, then deletes a branch only on positive proof that its PR/MR was merged, asking the forge one branch at a time; it also deletes worktree-agent-* leftovers actually merged into the default branch, and git worktree prunes stale entries. It never touches the current branch or the repository's default branch (resolved from origin/HEAD, falling back to a local main/master, then the current branch), so a branch with a live remote — or one checked out in another worktree — is left alone.

A gone upstream is not the proof. A squash-merged PR and one closed without merging leave exactly the same local state — upstream gone, commits unreachable from the default branch — and the second still holds the only copy of its work. So anything the forge does not confirm as merged is kept, with the reason printed: closed or never opened, unreadable state, or no logged-in CLI.

A merge on someone else's fork is not your merge. Neither CLI scopes its branch filter to the head repository — on cli/cli, gh pr list --head patch-1 --state merged returns merged PRs from thirty different fork owners. So the query names the repository explicitly (--repo <owner>/<name>, derived from origin, rather than whichever remote the CLI would have picked on a fork clone) and discards any match that came from a fork. Without that, a common branch name like fix or patch-1 would read as merged because a stranger's did.

Which forge, and being logged in. The CLI is chosen by asking gh and then glab which one holds a session for origin's exact host (gh auth status --hostname <host>), so github.com, gitlab.com, GitHub Enterprise and a self-hosted GitLab all work without the domain name mattering — what counts is where you are logged in. With no session for that host, nothing is deleted and the run tells you to gh auth login --hostname <host> (or glab auth login --hostname <host>). Both CLIs inherit their host credentials through inherit_host_auth.

Deletes use git branch -D (force), since a squash-merged branch never reads as merged locally. Recover one from the reflog — git reflog, then git branch <name> <sha> — until it is garbage-collected.

A new .test app is unreachable from the container

Symptom: https://<name>.test works in the host browser but fails to resolve/connect inside a shell that was already open.

Cause: a current shell normally follows proximo's Effective Route Inventory automatically. If it does not, the initial best-effort sync failed, the container predates the inventory mount, proximo's stack has not been re-materialized since gaining the inventory, or proximo-hosts --watch stopped; its log is ~/.toolbox-state/proximo-hosts.log.

Fix: run proximo up on the host to materialize the inventory, then toolbox stop and reopen the shell once to add its read-only mount. Later route changes need no recreate. Details: proximo runtime host sync.

"manifest unknown" with a registry mirror

Symptom: with registry_mirror configured, the first toolbox shell on a machine warns about a failed pull (manifest unknown / not found from the mirror), then aborts with image "<mirror>/filippolmt/toolbox:latest" not available locally and pull failed — check registry access.

Cause: a pull-through cache (Harbor proxy project, ECR pull-through, Artifactory/Nexus remote repo) only copies an image from GHCR when something asks for it — and some return manifest unknown on the very first request while they ingest the upstream copy asynchronously; replication-based mirrors serve nothing until a replication run completes. The registry refresh is best-effort, but on a first shell there is no local copy to fall back to, so imageplan.Ensure fails loud instead of starting a container without an image. Only successful pulls stamp the refresh TTL cache, so the failed attempt doesn't poison later ones — a retry asks the mirror again.

Fix: warm the cache before the first shell — docker pull <mirror-host>/filippolmt/toolbox:latest (re-run once if the mirror 404s while it ingests). Once the mirror serves the manifest, toolbox shell works with the default pull: auto. Alternatively take the registry out of the startup path entirely: pull manually from the mirror as above, then set toolbox config set --pull never --where global — the local copy becomes authoritative and no round-trip happens at shell start. Note the presence check is by exact ref: an image pulled from GHCR directly needs a retag first (docker tag ghcr.io/filippolmt/toolbox:latest <mirror-host>/filippolmt/toolbox:latest). Mechanism and precedence: image selection.

herdr reopens in the wrong directory

Symptom: herdr, launched from a project directory, opens its pane somewhere else — usually /home/toolbox, where a plain ls lists only go because everything else in the home is a dotfile.

Cause: herdr restores its persisted session and discards the launch directory — its own ~/.config/herdr/herdr-server.log says so verbatim: restored session already has workspaces; ignoring startup cwd. The session lives in ~/.config/herdr/session.json, bind-mounted to ~/.toolbox/herdr/config on the host, so the directory recorded at the first launch outlives every toolbox stop and container recreate. That persistence is deliberate — without the bind, every detachable session, plugin, and config setting would wipe on toolbox stop, which is herdr's whole value (toolbox mounts list shows the herdr and herdr-state entries).

Fix: herdr has no supported reset for the default session (herdr session delete default refuses), so delete the file with the server stopped:

$ herdr server stop
$ rm ~/.toolbox/herdr/config/session.json   # host path; ~/.config/herdr/session.json inside the container

The next launch records the directory it started from. Note that this clears a stale pin, it does not change the behaviour: a restored session always wins over the launch directory. For more than one project, give each its own session — herdr --session <name> — instead of resetting between them.

A herdr agent integration disappears or goes stale

Symptom: herdr integration status reports an integration you had installed as not installed after a toolbox stop, or as outdated against the herdr the image now ships.

Cause: herdr installs its integrations into each agent's own config directory, not under ~/.config/herdr — so the herdr and herdr-state binds do not cover them, and survival depends entirely on whether that agent's directory is itself a mount. claude (~/.claude/hooks/), codex (~/.codex/) and pi (~/.pi/agent/extensions/) are bundled tools with a row in internal/catalog, so they land on the ~/.toolbox/.claude, ~/.toolbox/.codex and ~/.toolbox/.pi binds and persist. A user-installed agent does not: opencode, for instance, is an npm global whose binary survives through ~/.npm-global, while the integration herdr drops into ~/.config/opencode/plugins/ — like everything else it writes outside the mounts — is gone after toolbox stop. Persistence cuts both ways, and that is precisely why the bundled case is automated: because those registrations outlive a recreate, they would otherwise stay pinned to the herdr that installed them. internal/build/assets/init.d/61-herdr.sh rewrites all three on every container start, so the binary in the container is always the authority for its own hook.

Fix: for a bundled agent, toolbox stop and open a new shell — the recreate re-runs the install against the herdr the new image ships. A stale registration that survives that is a real failure, and the script says so on stderr at boot; ~/.toolbox/state/init/61-herdr.log has the detail, and herdr integration install <name> re-runs it by hand. For a user-installed agent there is nothing to automate against — re-run the install after every recreate, or, better, move its config directory onto a mount so it stops vanishing: see persisting a user-installed tool's state. herdr integration status lists the state of all of them.