Backend & Sessions
The backend daemon owns project state. It is the only process the frontend talks to over the line protocol, and it sits between the frontend and the Julia kernels it supervises. For the big picture and the reasoning behind the language split, read Architecture at a Glance.
What the daemon owns
A single long-lived Rust process (tokio) holds everything stateful:
Project state — the mode trees, preview caches, and per-workspace navigation state the frontend reads and mutates over the protocol.
File watching — a
notifywatcher per workspace turns external edits intofile.changedevents (protocol).Process supervision — it spawns and supervises the Julia kernel(s) and the REPL, restarting the kernel on crash and surfacing failures as protocol events.
The orchestrator LLM session — the Anthropic client and tool-use loop live here, not in the frontend.
The frontend never touches kernel state directly, even when everything runs on one laptop. Local-only operation is "a client connecting to a localhost-bound backend," not a separate code path.
Kernel launch and supervision
The daemon spawns the kernel as a child process and frames the conversation over its stdio, per the line protocol:
julia --project=<repo>/julia/kernel \
-e 'using ShipToolsKernel; ShipToolsKernel.serve(stdin, stdout)'Spawning is tokio::process::Command through lifecycle::child_signal::Signal::spawn, which puts the child in its own process group (Unix) or job (Windows). Each stderr line goes to the daemon log at debug, and a generation that fails logs the child's last 30 stderr lines at warn — the only place kernel stderr surfaces, so user-facing errors must travel as protocol events, not prints.
Restart policy differs by process:
| Process | On exit |
|---|---|
| Kernel | auto-restart, backoff from 250 ms doubling to 30 s, started over only after a kernel that served a minute after its hello; callers get Dead meanwhile; state rebuilds from disk, so restart is safe |
| REPL | never auto-restarts — a crashed REPL is meaningful; the user decides, and the UI must say "REPL is dead, press X to restart" |
Orphaned julia processes are unacceptable, so the whole tree of every process the daemon starts, but those ADR 0050 names as outside it, is killed (SIGKILL to its process group on Unix, TerminateJobObject on Windows) when its owner lets go of it or the daemon's controlled end fires the child signal, whichever comes first (ADR 0050, "controlled exits"). The tree is killed before its leader is reaped, because the leader's pid is the group's number. There is no SIGTERM grace and no taskkill. A process that deliberately left, for example a Unix descendant that called setsid or setpgid, is the one exception (ADR 0050, residual 7).
Transport, persistence, reconnect
The primary workflow is remote: a Windows-local frontend reaches a Linux remote host where the kernel and GPU live. Local operation is the fall-through. The session must survive SSH disconnects, reconnect from a fresh client, and not depend on host-specific port allocation.
Backend as a user service on the remote. The daemon runs as the
systemd --userunitsotd.service(installed by the release installer), so its lifecycle survives SSH drops; its log issotd.logunder the state root.Per-session Unix socket, not a TCP port. It listens at
$XDG_RUNTIME_DIR/sot/<session_id>.sock. Cross-machine, the frontend reaches it by spawning its ownsshchild rather than forwarding a port — see Transport.Transport auth. SSH authenticates the Unix user for remote socket access, and the socket path is private to that user. Direct TCP remains token-gated because
localhoston a shared remote is machine-scoped, not user-scoped.Reconnect carries revision. Every connect sends
{session_id, client_id, last_seen_revision}. The backend replays missed events from a bounded ring or sends a full snapshot if the client is too far behind; heartbeats (~5s) evict stale clients.last_seen_revisionis what makes reconnect feel like tmux — without it every disconnect would lose the orchestrator session and any in-flight kernel work.
client_id is in the handshake from day one; multi-client policy (first client read+write, second read-only follower) is stated but deferred.
Sessions
Superseded: tmux was the original session registry
The design below — one tmux session per backend, tmux as the registry — is how sessions began. Since ADR 0042 every new session on every host is a capsule row under its own supervisor, and the daemon no longer starts tmux sessions. The current model is described in Sessions and persistence; this section is kept as design history.
A session is one project's backend, registered and supervised by tmux rather than by a second daemon. The insight here is that tmux already lists, supervises, multi-attaches, survives disconnect, and forwards across machines — so tmux is the registry, and the "orchestrator role" is a frontend-side library plus tmux -F queries, not a new crate.
One tmux session per backend, plus
sot-home. Sessions are namedsot-be-<slug>(fromProject.toml'sname, falling back to the directory basename, with a-Ndisambiguator on collision). Home base issot-home.Durable metadata on disk. Tmux is runtime-only, so pointers live under
~/.config/sot/:state-<hostname>.tomlrecords where the frontend left off (the-<hostname>suffix matters because the Linux boxes share$HOME); per-workspace toml records how to bring a backend back.Sessions mode is a normal mode: col 1 lists sessions with a running/unreachable/stopped sigil, col 2 the panes, col 3 pane metadata, and the preview is a live
tmux capture-panetail.Resume is the default. Launched with no flags, Ship of Tools reads
state-<hostname>.toml, resolves the last session's toml, and — if the tmux session is alive and the daemon socket answers — restores nav state, focus, and scroll exactly where you left off. If the daemon died, or the tmux session is gone, it shows a banner offering to restart or recreate. A startup reconciliation pass matchessessions/*.tomlagainsttmux list-sessions(one cheap syscall) and offers to adopt orphans or delete stale toml.
Workspaces
Two requirements surfaced after the initial sessions design: switching projects must not stop the Julia kernel, and the deployment must stay one daemon, one socket, one ssh child. The earlier "frontend reconnects to a different daemon socket per session" approach satisfied the first but fought the second, so the two concerns — where kernels live vs. which process is the protocol entry point — are split.
One daemon. One Julia kernel child per workspace, spawned directly by the daemon and routed by workspace_id.
The kernel is a direct child of the daemon (
std::process::Command,--project=<workspace.project_root>, framed over stdio), not a tmux process. The workspace's tmux session still holds the shell/BL pane and any optional LLM pane, but not the kernel.The load-bearing invariant: switching the active workspace never restarts a kernel or rebuilds Julia state. The kernel holding
x = 5keeps holding it while you visit another workspace and return.Switching is a frontend state change. There is no
workspace.switchop; the frontend just setsactive_workspace_idand re-firestree.rootwith the new id. Cost is one round-trip plus a redraw.workspace_idis additive. Tree/preview/kernel/repl/concept/pty ops gain an optionalworkspace_id; a missing field resolves to the default workspace, so existing single-backend invocations keep working. Lifecycle ops areworkspace.list,workspace.create,workspace.destroy.Kernels can run different
Project.tomlenvironments — each is its own Julia process, soMyPackage@0.1in one workspace and@0.2in another coexist.
The trade-off: the daemon owns its kernel children, so a daemon restart loses Julia state across all workspaces. That buys away kernel-discovery sweeps and health probes; spawning sits behind a KernelHost trait so a tmux-hosted implementation can be swapped in later (phase 3) without protocol changes, if "kernel survives daemon restart" ever becomes a felt pain point. Cross-machine deployment is unchanged — one daemon, one socket, one ssh child, with workspace switching in-band on that single connection.