# 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. 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. `npm test` in `web/` exercises the stream lifecycle with mocked browser transport and timers. - 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 a year and redirects to `/`; `/api` without that cookie answers `401 web_signed_out`. `--new-link` revokes the previous key and cookies. `--tailnet-auth` makes the tailnet the sign-in: a request from `100.64.0.0/10`, or from the loopback `tailscale serve` proxy that terminates its TLS, gets the cookie without a link (`Server.TrustTailnet`, `Server.fromTailnet`). The cookie still lasts a year, so a tailnet browser signs in once and keeps the session. This trusts every device on the tailnet with the daemon's full control; leave it off unless that is the boundary. It is recorded in `web.json` (`tailnet_auth`) like the other flags. - 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, which a later `albedo web` without `--allow` uses; `--save` alone clears it. - 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`. - `/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`, `tailnet_auth`). A second live server leaves the existing manifest alone and 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. 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. `npm test` covers counting, duplicate mail, focus clearing, missed transitions and the background transport lifecycle. 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. `+` makes a new board; double-click or F2 renames one; closing a tab 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 the zoom is readable (60% and up), 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()`), 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. + and − zoom 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. 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, and a file whose changes grew glows in the index for a moment. The **terminal** (`nodes/TerminalNode.svelte`, `views/TerminalView.svelte`, `jobs.svelte.ts`) shows one background job's output, read-only. A terminal icon in a chat's title bar (`JobsButton.svelte`, 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 **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. 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 `