# web client `web/` is the browser client: a canvas (Svelte 5, Svelte Flow) where sessions open as panels. It is a client like the TUI, not an extension; it reads the same daemon HTTP API. `albedo web` (`cli/internal/cli/web.go`) finds or starts the daemon, serves `web/dist` (or `ALBEDO_WEB_DIST`, which the Nix package sets to its `web` build, `.#albedo-web`: `buildNpmPackage` over `web/`, so bump `npmDepsHash` in `nix/packages/web.nix` when `package-lock.json` changes) on `127.0.0.1:4317` (`--port`, 0 for any) and, when this machine is on a tailnet, on its tailnet address too (below), prints a launch link and opens it unless `--no-open` or `ALBEDO_NO_BROWSER` is set, then returns after the detached server is ready. Repeating the command reuses the server and link; changing the listen options requires `--stop` first. `--stop` stops it, `--new-link` replaces its sign-in key and signs existing browsers out, and `--foreground` runs the server in the calling terminal until interrupted. Without `--save`, the detached process is not supervised: it does not restart after a crash or reboot. `cli/internal/webui` owns the boundary: - Each browser tab multiplexes its session panels and root-list follow over one WebSocket at `/api/events`, avoiding the HTTP/1.1 six-connection SSE limit across tabs. `stream.ts` sends new follows immediately on an open socket, preserves each session cursor independently, and resubscribes an ended follow. Unsubscribe sends a stop only for a follow actually sent. Hidden tabs stop chat follows but keep one root-list follow for completion alerts; without that background follow they close pending and open sockets and reopen on return. Stale socket callbacks cannot revive them. Older daemons fall back to the existing SSE routes, retaining their connection budget and visibility cancellation. Both the Go proxy and Vite forward WebSocket upgrades with the daemon bearer token. - The daemon token never reaches the browser. The page calls `/api/...` on its own origin; the proxy strips `/api`, drops `Origin`, `Cookie` and `Referer`, and adds `Authorization: Bearer `. Session streams pass through unbuffered. - The launch key persists in `$ALBEDO_HOME/web-key` across stops and restarts. `/?key=` sets an `HttpOnly`, `SameSite=Strict` cookie that lasts 400 days (the longest browsers keep one), so a browser stays signed in after it closes, and redirects to `/`; `/api` without that cookie answers `401 web_signed_out`. `--new-link` revokes the previous key and cookies. - Requests whose `Host` is not `127.0.0.1`, `localhost`, a shared address, an answered tailnet name or the published origin's host are refused (DNS rebinding), and so is an `Origin` that is neither the request host's own, whatever scheme it arrived with (a TLS terminator makes the page https while the server speaks http), nor the published origin (`Server.ownOrigin`). The Origin comparison loses the default port on both sides, so `Host: name:443` and `Origin: https://name` are the same origin; any other port still tells two pages apart. - `--allow ` (repeatable; CIDR, or an IPv4 address whose trailing zero octets stand for any, so `10.0.0.0` is `/8` and `100.64.0.0` is `/16`; `webui.ParseNetwork`) shares it with other devices: it listens on `0.0.0.0`, answers only on this machine's addresses inside an allowed network (also accepted as `Host`) and only to clients from one or from loopback (`403` otherwise), and prints a launch link per address. Devices still sign in with the key. `--save` stores the given networks in the daemon's `ui.web_allow` setting and installs a user login service. It runs `albedo web --foreground` with the saved networks and port. `--save` alone stops the server, clears the saved networks, and removes the service. The service does not save `--public-url` or `--tailnet-https`; those combinations are rejected. - On a machine signed in to Tailscale (`tailscale status --json` reports `Running`; `webui.CurrentTailnet`), `albedo web` with no `--allow` and nothing saved shares to `100.64.0.0/10` (`webui.TailscaleNetwork`) as if it had been given, and `Server.Answer` also accepts the machine's MagicDNS name (full and short; the suffix is the tailnet's own, never assumed to be `ts.net`) as `Host`, case-insensitively. It prints `on your tailnet: ` beside the per-address links. When stdin and stdout are a terminal it then draws a QR code (`cli/internal/qr`: a small in-repo encoder, byte mode, level L, versions 1-10, half-block rendering in explicit black on white) of the first link another device can reach: the tailnet name, else the first shared address; a loopback-only run prints none. Tailnet traffic is authenticated device to device and the launch key is still required. Without a tailscale CLI or a running tailnet it is loopback-only as before. `--allow` or a saved `ui.web_allow` replaces the default; `--local` (exclusive with `--allow`, ignores the saved networks) serves this machine only. `--allow` takes networks, not device names: a device's own address is never one of this machine's. - `--public-url https://albedo.example.ts.net` names the origin a proxy in front serves this listener through: the printed link and QR carry it instead of loopback's http, and `Server.Publish` answers its host and accepts it as the page's own `Origin`, so a proxy may pass the public name or loopback as `Host` (`webui.ParseOrigin` lower-cases the host, leaves out a default port, and refuses a path, query or fragment). `--tailnet-https` gets that origin from Tailscale: `webui.Tailnet.ServeHTTPS` first requires `CertDomains` in `tailscale status --json` to cover the machine's MagicDNS name (on Tailscale, HTTPS certificates in the admin console; on Headscale, `dns.certificates`), and refuses before running any `serve` command otherwise. `Serve.Claim` then points `tailscale serve`'s https:443 root at this listener, or reuses a root already pointing here, reading `tailscale serve status --json` rather than its text; it refuses a root that publishes anything else or that Funnel opens to the internet, and removes a handler it created but could not confirm. The command that set the handler removes it on Ctrl-C, a failed start and `--stop`, only while it still points at this listener (`Serve.Release`). Each tailscale command has 5 s; a background start or stop gets 30 s (`webGrace`), both ending the server with SIGTERM so its handler comes down before a SIGKILL. Both flags are recorded in `web.json` (`public`, `tailnet_https`), so a later `albedo web` reprints the same link and `--new-link` resumes them. `test/e2e/web_test.py` drives this through a fake `tailscale` on `PATH`. - A transport failure or a 401 from the daemon triggers `Connection.Refresh`, so a restarted daemon is found on the next request. - `/assets/*` (content-hashed by Vite) is cached as immutable; everything else is `no-cache`. The root HTML shell uses an ETag of its current bytes, not a filesystem modification date: packaged files share Nix’s epoch mtime across releases, so date-only validation could otherwise keep stale HTML pointing at removed bundles. Legacy `If-Modified-Since` requests receive current HTML; matching content ETags still receive `304`. Reading the current shell also preserves in-place development rebuilds, and `/index.html` keeps its redirect to `/`. - `/frame/...` forwards artifact files to the daemon without requiring the web sign-in cookie. It permits `GET`/`HEAD`, and `POST` only to `.albedo/state` and `.albedo/errors`, which it forwards, and `.albedo/message`, which it answers itself (`message.go`): a page in a tab of its own posts its chat messages there, and albedo web sends them to the artifact's session after checking the key and the loop guard (robot-docs/artifacts.md). The `GET .albedo/version` long-poll a `latest/` tab waits on passes like any file, without a timeout. It strips browser `Cookie`, `Origin` and `Referer`, adds the daemon bearer token, streams the response, and applies the artifact sandbox/CORS/referrer/nosniff headers to every response. - While serving, the command atomically owns `$ALBEDO_HOME/web.json` (`pid`, actual `port`, `url`, `names`, `addresses`, `allow`, `local`, `public`, `tailnet_https`). A manifest is live while its pid exists and its port takes the connection (`webui.ManifestAlive`): a server that answers late is busy, so `--stop` and opening an artifact still find it. A per-home process lock prevents foreground, detached, and supervised web servers from running together. Each server removes only a manifest that still names its own pid. Sharing to a network (the tailnet by default) listens on every address, which binds beside a server already on `127.0.0.1` (Go sets `SO_REUSEADDR`) while that one keeps answering loopback, so before listening it claims `127.0.0.1:` once and refuses to start when another server holds it. `cli/internal/cli/login_service.go` owns the shared web/proxy login service. Each role and `ALBEDO_HOME` has a separate service identity. macOS uses a LaunchAgent in `~/Library/LaunchAgents`, with `RunAtLoad`, `KeepAlive`, and logs at `$ALBEDO_HOME/web.log`. Linux uses a systemd user unit under `$XDG_CONFIG_HOME/systemd/user` (or `~/.config/systemd/user`), enabled and started immediately. Without lingering, the command prints a `loginctl enable-linger` hint but does not change it. Lingering permits startup at boot and operation after logout. The service preserves the invoked executable path, including a moving Nix out-link; the package's compiled wrappers preserve that path through both wrapper layers. It retains `ALBEDO_HOME`, `PATH`, and explicit `ALBEDO_ROOT`, `ALBEDO_DAEMON`, or `ALBEDO_WEB_DIST` values. `ALBEDO_DAEMON` and `ALBEDO_WEB_DIST` values under `/nix/store` are omitted so the current package wrapper supplies fresh defaults at each start; non-store overrides are retained. With a web login service installed, a bare `albedo web` starts or kickstarts it and prints the launch links. `--stop` stops it through the service manager, so supervision does not restart it immediately. The command says that it returns at the next login and gives `albedo web --save` as the command to forget it. `--new-link` stops the service, revokes the old key, and starts the service again. Other listen options require a `--save` update or removal of the service. `ALBEDO_LAUNCHCTL` and `ALBEDO_SYSTEMCTL` override the OS manager executables for tests. In development, `npm run dev` in `web/` proxies `/api` the same way from `$ALBEDO_HOME/daemon.json`, read once at start (only when serving, so a build needs no daemon), dropping `Origin` and `Cookie`. WebSocket upgrades also drop `Referer` and require the page's own origin when an `Origin` header is present. It listens on the local network, open, so other devices at home can try the client; the `Host` it answers is limited to the machine's own name (bare and `.local`) and Tailscale's `*.ts.net`, since the proxy holds the token and a rebound name must not reach it. Background alerts (`attention.ts`, `notifications.svelte.ts`, `sessions.svelte.ts`): the tab title counts distinct watched sessions that finished a turn, acquired a blocking reason, or received agent mail while the tab was hidden or unfocused; the favicon gets a small dot. A watched session is pinned, on any open board, or the phone's current chat, excluding archived sessions. Root-list activity supplies busy-to-idle transitions; a changed latest-answer id also catches a turn missed while disconnected. Mail marks the watched recipient, not the sender: task dispatch and system event mail do not count. Unwatched child turns stay quiet. The existing roots collection supplies these events; no per-child follows or daemon changes are needed. A closed board alone does not subscribe its sessions. Returning to the tab (focus or becoming visible) clears the count and closes its system notifications. The settings switch asks for Notification permission only on a click and saves its opt-in in this browser, off by default. Denial, unavailable APIs and notification construction failures leave tab counts working. System notifications appear only while hidden, once per unread session, contain the session name and event kind (not message contents), and focus the tab and reveal its chat on click, reusing an open board before adding a window. Hidden tabs keep only the collection follow, including SSE fallback; chat/history reads still pause. If that follow is offline, the list is retried every 30 seconds. Browsers may suspend background tabs, so these are best-effort alerts, not push notifications. Words: a **board** is a named canvas of panels, and the open boards are the tabs along the top (`boards.svelte.ts`). A board holds sessions from any folders. **Workspace** keeps its albedo meaning, a session's folder, and the sidebar shows each session's folder under its name. Its sections are headed as the terminal heads them, the label in the text colour, its count faint, then a thin rule to the edge (the one place lines separate, as `sectionRule` does in the TUI); its rows alternate as the phone's list does, every other row in a section on `--rail-stripe` (the phone's step from its ground, taken from the rail's, about 1.06:1), hover lays a step over a row's own tone, and a session open on the board sits on `--open-tint`, the rail with a flat step of the accent (12% in light, 18% in dark), opaque so the stripes don't show through: a hue over the stripes' tones, under hover's step, and open rows that meet square their corners so a run of them reads as one block. `[` (shown on the sidebar's toggle, as search shows ⌘K; those key chips sit a 12% step of ink above their button) hides or shows the sidebar; hidden, it pops out floating over the canvas, without animation, as soon as the pointer is within 20 px of the canvas's edge on its side, or further out the faster it is heading there (15 px more per px/ms, up to 50 px), so a flick toward the edge opens it early (`App.svelte`, watched from the canvas so the edge still pans). It stays while the pointer is on it or within 48 px of it, the top bar above it included, or while its filter has focus, and goes 100 ms after the pointer is clearly elsewhere or leaves the page; `/` brings it out the same way with the filter focused (Escape clears it and puts it away), and only focuses the filter when the sidebar is shown. `+` or B makes a new board; double-click or F2 renames one; closing a tab (⇧W) keeps the board, which the palette reopens (`Open board: `); `Delete board` removes it. The last tab cannot be closed, so there is always a canvas. A board is an open canvas. **Windows** sit anywhere on it; the view pans freely and zooms out to an overview. Svelte Flow draws, drags and resizes the windows; `board.svelte.ts` places new ones (right of the selected window, top-aligned, stepping past overlaps) and owns the viewport. The wheel is handled in `Canvas.svelte` rather than by Svelte Flow, because windows and canvas share the trackpad: a pinch (ctrl-wheel) zooms around the pointer anywhere, a mouse wheel's notch with ctrl or ⌘ by one 10% step (a notch is a jump of 50 px or more, or any line-mode delta, where a pinch moves a few pixels at a time), an up-down scroll over a window that can scroll scrolls it while its text is readable (at least 8 physical pixels tall: the canvas text size × the canvas zoom × the window's own zoom × `devicePixelRatio`, which counts a Retina screen and the browser's zoom, so a 1× screen keeps windows scrolling down to about 60% and a Retina one to about 30%; scrolled natively; when the browser's scroll stays latched on the canvas, after a pinch over empty canvas or a pan under a still pointer, and a frame passes with room to scroll and no movement, the rest of that gesture moves the window by the fingers' own deltas, with no animation), so does a sideways scroll over anything under the pointer that scrolls sideways (a diff's code inside Pierre's shadow root, a wide table; found through `composedPath()`), a menu floating over the canvas (`MenuButton`'s list) or a view that pans and zooms itself (the agents tree), marked `holds-wheel`, keeps every scroll over it at that zoom even when it has nothing to scroll, and every other scroll pans. Dragging empty canvas pans; holding Space (unless a text field has focus) or the middle button drags the canvas from anywhere, over windows too, taking the pointer before Svelte Flow or the window sees it; holding Alt (Option), a drag anywhere on a window or note moves it, not only by its title bar, without selecting text, and the click that ends it is swallowed (Option-click would download a link), and Space with a double-click anywhere glides the view there, centred at 90% (`Board.centreOn`, 300 ms from the middle of the pane up to 500 ms from a corner), where a plain double-click on empty canvas leaves a note. On a touch screen (`Canvas.svelte`), two fingers anywhere, over windows too, zoom the view around the spot between them and pan it with their travel (Svelte Flow's own pinch stays off, and Safari's page pinch is cancelled anywhere while the canvas is up; `index.html`'s viewport sets `maximum-scale=1`, because iOS zooms the page into any focused field under 16 px and never zooms back, which left the canvas and sidebar off screen); one finger scrolls a window natively or pans empty canvas, and the touches of a pinch go no further until every finger lifts. Under `pointer: coarse` the invisible resize edges reach 14 px and the corners 40 px past a window, and edges, corners and title bars take `touch-action: none` so the browser never claims their drag as a scroll. + and − zoom the canvas in and out by 5 points (the buttons too), ⇧1 fits all windows, ⇧0 returns to 100%, M shows an overview map, ⌥←/⌥→ moves between windows, W closes the selected window, Enter focuses its field (and Escape leaves a chat composer so canvas keys work), S opens the new-session dialog, A opens the artifact gallery, R refreshes the session list, and ? opens the about pane. Each window also keeps its own zoom (`data.zoom` on the board, 50% to 200%, applied to `WindowFrame`'s `.body`): its title bar has a quiet `− +` pill; pointing at it (or focusing it) floats the level beside it on the left, over the chips so nothing moves, with the covered chip fading out under it, and once the window is zoomed the level stays there in the accent and takes it back to 100% when clicked, Alt (Option) with a scroll over a window zooms that window, and ⌥+, ⌥− and ⌥0 step or reset the selected window's zoom. The view controls (zoom, fit, map) in the bottom-right corner, under the overview map when it shows (as wide as the controls, measured, and showing and fading with them), and key hints in the bottom-left as the terminal's footer lists them (spc and a grab to pan, ⌥ and a move to move a window, cmd and a scroll to zoom, ⇧1, ⌥←→, spc and two clicks to zoom at a spot, two clicks for a note; the mouse gestures are Lucide's icons, drawn in `Gesture.svelte`, to keep them short; keys in the text colour, what they do muted, each pair on a surface of its own with a small gap between), fade in while the view moves and fade out 4 s after it stops; hovering or focusing them keeps them, and once faded they let clicks through to the windows beneath. What a window shows is a **view**, each framed by `nodes/WindowFrame.svelte`: a session's chat, or a review of one turn's changes; files, terminals and agent-made pages are meant to follow. A view opened from a window names it as its `parent`, opens beside it, and the canvas draws a faint dashed line between them (the board derives Svelte Flow edges from those parents; every window carries invisible handles for them). The review (`nodes/ReviewNode.svelte`, `review.ts`) opens from "review N files +a −r" on a turn's sign-off, or from the +a −r on any work line, which also scrolls it to the first file that stretch of work changed (`data.jump`). It needs no daemon support beyond history: every tool call's trace records each file it changed, with the unified diff (`changes[].diff`, or `reason` when the file was binary, unreadable or too large). Writes the harness makes for itself stay out of it (`albedo_trace.unobserved()`): the browser plugin's profile lock and marker and its auto-named screenshots (in the cache, see [kernel](kernel.md#browser-gestures)) among them; a screenshot saved to a path the cell chose is still the cell's change. The window keeps the session, the turn and the turn's first history position, reads that turn's entries (`after=`, until the next turn starts), hydrates their traces, and shows the files with their counts, then each file's diffs in order. Diffs are drawn by `@pierre/diffs` (Shiki highlighting, `pierre-dark`/`pierre-light` tokens over the canvas's own ground and diff colours; `views/DiffView.svelte`), loaded with the first review so the rest of the client stays small. The margin button beside a line (drag for several) leaves a note on the canvas beside the window, tied to those lines; "send review" sends every unsent note tied into the review as one message, after an optional overall line. While the session works, the review reads what came after the entries it has every 2.5 s, and once more when it stops (`readTurn` without `seek` ends at the first entry of another turn, so an older turn's review costs one page a read); results already filled in are kept, a file whose changes grew glows in the index for a moment, and the review follows its end as new changes arrive while the reader is at the bottom. The **terminal** (`nodes/TerminalNode.svelte`, `views/TerminalView.svelte`, `jobs.svelte.ts`) shows one background job's output, read-only. An activity pulse in a chat's title bar (`JobsButton.svelte`; not a prompt, which is kept for a live terminal: the chat's own `>_` button beside its folder chip opens one in the session's folder, and the folder chip only names the folder; on the phone above the composer, shown once the session has had a job; it and the artifacts icon share `MenuButton.svelte`) counts the running jobs and lists them, finished ones with their exit codes while the kernel keeps their output; the list polls `GET /extensions/run/sessions/{id}/jobs/recent` every 2 s while a job runs, 6 s otherwise. Choosing one opens a window beside the chat, tied to it like a review. The window opens on the last page of `.../jobs/{job}/output` and follows `next_offset` every 700 ms while the job runs (once more after it ends), keeps at most 400,000 characters, drops colour codes and shows a `\r`-redrawn line as its last draw. Its stop button asks twice, then posts `.../jobs/{job}/stop`. A job the kernel no longer has (a restarted kernel) says so. On a phone the same view opens in the chat's dock, above the composer. The **interactive terminal** (`nodes/PtyNode.svelte`, `views/PtyView.svelte`, `terminals.svelte.ts`) opens a live pseudo-terminal running the user's shell in any workspace. T or "new terminal" in the command palette opens one beside the active window, and clicking a chat's workspace path pill opens one in that session's folder. Underneath, `@xterm/xterm` (v6) with `@xterm/addon-fit` and `@xterm/addon-web-links` provides terminal emulation, clickable link opening, and dynamic theme synchronization with the canvas's madoromi colors and mono font preferences. The window connects over a WebSocket at `/api/terminals/{id}` to the daemon's PTY service (`albedo_terminals.erl`, `albedo_pty.py`), negotiating row/column sizing on resize and retaining output history across reconnects and board switches. Every attachment sends its `ready` frame before history or live output. An in-stream restart keeps its requesting socket attached, closes any other watchers, then sends that socket the new `ready` frame before output from the replacement PTY. Output is flow controlled: the worker puts `"credit": 8` in the spec it sends `albedo_pty.py`, and only then does the helper wait for credit (inbound frame `0x05` + uint16), so a running daemon's older worker that starts a newer helper from disk still gets unthrottled output, and a newer worker's credit frames are ignored by an older helper (`test/harness/pty_protocol_test.py` checks the first). With credit, the helper sends at most 8 output frames of at most 16 KiB ahead of the worker, so at most 128 KiB of output is read ahead; the worker grants one frame per frame it handles, and with no credit left the bridge stops reading the PTY, so a fast producer blocks on the PTY instead of filling the daemon. The worker serves attach, detach, input, resize, signal, restart and stop before queued output. A socket whose mailbox holds more than 32 messages is behind: its output is held (at most 64 KiB, newest kept) and retried every 20 ms, then sent as one binary frame once it catches up, and before any exit event. When bytes were dropped that frame starts with `\r\n\e[0m[albedo: skipped N bytes of output while this view was behind]\r\n`; the other sockets and the reconnect history are unaffected. The footer displays live dimensions (`cols × rows`), signal interruption (^C), clear, and restart controls. The **trace** (`nodes/TraceNode.svelte`, `views/TraceView.svelte`, `trace.ts`, `TraceTie.svelte`, `tracelink.svelte.ts`) shows one task as albedo ran it: "trace" on the sign-off of a turn that ran tools opens it beside the chat, tied to it like a review. A task is that turn and the turns after it until the person types again (mail from a child, continuations), so a parent's wait for its agents and their replies fall inside it. A trace opened on the session's newest task follows the session instead: every later turn is added as it begins, each message the person sent marked on the axis ("you"), and it reads again whenever the session's phase changes as well as every 3 s while anything in it works, never while the tab is hidden, and every 5 s while a read fails. Its axis runs ahead of now (a dashed line), by a quarter of the task or at least 30 s, and grows only when now reaches the end, so the calls already drawn hold still under the pointer between reads. "earlier turn" adds the turn before the earliest shown (`turnBefore` walks history back to the person's previous message; the count is saved on the window as `earlier`), read in place so the view keeps its zoom. The trace is read in parallel where it can be: agents, jobs and model calls start with its history, and the session's own lane shows before its agents are read. Each window keeps per-lane history positions, request sequences and known children. Live reads append new entries and calls, close pending tools, and reuse completed child lanes until their activity changes; running jobs and child summaries are reconciled. A followed child keeps growing while its parent waits. Hydrated inputs and traces are kept, with a tool’s later result fetched separately. Earlier-turn changes and failed reads discard the cursors. Initial model-call reads use `since_ms` to skip requests that finished before the task; a daemon rejecting it with 400 is detected once, then uses the older parallel single-row probing search. Later reads use request sequences on either daemon. It animates only transform and opacity, and nothing animates while it is out of view. Rows come from three places: model calls from the session's request records (`GET /sessions/{id}/context?view=requests`, oldest first; `since_ms` finds the task’s first records, with single-row probes only on older daemons), tool calls from the transcript (a call's entry to its result's; input a history page only referenced is read afterwards in one `history/content` walk per agent), and agents from `GET /sessions?parent_id=`, three deep, kept when they were alive during the task. Each agent is one lane: its calls never overlap, so model calls (grey, the wait for the first byte lighter) and tool calls (the lane's hue, which keeps clear of red; failures are red) take turns along one strip, and a gap of 1.5 s or more while an agent below works is hatched as waiting. Under the strip, each tool call has one chip naming the most telling thing it did, read from its trace (a web search, then an MCP call, the computer, an artifact, an edit, a command, a search, a read, a listing, a display; the first line of its code when it did none of them), with the rest counted ("+3") and listed on hover and in its drawer. Calls closer than a readable chip (64 px) share one cluster chip that counts their kinds (as many as fit) and zooms to its stretch when pressed; clusters are worked out again as the zoom changes, and no chip runs past the next, so none overlaps. Jobs are packed by where they end on screen, at least 18 px wide, for the same reason. A fold's idle label stays off the ticks, and gives way to the person's "you" when their message ended it. Under the chips, the lane's background jobs (`GET /extensions/run/sessions/{id}/jobs/recent`, kept only while the kernel keeps them) on the same clock, packed into as few tracks as keep them apart; tracks past the first fold into a thin band of ticks, and pointing at them floats them out over the lanes below, so opening them never moves the layout. A running job shimmers, one that failed or timed out is red, one started as a service is dashed, and a click opens its terminal beside the trace. Jobs never keep idle time from folding. The gutter gives each lane's model and how much context its last call sent, and what share came from the cache; a compaction summary's call is drawn in the accent. Calls are often a few pixels wide, so the strip is taller than its bars and finds the call itself (`callAt`): the nearest within 10 px, a tool call close by beating the model call under the pointer. Spawn arrows leave the parent's tool call running when the child was created; reply arrows leave the child's last step before its mail landed. Above the lanes, one bar says where the task's own time went (`spent`): thinking, running code, waiting on agents, waiting on the person (an idle stretch their message ended), and the gaps between steps, with the duration, tokens, calls and failures beside it; all of it follows the playhead. A press on a call opens a drawer under its lane: what it did (each edit opens the review of its turn at that file, through `onreview`), its code and output (read then, in one batch), or a model call's first byte, tokens sent, cache share and tokens written; ← and → step along the lane, Esc closes. Hovering a tool call in either the trace or the chat lights it in both (`traceLink.entry`, keyed by the chat's entry: the result once there is one), and `TraceTie` draws a line between them over the canvas, pinned to the chat's edge when the call is scrolled out of view; opening one in the trace sets `traceLink.reveal`, which opens its stretch in the chat and scrolls it there. Any stretch of 20 s or more with nothing running is folded into a 2.5 s break labelled with how long it was idle (`clockOf`, a polylinear d3 scale); ticks are round elapsed times within each busy stretch. Zooming, panning and replaying only move CSS variables (`--w` the axis width, `--left` the pan, `--t` the playhead), so none re-renders a bar: a pinch zooms at the pointer, a sideways swipe pans (the canvas leaves both to anything inside `.zooms-itself` while the window is readable), a press on the axis scrubs, a double-click on it or "fit" shows the whole task, a double-click on a call zooms to it, and the replay plays any task in about 12 s. Search matches a call's name, the first 4000 characters of its input and its error; Enter opens each match in turn. `prefers-reduced-motion` drops every animation but the replay. **Agents** (`AgentsButton.svelte`, `agents.ts`, `nodes/AgentsNode.svelte`, `views/AgentsView.svelte`): once a session has agents below it, its window shows a branching-icon button beside the jobs one (in canvas windows only; the phone has none). Its count is the agents working, and its menu lists them (oldest first, closed ones too, indented by how far below the window's own session they are) under an "open tree" row. A family is read whole from its root with `GET /sessions?family_id=` (`listFamily`), once for every window of it, however many show it (`watchFamily` shares the read and the newest answer). It is polled, since the events socket follows only roots, and `familyDelay` sets the pace: every 2 s while an agent works, 3 s while the root has a turn, 6 s at rest with agents and 30 s with none, at once when the root's turn starts or ends (`pokeFamily`) or the tab comes back, and never while the tab is hidden. A read that changes nothing tells no one, and a first read that fails says so. An agent picked opens as an ordinary chat window beside the one it came from, tied to it by a tether (`Board.openSession(id, parent)`). "open tree" opens the agents window for the family's root (`layoutTree`): active branches grow to the right of the root while closed folds and finished branches balance to its left when active work is also present; large leaf sibling sets (> 5) stagger across sub-columns with a half-row vertical offset so links glide between rows, and two or more closed leaf agents under a parent fold into a `▸ N closed agents` deck node that unfolds its compact closed pills in place on click without shrinking the fitted zoom (`fitBox` and `shapeOf` ignore unfolded closed pills, and clicking a fold pins it under the pointer while contour tucking keeps sibling branches from overlapping). Active cards show their name, short model or live face (on roots and leads), and latest tool/activity snippet; closed pills use an opaque dimmed surface, and links sit at 22% opacity at rest and light the hovered card's ancestor chain in `--accent` (90%) while stepping down monotonically across descendant hops (`hoverLineage`: 62%, 42%, 30%, vs 14% for unrelated branches). A click opens an agent, and a "hide inactive" button (shown once any agent is not working) leaves out the agents that are not working, idle and closed alike, unless one is working below them (`withoutInactive`), the window shrinking to what is left; the choice is kept in this browser. The tree pans and zooms itself: a left-button drag on its ground cancels native text selection, and only the tree is non-selectable, so panning cannot extend a selection into neighbouring conversation blocks while their text and the tree's buttons retain their usual interactions. It starts fitted and centred with room around it (48 px, enlarged up to 135% when the window is bigger than it needs) and follows the window while it is resized, until the person drags the ground, scrolls, pinches or uses its + and − buttons, after which it stays where they put it until the fit button follows the window again; an "auto-fit" button (on by default, kept in this browser) also fits it whenever the tree's shape changes (`shapeOf`: an agent added, removed or hidden, not one only doing something else), and the window itself is sized to the tree either way until it is resized. The window is sized to hold the tree at 115% and its room (`AgentsNode`, at most 1500 by 960), and again when agents come or go, growing only into free room (`growInto`: it stops 32 px short of a window beside or below it, so it never covers the chats opened from it), until the person resizes it. The size it was last fitted to is kept on the window (`data.fit`, saved with the board), and it is fitted again only while it still has that size (`refit`), so a size the person set holds through a reload. The session list holds only roots (`scope=roots`), so a window of an agent keeps its record in `sessions.agents` (`adopt` when it opens, refreshed by each family read, and `ensure` reads it back for a window the board restores, counting only a 404 as gone). The **artifact** (`nodes/ArtifactNode.svelte`, `views/ArtifactView.svelte`, `artifacts.svelte.ts`; robot-docs/artifacts.md) is a page the agent made, live in a sandboxed `