A newer runtime image is fetched in the background while you work. When you decide, one command moves your session onto it — same working directory, conversation resumed — without exiting and reopening by hand.
Two physical constraints shape all of it. Docker cannot swap the image of a
running container, and the command asking for the swap runs inside the very
container being replaced. So the reload is owned by the host-side toolbox shell process — it tears down, recreates and re-attaches — while the
in-container command only signals it. Process continuity is not preserved;
conversation continuity is.
When a newer version is available, the prompt prints a one-shot yellow line the next time it redraws:
toolbox-reload to move this session onto it (recreates the container).brew upgrade on the host.brew upgrade on the host, then toolbox-reload
here. One reload covers both axes, because it re-execs whatever toolbox
is on disk before it recreates anything — so upgrading first is what makes
the second command pick the new CLI up.The banner shows once per distinct result, in each shell. It will not reappear on every prompt; it shows again when the detected result changes, and once in every shell you open afterwards. A download you can already use outranks one that failed.
The line names the cost — recreates the container — because the reload asks for no confirmation, so this is the only place the price is stated before the act. It deliberately promises nothing about your agent; see what survives for why that promise would be conditional.
If your host CLI predates the reload, the banner falls back to the old
exit the shell and run toolbox stop wording instead of advising a command
that would refuse. See version skew.
Type it at the zsh prompt:
toolbox-reload
It always reloads, including when nothing newer exists. That is deliberate: it also re-execs the host CLI and recreates the container from scratch, which makes it the natural way out of a dirty session. A command that refused when there was nothing to adopt would be unreliable exactly when you reach for it to put things back in order.
It is a zsh function, not a program: a child process cannot make its parent shell exit, and the exit is the whole mechanism. Two consequences:
pull: never, which is what keeps never + toolbox build + reload alive.There is no confirmation. What you get instead is a short summary once the swap
completes: image digest before/after, CLI version before/after, with an
explicit unchanged when they match — the only evidence distinguishing a
successful-but-pointless reload from one that failed silently.
toolbox-reload → marker written, shell exits
→ host re-execs the toolbox on disk (a `brew upgrade` takes effect here)
→ refresh + verify the image
→ destroy the old container
→ create, attach
The order is the safety argument. The riskiest step is the first, when your old
session is still alive, and the image is proven present before anything is
destroyed: a reload that finds no usable image is not a failed reload, it is
a no-op that leaves you where you are. If something fails after the
teardown — a port conflict, a ~/.toolbox/Dockerfile overlay that will not
build — the error names the exact command that gets you back, because the
shell that would normally tell you has already exited.
Survives, because it already lives on a bind mount rather than in the container:
| What | Where |
|---|---|
| zsh history | ~/.toolbox-state/zsh_history |
| atuin history and its sync key | the atuin mount |
| Agent conversation (Claude / Codex) | the claude / codex mounts |
| Your shell customisation | ~/.toolbox-state/zshrc.d/*.zsh |
| Every credential and CLI auth directory | the auth mounts (gh, glab, gcloud, docker, …) |
| Workspace files | the workspace bind |
Carried deliberately: your working directory, when it is still under the
workspace. Anything else — cd /home/toolbox, a path inside a mount the new
session may not have, a directory since deleted — falls back silently to the
top of the workspace.
Re-derived, not carried: the whole TOOLBOX_* identity. The new session
computes it from the same workspace and the same config, so it is right for the
new image instead of replaying the old one's PATH and CA variables into it.
Snapshotting the old environment and restoring it is not merely expensive, it is
the half-updated state this whole mechanism exists to avoid.
Dies: running processes, background jobs, servers and watchers you started
by hand, and environment you exported into the live shell. For environment that
should persist, ~/.toolbox-state/zshrc.d/ already exists.
Also dies: another terminal attached to the same container. An ordinary exit spares a container while a sibling pane is attached; a reload cannot. The container name is fixed per workspace, so a spared old container would block the new one from being created, and half your panes on the old image and half on the new is worse than the loss. The rule is one sentence: another attached terminal or a process left behind, the reload lists both and kills both.
The list is printed, not prompted on. A dev server or a watcher is the normal
state of a working shell, so a confirmation would fire on nearly every reload.
It exists for the one thing you have forgotten — a Ctrl+Z-suspended agent, a
detached job — invisible at the prompt where you typed the command.
A toolbox worktree session auto-launches an agent, so a reload relaunches it
and resumes the most recent conversation (claude --continue,
codex resume --last). A plain toolbox shell reloads into a plain shell: the
reload reproduces how the session was started, it does not guess what you were
doing.
"How the session was started" includes the flags you typed. A session opened
with --profile work -p 7171 --peer=false reloads with all three: --profile
and --peer decide the container's name, --profile also decides which
credential root is mounted, and -p bindings are fixed at creation — so
dropping any of them would land you in a different container from the one
the reload just destroyed. Two things are deliberately not replayed: --create
and --path, whose work is already done (the named shell is in your config by
then), and worktree create, which comes back as worktree open <branch> so a
branch that now exists is not re-created and a prompt your agent has already
completed is not re-sent. The agent is pinned as resolved, so the session
comes back on the agent it actually ran even if you change the default
meanwhile.
The resume is conditional on your working directory having been carried:
claude --continue is keyed on the directory, and the workspace is mounted
twice. When the directory falls back, the agent launches bare — resuming the
wrong conversation in silence is worse than not resuming.
A reload replaces only the session container that asked for it. The peer anchor is shared and stays on whatever image it was created from — the reload does nothing about that, deliberately, since forcing the issue would either break sibling sessions or make you wait for other terminals to close. The anchor is functionally inert, so the mixed-image window is a documentation matter and not a correctness one.
The reload is, however, the window in which a stale anchor becomes replaceable: it stops holding the anchor before the next session asks for one, so a single-session developer gets for free the replacement the existing warning otherwise asks them to trigger by closing every shell. That warning now fires after the old container is gone, so it cannot be acted on beforehand.
The image is pushed when a change merges; the CLI is released on a tag and
reaches you through brew upgrade, which is your act. So the two can disagree,
in two directions that are not symmetric:
toolbox-reload exists but the CLI cannot hear
it. Left unguarded this is the worst failure here — the marker is written,
the old CLI does not recognise it, and the session is torn down for nothing.
So the command refuses at the prompt without exiting, naming
brew upgrade toolbox and leaving your session intact. The banner falls back
to the pre-reload wording for the same reason.toolbox-reload does not exist yet:
command not found, loud and lossless, and it heals in one exit-and-reopen.
There is a chicken-and-egg worth stating plainly — to get the command you
must exit the old way once — but it happens once per machine.One detector, host-side, running for as long as the shell is attached
(internal/imageprefetch). Three steps, all silent — the host process's
stdout is your terminal:
DistributionInspect asks the Docker daemon for the digest the
registry currently serves for your resolved image ref. It needs no
credentials against the public GHCR package, and it goes through the daemon
— so a registry_mirror is what gets
asked, and is therefore authoritative for how fast a new image is noticed.TOOLBOX_IMAGE_DIGEST, injected at creation), so it stays right
when you attach to a container another terminal created. The result is
written to a cache file; the in-container zsh precmd hook reads only that
file and renders the line.The CLI axis rides along: the host compares its own build version against the
GitHub releases/latest tag. Nothing in the container polls anything.
The prefetch stands down on a locally built image. toolbox build tags the
canonical ref, and an image that was built rather than pulled has no repo
digest. That absence is the signal: while it holds, the probe and the pull are
skipped entirely, so an automatic download never overwrites a build you asked
for. An explicit docker pull ghcr.io/filippolmt/toolbox:latest restores the
digest and the prefetch resumes.
State lives under $HOME/.toolbox-state/ in the container — the same
directory the host knows as ~/.toolbox/toolbox/state/ (mounts_root- and
profile-aware), which is how the two ends meet:
| File | Role |
|---|---|
update-check |
Latest comparison result the prompt renders. Written host-side, atomically. |
update-check.stamp |
Records each poll attempt (success or failure) so the cadence throttles retries even while offline. |
update-check.unavailable-since |
When the download first started failing. Removed as soon as the bytes land. |
Nothing records what a shell has already displayed: each shell keeps that in a variable of its own, so a stable cache never re-nags at the prompt and a shell opened later is still told. That scoping is deliberate — attaching to a container another terminal already runs (the connect branch) is never offered the start-up refresh, so the banner is the only way that session can learn it is behind.
The result file is key=value, one per line:
| Field | Meaning |
|---|---|
image_update |
1 when this session is behind the local store. Per-session, and the cache is shared — so each session restates it for itself. |
image_latest |
The digest the registry currently serves. |
image_state |
none / ready / unavailable — added alongside image_update, never replacing it, so an older image still renders its own (still true) sentence. |
cli_update, cli_latest |
The CLI axis. |
The poller's ticker is only an alarm: every tick re-reads the stamp, and the
registry is contacted only when it is older than the prefetch's cadence
window (imageprefetch.probeTTL). Because the stamp lives on the shared
state mount, that is one probe per window for you rather than one per open
shell — best effort, since two sessions whose
ticks coincide can both find the stamp stale and both probe; the daemon
deduplicates the pull that follows, so the cost is a second manifest lookup.
The first poll runs as soon as a shell attaches, subject to the same gate —
but a poll turned away by it still republishes image_update from the local
store, on every pass, because that field says whether your container is
behind while the one cache is shared by every workspace: the result on disk
may have been written by a sibling session whose container is already on the
new image, and that sibling is what keeps the gate shut. The restatement is
local-only; image_latest stays as the last real probe left it. Delete the
cache files to force a check on the next tick.
If the registry cannot be reached, the previous (still valid) result is left alone rather than blanked, and the stamp still advances — an offline machine costs one failed probe per cadence, not one per tick.
Set TOOLBOX_NO_UPDATE_CHECK to any non-empty value to silence the banner. Put
it in your env: passthrough (see
env: passthrough) to make it permanent
across shells — set there it also stops the host-side probe, so there is no
network traffic and no cache write either. Exported inside a live shell it
stops the rendering only; the network side is governed by
pull from that point on.
pull: never turns the whole act off: no probe, no prefetch, no banner. It is
a statement about the network, and a probe talks to the network. For a single
run without touching your config: TOOLBOX_PULL=never toolbox shell.
bridge: false is irrelevant here, before and after: the bridge is
host-application access (browser, editor, proximo), not network egress, and
detection has never gone through it.
There is no metered-network detection and no size threshold — deliberately.
The transfer itself is not new: under pull: auto those bytes arrive at shell
start too, and there you are asked
first. What moves is who is
present when they move — from a developer who just opened a shell to one
already sitting in it. The
download is also strictly rarer than the ticker, because a pull only follows a
probe that found a new digest, so its rate is the repo's merge rate. If that
is not acceptable on a given connection, pull: never is the answer, and
TOOLBOX_PULL=never is its per-session form.