Expand description
The web UI: magi’s queue and run history, readable from a phone.
The terminal is the wrong surface for the two things an operator actually does between runs — file a task and check whether the last competition landed. Both happen away from the desk, so they get an HTTP surface: a handful of JSON routes and three embedded files.
§One binary
index.html, app.css and app.js are compiled in with include_str!.
There is no --assets-dir and no filesystem fallback, because a UI that
reads its own front end from disk breaks the moment the binary is copied
somewhere else — which is exactly what cargo install magi-cli does. No
JS toolchain, no CDN, no remote font: everything the phone needs arrives
from this process.
§No authentication
There is none, deliberately, and the startup log says so. The tailnet is
the security boundary: --bind auto resolves to this machine’s Tailscale
address, so the UI is reachable from the operator’s own devices and from
nothing else. Anyone who can open the URL can file and hold tasks, which is
why binding to 0.0.0.0 is not offered and why the fallback when Tailscale
is missing is loopback rather than every interface.
§Change notification
A phone must not poll a full run list on a mobile link. GET /api/events
is a server-sent stream carrying nothing but two revision numbers — the
newest modification time in the queue and under the runs directory — so the
client refetches only what moved. The browser’s own SSE reconnection covers
a sleeping phone; there is no session to lose.
§Reading state must never take the server down
A corrupt run.json is skipped in the list and explained with a 500 on the
detail route. No handler unwraps a filesystem or parse result: a single bad
file left by a killed run would otherwise turn the whole history into a
blank page.
§Agent-authored HTML, rendered anyway
Everything else here refuses to put API data into the document: app.js
builds nodes and sets textContent, and even an href from a run record is
laundered first. A confirmation panel breaks that rule on purpose - an
agent asking the owner to approve a merge needs a diff and a table, not one
line of prose - and the only reason it is acceptable is that the panel is
never part of this document.
It is served by [question_panel] and [question_asset] and rendered in an
<iframe sandbox> carrying no tokens: no allow-scripts, no
allow-same-origin. So no script in a panel runs, and the frame cannot
reach the parent document, the cookie jar or localStorage. On top of that
both routes send [PANEL_CSP], which denies every network destination, so a
panel cannot phone home through a remote image or a beacon either - the two
things it may load, images and inline CSS, are the two things free
formatting actually needs. Assets come from the question’s own directory and
never from the network, and their content types come from a closed
whitelist, so an agent cannot get markup rendered outside the frame by
naming a file .html.
§A conversation turn is not a filesystem read
Every other route here is disk work, which is why [blocking] exists.
POST /api/talks/{id}/say is the exception: it spawns an agent CLI and
waits tens of seconds for a sentence. It is a plain await holding no lock
and no executor thread, and concurrent turns on one talk are refused rather
than queued - see Ui::begin_talk_turn.
§The loop runs here
magi web runs the queue loop in this process, started and stopped from
/api/loop. That is the point of the whole surface: a task filed from a
phone with nobody around to type magi serve is a task that sits in the
queue until someone walks back to the machine.
It is a tokio task holding a daemon::Stop, not a child process. There
is no pid file of this module’s own and nothing to supervise - a child
would need reaping, a second copy of the daemon’s retry policy, and a
story for what happens when magi web dies with the loop still running.
<home>/daemon.json, which the loop itself writes, stays the only
cross-process signal, and it is how this process notices that the
operator’s own magi serve already owns the loop and refuses to start a
second one that would fight it for claims.
Stopping is cooperative and therefore not instant. A run in flight is
finished first, for the reason daemon::serve gives: killing the graph
mid-node leaves worktrees, branches and agent sessions behind and throws
away every agent call already paid for. POST /api/loop sets the flag and
answers immediately rather than waiting, because the wait is measured in
tens of minutes and the operator is holding a phone.
Structs§
Enums§
- Bind
- Which address to listen on.
Constants§
- DEFAULT_
PORT - Default port. Chosen high and memorable; nothing else in the fleet uses it.
Functions§
- resolve_
bind - Resolve
--bindto an address, plus a warning when the answer is not what the operator asked for. - runs_
unreadable - Runs on disk under
runswhose state this build cannot parse - almost always a schema bump, occasionally a run killed mid-write. - serve
- Serve the UI until Ctrl-C, finishing a run the loop has in flight.