64 KiB
ai-app — plan
A phone interface to AI coding sessions — Claude Code and llama.cpp — built to replace the Claude app for day-to-day use. Two motivations: local models need a front end at all, and owning the client means fixing what the official app gets wrong (it won't deliver a typed message until the turn fully finishes, where the TUI injects it at the next tool boundary).
Same shape as ../dev-updater: a Rust/Axum backend on the desktop and a
shared Rust client for Android and desktop, with pinned self-signed TLS.
This file records decisions with their date, their rationale, and what was
rejected. Update it in place when one changes; AGENTS.md is the working
notes layer and must not become a second version of it.
The one idea everything hangs off
A session is a child process, translated into one common event model.
The backend spawns it, translates its dialect into a common event stream,
and keeps an append-only transcript. A new session type is a new driver —
never a session-type branch in shared code (routes, transcript, app
screens). SSH falls out of the same shape: a remote session is the identical
command wrapped in ssh host …, and the driver never learns which it got.
Android or desktop app (Rust/iris)
│ HTTPS (pinned CA) — REST for actions, SSE for live events
▼
backend (Rust/Axum, desktop)
├─ SessionManager ── Session ── Driver (trait)
│ ├─ ClaudeDriver (claude stream-json over stdio)
│ ├─ LlamaDriver (llama-server over HTTP)
│ └─ EchoDriver (the test rig)
│ each driver's process is spawned through a Transport,
│ locally or as `ssh host …`, decided by the setup it names
├─ usage.rs (Anthropic OAuth usage endpoint, per machine)
├─ models.rs (HuggingFace browsing and GGUF downloads)
├─ files.rs (the file explorer's half of the backend)
└─ config.ron + per-session transcript files
Architecture
Setups and providers (2026-08-28)
A setup is a machine, and it carries the providers that machine has. Optional ssh details, plus the list of what can be run there. Spawning is two choices in order: pick a setup, then pick one of its providers.
This replaced an independent providers × hosts cross-product, because the
two axes are not independent: a provider is only real on a machine where
that CLI is installed, so the cross-product offered combinations that cannot
work — claude-cli on a machine with no claude, and every provider paired
with a host the driver ignores (EchoDriver takes no host, so "Run on" was
a control that silently did nothing).
- Echo is seeded, not implicit. It lives in the setup with no ssh,
because it runs in-process and has no transport to cross. It is written
into
config.ronon first run rather than conjured at read time — a provider nobody can see in the file is one nobody can edit from the phone. - Providers are discovered by asking the machine, never typed, so an
enrolled token cannot introduce a command. The escape hatch for a binary
somewhere unusual is editing
config.ron, deliberately the one authority the phone does not have. - Migration code is deleted once the update carrying it is received. The providers/hosts migration ran on the one host there is and is gone. A file in the old shape now fails to parse, which is correct because no such file exists.
Backend layout (server/)
axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition 2024, warning-clean, clippy clean.
main.rs— bootstrap, TLS listener, auth layer, enrollment, wg0 binding.routes.rs— the whole HTTP table in its module doc comment. That comment is the surface's source of truth; this file does not repeat it.auth.rs— the bearer-token middleware.config.rs— the persisted schema.setups.rs— machines and provider discovery.files.rs— the file explorer (EXPLORER.md).usage.rs— Anthropic usage polling, per machine.models.rs— HuggingFace browsing and GGUF downloads.media.rs— the image media-type/extension table, shared by the four places that must agree: storing an upload, serving it back, handing one to a driver, and saving one a tool produced.session/mod.rs—SessionManager, the live registry; every mutation funnels through it so in-memory and on-disk state cannot come apart.session/driver.rs— theDrivertrait and the common event model.session/claude.rs,session/llama.rs,session/echo.rs— the drivers.session/transcript.rs— the append-only JSONL event log per session, with monotonically increasing sequence numbers (the phone's resume cursor).session/transport.rs,ssh.rs— running a driver's command locally or over ssh.session/process.rs— the pid + start-time record that lets a process outlive the backend.session/import.rs— continuing a Claude Code session the machine has.session/pending.rs— operations in flight on importable sessions.
The certificates, enrollment, wg0 binding, owner-only file modes and RON
house rules live in the wg-app-link submodule, shared with dev-updater.
The common event model
Driver output, whatever the dialect, is normalized into one enum before it touches the transcript or the phone. Every event is appended to the session's transcript with a sequence number, then fanned out to SSE subscribers. The phone renders purely from this stream: reconnecting is "give me events after seq N", so there is no separate history path to drift from the live one.
UserMessage { text }— echoed into the transcript by the manager, not by drivers, so every device renders the conversation from one stream.AssistantText { delta }— streaming text, rendered as markdown.ToolStart / ToolUpdate / ToolEnd { tool, input, output }.Image { ref }— saved under the session dir, fetched by URL.Question { id, prompt, options }— anything needing a human. Claude's AskUserQuestion and permission requests (canUseTool) are the same shape; a permission is a question with two bare options, not a different kind.Answered { id, answer }— so a question card resolves on every connected device, not just the one that answered.Status { state }— idle / running / awaiting-input / compacting / exited.UsageDelta { tokens, context }— what a turn cost and how much the model was holding when it ended.contextis prompt plus both cache figures, taken from the last assistant message rather than the turn'sresult: measured 2026-08-30 against CLI 2.1.237, the result adds a turn's messages up, so its cache read of 40,211 was the same conversation counted twice. It is carried rather than summed, because it goes down — a compaction replaces it and a clear leaves it unmeasured.driver::context_afteris that rule and the phone folds with the same one. A session the server has no measurement of asks the CLI's own file instead of waiting for a turn (import::context_of).MessageQueued/MessageDropped— see "Taking a queued message back".PeerMessage— see "A message from another agent".Error { message }.
Inbound, the Driver trait is small: send a message, answer a question,
interrupt, set the model, compact, unqueue, and two ways out — detach (the
server is going away and means to come back) and stop (the session is being
deleted, so the process must not survive). Every driver owes exactly one of
the two.
send_user_message during a run is the point of the whole app: the dialect
queues it for injection at the next tool boundary rather than the end of the
turn.
Claude driver specifics
Spawn: claude -p --verbose --input-format stream-json --output-format stream-json --permission-mode <mode> in the chosen working directory, plus
--model and, where one has been chosen, --effort. Wire-format notes are
pinned against CLI 2.1.237 in session/claude.rs's module doc: permissions
need the hidden --permission-prompt-tool stdio flag, AskUserQuestion answers
ride updatedInput.answers keyed by question text, and set_model/interrupt
are control requests.
The thinking level is settled at launch (added 2026-09-04, because it is
the largest saving available on a long session: output is about an eighth of
what a session costs and thinking is the bulk of output, against the ~1.5% that
is prose). The CLI's only two setting control requests are set_model and
set_permission_mode -- checked against the 2.1.258 binary -- so there is no
way to ask a running process to think differently. set_session_effort is
therefore shaped like set_session_cwd rather than like set_session_model:
it records the level and stops the process, and the next message or Start
launches one that has it. It lives in the session settings dialog beside the
working directory for that reason, not on the session bar beside the model and
the mode, which do take effect mid-turn. None is a level in its own right --
the CLI's own default -- so the picker can return to it; a level this app named
as the default instead would be this app choosing one.
What a new session starts at is Config::default_effort, applied in
spawn_session rather than filled in by the spawn screen, so it holds for an
import and a bare API call as well. It is set by the spawn screen's own
picker, whose label says so: one control, where new sessions are made, rather
than a settings page for a single value. It is not on a provider, because
providers are discovered and the next rediscovery would erase it, and not on
the phone, because a second device would then spawn at a level nobody there
chose. GET/POST /defaults carry it, as a struct rather than a bare value
so the permission mode -- still hardcoded to auto on the spawn screen -- can
move there without a second route.
--resume only ever runs when nothing else has that session open. That
is the rule behind the import refusal, the single ClaudeDriver::launch
entry point, and the Exited correction below; two CLIs on one session file
duplicate the conversation into it and bill the second for re-reading it all.
The llama driver
One llama-server per session, started through the same Transport as any
other process and then reached over HTTP on a loopback port. Two things are
deliberate and easy to undo by accident:
- The conversation is rebuilt from the transcript, not kept in the driver. A copy in driver memory is invisible to a second device and gone when the process restarts. That leaves the Claude driver as the odd one out rather than this one — the CLI's memory is a cache in front of the same transcript. Resolve any inconsistency in this direction.
- A llama session runs on whatever machine its setup names (2026-09-04,
the last of phase 5). A transport is "run this" plus "reach this port", and
the second half is
Transport::reserve_port— the port the server binds there and the port that reaches it here, the same number locally — carried byLaunch::reachingonto the connection that already runs the command.llama-serverbinds loopback on the far machine, so nothing is served to its network. The far port is a guess from a range below the ephemeral one, because no portable way to ask a machine for a free port avoids racing the bind anyway; a collision is not silent, since the server fails to bind and the readiness poll reports what its log said. - The model file lives on the machine that serves it (2026-09-04). Each
setup names its own models directory (
SshConfig::models_dir, default~/.local/share/ai-app/modelsexpanded there), and a spawn resolves the key on that machine — one round trip answering "at /abs/path" or "missing", so a model that is not there is refused at the spawn rather than becoming a server that never becomes ready. The spawn screen offersGET /setups/{id}/models, that machine's list, rather thanGET /models, which is this backend's downloads. Downloading to another machine is deliberately not built: a multi-gigabyte transfer with no progress anywhere, and the file gets there however anything else on that machine did. - The readiness poll watches the process, not only the port. A model that
will not load, a port already taken, a flag an older build does not know:
all exit within a second and none will ever answer
/health, so waiting out the 300s timeout turned the server's own account of the problem into "gave up". The failure carries the tail ofllama-server.log, which on a remote session is the only copy anybody reading the phone can see.
Models (2026-08-28)
- A download belongs to the model, not to the request. Keyed by
owner/repo/file.ggufand owned by the server, so a second device can watch one it did not start and an hour-long fetch survives a locked screen. Every run has an id and its outcome outlives it, because "not downloading" otherwise means finished, never started, or someone else's run ended while you were away. - Progress is measured, never estimated:
totalis Content-Length, or Content-Range's last field on a resume, and absent when the server says nothing. - Resume is guarded by identity, not by hope. A partial carries the ETag
it was written against and a mismatch discards it.
If-Rangewould be the tidy mechanism but HuggingFace's CDN ignores it (probed 2026-08-28). The published sha256 is checked before the file is renamed. - Sampling parameters reach a driver as an untyped
paramsmap, so the shared schema does not grow llama.cpp's vocabulary.
Transport (ssh)
- A remote session is a local one with the command wrapped in
ssh -T host …, every argument shell-quoted, run withexecso dropping the connection takes the CLI down rather than orphaning it. Key-based auth only, through the systemsshclient, which inherits~/.ssh/config, agents and jump hosts for free. - The transport wraps the driver, not the other way round (2026-08-28). A driver says what to run; something above it turns that into a process. Otherwise transport knowledge sits inside a translator whose job is a wire format, and every future driver has to remember to do the same.
- A forwarded launch gets a pty and every other one does not (measured
2026-09-04). Killing the ssh client ends a CLI because it closes the stdin
that CLI is reading;
llama-servernever reads its stdin, so the same kill left it running on the far machine with the model loaded — one orphan per stopped session. With-ttthe far side takes SIGHUP when the connection goes. Its log then arrives through a line discipline, which nothing parses.-Tstays everywhere else, where a pty would rewrite the JSONL. command -vfollows ssh's non-login PATH, which is narrower than an interactive shell's, so a binary somewhere unusual is invisible to discovery. Pointcommandat an absolute path.- Images need no file transfer.
attachment_blockbase64s an upload into the stream-json message, and produced images come back the same way. - Any other file is told to the session by path (2026-09-03): a trace, a
log, a zip — things a model cannot be shown and the CLI can read. The
upload is streamed to disk under the session's attachments on this machine,
and the message ends with
Attached file: /abs/path. For a session on another machine the upload also copies the file there in the same request, over onesshinvocation, landing in the setup'sattachmentsDirif set, else the session's cwd, else the login home. The resolved remote path is recorded beside the file (<name>.remote) and is what the driver names. A copy that fails fails the upload, so no message ever names a file that is not there.
Moving a session to another directory (2026-08-31)
POST /sessions/{id}/cwd, from the session settings dialog. A working
directory is settled when the process is spawned, so this records the new one
and ends the process in the old one. It does not start a replacement: a
session with no process starts on the next thing said to it or on Start,
which is this app's rule everywhere else.
The path is checked against the session's own machine and refused if it is not there, rather than corrected. The spawn path corrects instead, because it is resuming a directory the machine recorded, which can be gone through nobody's fault; a path somebody has just typed is different, and a mistyped one accepted here surfaces much later as a session that will not start.
Nothing of Claude Code's own is moved. Measured against CLI 2.1.237:
claude --resume <id> finds a session from any working directory. Relocating
the file would mean reproducing a rule this app cannot see the whole of — the
project directory is the path with every non-alphanumeric character replaced
by -, truncated at 200 characters with a hash appended, and overridable.
A message from another agent (measured 2026-08-31)
Measured by sending a real cross-session message to a real stream-json
session on CLI 2.1.237: the CLI emits no user record for it, and
nothing in the partial-message stream mentions it. The whole of it arrives as
an origin object on the turn's result, in the same shape the session file
records — so import::peer_message reads both and there is one function for
one wire format. Only peer-caused turns carry it.
The cost is the position, and it is paid on the wire rather than on screen. The event cannot be recorded in place: at no earlier point does the CLI say why the turn started, and the transcript is append-only, so by the time anyone knows, everything the message caused is already written above it. Tailing the CLI's own session file instead was rejected and stays rejected — two sources of truth for one conversation and a poll per live session.
So PeerMessage carries a turnStart: the seq of the Status that opened
the turn, stamped by the pump, which is the only thing that knows a seq and
sees every driver's turns. The phone draws the note at that seq. A status
draws no row, so there is nothing to collide with and the list stays sorted,
which is what the scroll anchor and paging depend on. turnStart is absent
where there is nothing to correct — a message replayed by import is already
in the right place. The echo driver models both shapes: /peer and
/peer-turn.
Taking a queued message back (2026-08-31)
POST /sessions/{id}/unqueue, answered by Driver::unqueue and recorded as
Event::MessageDropped so every device loses the bubble and a reconnect does
not replay it.
The answer has three states rather than a yes/no, and that is the whole
design: Dropped, AlreadySent, and Unknown. The Claude driver can only
ever give the middle one — it writes a steer into stdin the instant it
arrives, which is what makes a steer reach the model at the next tool
boundary instead of the end of the turn. What waits in awaiting is the
announcement, not the message. Holding the write until a boundary would
make the drop real everywhere but costs a steer one model call, which is the
latency the immediate write removed. So the refusal is the honest answer, and
it is reported on the bubble the reader pressed rather than in the screen's
error row a screen away.
Unknown is not "we could not find out": a driver that is gone reported
everything it was holding when it closed.
Session processes outlive the backend (2026-08-29)
A session's process is left running when the backend stops and adopted
again when it starts. A rebuild, a service restart or a crash must not end
a turn somebody is waiting on, and a turn can be minutes long. What this
replaced leaked processes either way: shutdown_all asked every driver to
stop and then exited immediately, with the SIGKILL escape hatch on a timer
inside the dying runtime, and whatever survived was orphaned with nothing
written down to find it by.
Inside the session directory, beside the transcript:
process.json— the pid, the kernel's start time for that pid, and how much of the output log has been read. The start time is what makes the pid an identity: pids are reused, and adopting a stranger's would mean never resuming the real conversation and signalling something unrelated.stdin.fifo— opened read-write and inherited by the process, so it is its own last writer and never reads EOF when the server goes away. Closing stdin therefore stops being the graceful-exit signal; ending a process is a signal, and onlyDriver::stopsends one.stdout.log/stderr.log— plain appended files, read from a byte offset. A fifo would fill its 64 KB buffer and block the process while nothing drained it, stalling the very turn this exists to protect.
Remote sessions are adopted too, and the recorded pid is the ssh
client's — the process the backend owns, which lives exactly as long as the
remote command does. The far claude always has an sshd pipe on stdin
whichever version started it, since the fifo is on the backend's side, so a
remote session's stdin says nothing about which server started it.
A zombie is dead. /proc/<pid>/stat keeps the entry, with the same pid
and start time, until the exit status is collected — so a finished process
answered "still there" for as long as nothing reaped it, and Alive is the
word that makes Exited unsayable. process::stat_of reads the state field
alongside the start time.
Stopping and starting a session's process (2026-08-30)
POST /sessions/{id}/stop and /start: end the process without ending the
session, and start it again on the same conversation. Three decisions worth
not undoing:
- Stop signals the recorded process and says nothing else. It does not go
through the driver and does not announce
Exited. The record is the session's rather than any dialect's, so this works for a session whose driver is in no state to be asked, and the driver's own reader already reports the death correctly. Announcing it here would be a guess arriving ahead of the measurement, and wrong for the grace period. - Start replaces the driver and nothing else. The transcript, the event
pump and every open SSE stream stay where they were, so starting again is
not a reconnect for anybody watching, and there is still exactly one writer
of the transcript.
LiveSessionandCommandsshare oneMutex<Arc<dyn Driver>>rather than each holding a copy. - Start is refused unless the session is known to have exited.
Unknownmeans nobody could find out, and starting on that is exactly the two-CLIs-on-one-conversation faultsession::processexists to prevent.
Exited is a claim about a process, and the record is what settles it.
It is the one status that draws the phone's Start button and lets
start_session build a driver, so it is checked against session::process
before it is believed (corrected, called in launch and start_session).
A record not known to be dead makes it false and the session reports
Unknown instead. Every other status is left alone — those are the pump's,
written from what the process itself said. Without this, a session adopted at
a backend start kept the transcript's Exited while its CLI ran, Start was
accepted every press, and each press attached another reader to one
process: one reply drawn interleaved several times over
(GotGotGot it — it — it —). A driver that start_session replaces gets
Driver::detach, because swapping the Arc does not end the tasks the old
one is running.
Who says so matters as much as what is said. A status written into the
manager's view alone is two screens disagreeing — the list reads the
manager's status and the session screen replays the transcript, which showed
up as a stop button turning into a play button a moment after the screen
opened. So a driver announces the state it starts in, through the event
sink. It says Idle only when it started a process; adopting says
nothing, because a process already running may be mid-turn and the
transcript's last word is the better answer until its output says otherwise.
A message or a command starts the process if there isn't one. Refusing
was work handed back: read the status word, find the other button, press it,
type the thing again. --resume puts the new process on the same
conversation, so nothing about what was typed changes. A rename is included
for a sharper reason: Claude Code keeps its own copy of the name, that copy
is what its session picker and other agents' session lists show, and a
session is only ever given a name at birth since every later start is a
--resume — so a rename reaching no process would leave the two lists
disagreeing permanently. Its save happens before the telling, so a failure
there says the telling failed rather than the rename.
start_if_exited is one function under one write lock, which is what stops
two requests arriving together from starting two CLIs. Its callers want
opposite answers: "there is already a process" is a refusal worth showing to
somebody who pressed Start, and nothing at all to a message. Only Exited
starts anything — Unknown has a process that may well be reading its fifo.
run_command judges against what start_if_exited returned rather than
re-reading a status the pump may not have caught up with.
On the phone this is one button in the composer, left of Send, whose mark and colour say what pressing it would do now: an orange pause while a turn runs (interrupt — the process stays), a red stop when it is not (end the process), a green play when it has exited. One button rather than three that come and go, so its presence is never the signal. It is disabled while its own request is in flight, as a courtesy; the server refuses the second request either way.
A backend start adopts, and starts nothing (2026-08-30)
SessionManager::new takes charge of the processes still running and
leaves every other session exactly as it found it — listed, with its
transcript, its pump and its SSE stream, and no driver until somebody asks
for one. It used to launch a driver for every session in the config, and
ClaudeDriver::launch starts a process when there is none to adopt, so a
session somebody had deliberately stopped came back at the next rebuild, and
the Idle the new driver announced stamped it as active at the moment of the
restart. On the phone that read as every session idle and "just now", with
the list sorted by that time in an order that meant nothing.
Launchingis the parameter that says which it is, and an import's seed rides on the asked-for variant, because a restart re-seeding a transcript would write the imported conversation into it twice.- A session with no process has no driver.
DriverCellis an option rather than a driver whose requests go nowhere, so "nothing is running this" is a state the code can be asked about instead of one it discovers by sending into a dead fifo.LiveSession::askanswers it with anEvent::Errornaming what could not happen — a request nobody can carry out is reported, never swallowed. - A launch never moves a session's clock. A status a launch has to
correct is written at the time of the last thing the session actually did,
not at
now(). Taking charge of nothing, every word butExitedis disproved at once — a backend killed mid-turn leaves a transcript sayingRunning, which draws a stop button for a turn that ended hours ago — but stamping the correction withnowis the same lie in the same field thatTranscript::last_activityexists to prevent. - A session that has never done anything reports when it was created. Its
transcript is empty, since a driver announcing the state it starts in is
not news, so it is the one session with no line to read a time off. Not the
file's mtime, which is a worse answer for a checkout that can be copied;
SessionConfig::createdis recorded rather than inferred.
Sessions spawned while testing clean themselves up (2026-08-30)
--throwaway-sessions, on by default in a debug build. Every session
such a server spawns is marked throwaway in the config, and a marked
session's process is stopped when the server exits or is signalled.
Leaving processes running is right for the sessions somebody is using and
exactly wrong for the ones a test made: those leave a claude behind that
every later server adopts, they cost tokens if anything speaks to them, and
nothing says they are there — twelve accumulated on this machine in a day.
- The flag marks; the mark decides. What a server was told at startup governs only the sessions it spawns, and the mark is written into the session, so it outlives that server. A session spawned deliberately keeps running whichever server is up when one exits, and a throwaway one is cleaned away even by a server started without the flag. The alternative — the exiting server stopping whatever it has marked in memory — makes cleanup depend on which process is up.
- Stopping is not asking.
process::stopleaves its SIGKILL on a tokio timer, which a shutting-down runtime never runs; that is precisely how the originalshutdown_allleaked. The exit path waits withprocess::wait_gone— one deadline for all of them, since they were signalled together — and kills whatever is left.
Importing refuses a session that is already open (2026-08-29)
Claude Code keeps a descriptor per live session at
~/.claude/sessions/<pid>.json carrying the sessionId and a procStart —
the same pid-plus-start-time identity used above. So "is this session open
right now" is a measurement, and the import list reports it as no /
yes / unknown. Three answers because a machine that keeps no such record
cannot answer, and "could not check" is not "nobody is using it".
yes is refused. On 2026-08-29 an agent imported the session it was itself
running in: two claude --resume processes on one file, the whole 65 MB
conversation with 154 embedded screenshots duplicated into it under a new
prompt id, and the adopted copy billed for re-reading all of it. It ended at
the account's session limit.
Importing and deleting run on the server, and a batch is handed over in one
call. POST /setups/{id}/importable/{delete,import} each take a list of
ids, answer 202, and do the work in spawned tasks — the phone that asked is
free to leave, and used to cancel its own batch by doing so. A list rather
than a route per session because one request per row made a handover only as
atomic as the network, and a row nobody asked for looks exactly like a row
nobody picked. Only the registering is atomic; the work settles per row,
since six deletes that all roll back together is not something a filesystem
offers.
What replaces the reply is session::pending: every row carries pending
and error, and /importable/events streams the changes. Both, not
either — the stream is a broadcast with no memory, so an operation that
starts and finishes while it is still connecting is one nothing will ever be
said about, which left a row marked "waiting" for ever. A single tap still
waits, because "continue this and take me to it" needs the session it made
and 202 does not carry one; the batch and the tap share spawn so the two
cannot drift about what importing means.
An imported session keeps itself level with the CLI's file, so work done
at a terminal appears without anyone pressing anything. Which lines came from
here is answered by counting the events this session has recorded, not
by looking at its status — a turn that starts and finishes between two polls
reads as idle at both, and its own output gets replayed on top of itself.
That bug was visible on screen as donedone.
Usage limits (Claude)
Poll https://api.anthropic.com/api/oauth/usage — the endpoint behind Claude
Code's /usage — with the OAuth token from ~/.claude/.credentials.json,
headers anthropic-beta: oauth-2025-04-20 and User-Agent: claude-code/<version> (without the User-Agent it lands in an aggressively
rate-limited bucket). Poll at ≥180 s, only while a Claude session exists or
the usage screen is open, and cache the last answer. It is undocumented, so
usage.rs treats every field as optional and degrades rather than erroring.
Per provider, not per machine (2026-09-04). A machine is not what is
metered; the provider a session runs is. One machine offers echo, the Claude
CLI and a local model side by side, and only the second spends anything — so
pairing a session with a snapshot by machine alone drew the CLI's five-hour
window under every echo session on it, a quota that session cannot spend. A
session now names its meter (usageProvider, from
DriverKind::usage_provider, which usage::providers_for reads too, so the
two lists cannot disagree) and GET /usage is matched on machine and
provider. None is a session that meters nothing, and the phone draws
nothing at all for it — not a zero, and not "unknown".
DriverKind::Echo names a meter of its own that exists only when a test has
asked for one: /usage in an echo session sets an invented answer
(usage::Fixture), and with none set there is no snapshot and no bar. That
is what makes those screens' states reachable — a number near the top, a
window between blocks with no reset time, a machine nobody logged into, one
that could not be reached — without spending real quota to arrange them,
which is why none of them had ever been looked at.
Per machine, not per backend (2026-08-29). The credential store that
matters is the one on the machine the session runs on, because that is the
account being billed — and in the layout this aims at, ai-server is on the
host, the host has no claude, and the CLI machine is a remote. So
credentials are read through the session Transport ($HOME expanded by the
far shell, because a path built locally is the wrong home), one snapshot per
setup that offers Claude. The HTTP call stays on the backend, so the far end
needs nothing but a shell.
The snapshot says which of four things happened rather than carrying a flag
and a message: ok, notLoggedIn, unreachable, failed. notLoggedIn is
the one that matters — a machine nobody put an account on is working as
configured, and collapsing it into an error string made a healthy setup read
as broken. A machine with no Claude provider is not asked at all.
The five-hour window has no reset time between blocks, and that is not a
missing value. Measured 2026-08-31: the API anchors the window to the block
it started in, and when no block is running there is nothing to reset, so
resets_at is null. The weekly windows always have one because a week is
always running. So absent means not running, and only a timestamp that
arrives and cannot be parsed is unknown. WindowEnd in ResetCountdown.kt
is the one rule both readers go through.
Auto-resume (2026-09-05)
A session may pick itself back up when the account's usage limit lifts.
Off unless somebody switched that session to it, because it spends quota the
moment quota exists and does so with nobody looking — that is not a thing a
default may decide. It sends one message, continue unless another was
typed, and then it is done; there is no retry loop around the conversation
itself.
Running out of quota is a state, not an error. Event::LimitReached
carries the dialect's reset time where it gave one, and recognising it
belongs to the driver — the Claude CLI ends the turn with is_error and
Claude AI usage limit reached|1788546972, and nothing above the driver
matches on a string. The transcript draws it as a divider, like a clear or a
compaction: what a reader scrolling back wants from it is why the
conversation stops at that line.
The schedule is a plan to ask, never a plan to send. Every reset time
available here is untrustworthy in the direction that matters: the dialect's
is written when the turn fails, and the endpoint's moves when the window
does. So the wait ends in a question to usage.rs, and only ok with no
window at 100% sends anything. A window still spent reschedules to its own
reset time — which is what makes a limit that lifts later than promised wait
longer, and one that lifts sooner resume sooner. A meter that cannot be
asked at all is a longer wait too, never a send: "we could not find out"
must not be able to produce the same action as "there is room".
Bounded, because something has to be: a day after the limit was hit the wait stops and says so in the session's own transcript. A machine that can never be asked would otherwise be retried for ever with nothing on screen saying so.
The schedule is persisted on the session (resume: Some(ScheduledResume)),
not held in memory: a five-hour window routinely outlasts a backend restart,
and a wait forgotten across one is a session that silently never comes back.
resume.rs is the top layer — it holds the manager and the monitor and
neither holds it — which is what lets the decision be a pure function of a
snapshot and a clock. The pump reports limits downward on a broadcast, for
the reason Shared exists: the pump runs underneath the manager.
Exercised with echo, never with a real account. /limit [minutes] in an
echo session reports the same event a real driver does, and /usage sets
what the meter answers — deliberately two commands, because the two
disagreeing is the state the whole design is about. The loop was driven end
to end that way on 2026-09-05: the wait moved from the dialect's two minutes
to the meter's seven when the meter changed its mind, and the message went
out on the first check after the meter came back under the limit.
Subagents (2026-09-05)
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls of its own. Full design and wire
shape in SUBAGENTS.md, kept separate because the app half is being built
against it in parallel and it is the shared contract between the two. The
one-paragraph reason: a session's Task-tool helpers already speak the common
event model on the parent's own stdout (each line carrying
parent_tool_use_id), so giving each one its own small transcript — same
file format, same paging routes, same SSE stream, reused by addressing rather
than by copying — costs a routing step in the translator and a registry
(session/subagent.rs) rather than a second session type with a driver, a
process and a config entry it does not need.
HTTP surface
routes.rs's module doc comment is the table. REST for actions, one SSE
stream per open session screen for events, all over the pinned TLS listener.
SSE rather than WebSocket because resume-by-cursor (Last-Event-ID =
transcript seq) is native to it and the inbound direction is plain POSTs.
Sessions live in config.ron ($XDG_CONFIG_HOME/ai-app/) plus a per-session
directory under $XDG_DATA_HOME/ai-app/sessions/ (transcript, attachments,
produced images, process record), owner-only. Deleting a session is the
complete path out of everything spawning one created.
Every request body refuses fields it does not know
(serde(deny_unknown_fields)). A caller that misspells permissionMode got
a 200 and a session in the default mode, which is indistinguishable from
success at the place they are looking. Query strings are deliberately
permissive.
A phone that falls behind is answered with reset. Past
CATCH_UP_LIMIT the stream sends a reset frame and the newest window, and
the client rebuilds from it exactly as it does when the screen opens. Not
optional: without it the window is spliced onto rows no longer adjacent to
it, which reads as ordinary output. The stream used to replay everything
after the client's cursor, unbounded, while opening a session was bounded
to a page — so a long disconnect delivered thousands of events one frame at a
time.
The file explorer (2026-09-03)
EXPLORER.md holds this design. The one-line version: a machine's
filesystem, seen from the phone through the backend, keyed on the setup
rather than on a session (a session only says where to start), with every
operation one fixed shell script run through Transport so the local and the
ssh case are one implementation.
Security
- TLS with a self-signed CA, pinned in the app. Generated in process on
first start into
$XDG_CONFIG_HOME/ai-app/certs, so one place decides the extensions, the file modes and which addresses the leaf covers — every local IPv4 plus loopback and the emulator's host alias, so nobody maintains a hardcoded IP. The CA is created once and left alone; the leaf is reissued every start, so covering a new address is a restart. Regenerating the CA strands the installed app — the one-way door.- The enrollment link carries the CA certificate.
app/src/client/config.rsdecodes it and the transport pins it for every connection. A malformed or absent CA refuses enrollment rather than silently weakening TLS. This lets one APK enroll against either the host or an isolated development server.
- The enrollment link carries the CA certificate.
- The dev VM is untrusted (2026-08-25): not malicious, but it could
become so. The repo is a read-write mount shared between the VM and the
backend host, so everything in it — source, binaries, and the shell scripts
the host runs — is attacker-writable.
- Nothing secret lives in the repo. A CA private key the VM could read would let it mint a leaf the pinned app accepts, which is precisely the attack pinning exists to stop. Transcripts move for a plainer reason: they are whole conversations.
- The host should not execute what the VM can write — build and run the backend from a host-only checkout rather than the shared mount. Moving the keys closes the smaller door; this is the larger one.
- Accepted: a compromised VM can return anything it likes from the sessions it runs, since running an agent there is the point. The blast radius is that session's content, not the backend.
- This server's API is remote code execution (spawn a
bypass-permissions Claude on any ssh host). Pinning authenticates the
server to the phone but not the phone to the server, so a bearer token adds
the other direction. The token gates LAN-reachable RCE; it cannot defend a
compromised backend host or phone — those are inside the trust boundary,
and a compromised phone is handled by rotation.
- No route accepts a command. Listing, reading and writing files are
fixed scripts in
files.rs; the phone chooses only the path and the bytes. Provider discovery asks the machine rather than taking a command. - The explorer's routes take a path, and that is deliberate (EXPLORER.md's decision 3). Elsewhere the phone picks an id and the server resolves which file it names, so an enrolled token cannot become "read me an arbitrary file" — the import listing is written that way. The explorer is different because the path is the whole feature, and it grants nothing new: the same token already spawns a bypass-permissions agent in any directory on any machine a setup names. The import rule stands where it is, because there a path was unnecessary.
- Generation: 256 bits from the OS CSPRNG, base64url. A machine credential, never typed twice, so at this entropy no stretching is needed.
- Enrollment: printed once as a terminal QR code encoding
aiapp://enroll?host=…&port=…&token=…. The CA is embedded in the APK, so the QR carries no trust material — photographing the terminal leaks only the token, never a way to weaken pinning. The app registers an intent filter for the scheme, and the Settings screen also scans in-app viazxing-android-embedded, because not every phone's stock camera redirects a scanned URI to an app reliably. - Storage: the server keeps only the SHA-256 in
config.ron; a plain hash is enough for high-entropy random input. No "show token again" — lost means rotate. The phone seals it with an Android Keystore AES-GCM key (ServerConfig.kt; Jetpack's EncryptedSharedPreferences is deprecated with no drop-in successor and Google's guidance is now "use Keystore directly"). - Transport:
Authorization: Beareron every request including the SSE GET, never a query parameter, since URLs leak into logs. The tracing layer must not log the header — covered by a test, so a logging change cannot silently start leaking it. - Verification: one middleware wrapping the entire router, never
per-route, so a new route cannot forget auth. Zero unauthenticated
endpoints,
/healthincluded. Hash-then-constant-time-compare (subtle); failures logged with peer address plus a small fixed delay — not against brute force, but so scanners show up in the log. - Rotation (the path out):
--rotate-tokenregenerates, invalidates the old hash, reprints the QR. Config stores a list of{name, hash}, so per-device revocation is a config entry later, not a migration. - Why not mTLS: stronger in theory, but given pinning the delta is only "someone reads the token off a device already inside the trust boundary", and it costs Android client-cert provisioning and a worse new-phone story. Revisit if this outgrows single-user-on-LAN.
- No route accepts a command. Listing, reading and writing files are
fixed scripts in
- Off-network access: plain WireGuard (2026-08-24, no third party). The
backend binds
wg0only; the phone runs the official WireGuard app, enrolled by scanning its config as a terminal QR. The only internet-visible thing is one forwarded UDP port silent to unauthenticated packets, so the pre-auth surface is reachable only from enrolled peers and the token becomes defence in depth rather than the sole gate. Addressing stays single-path: the phone reaches the backend at its WireGuard address from everywhere.- Accepted operationally: the endpoint is a DDNS name, since the home IP is not guaranteed static. The WireGuard app resolves it when the tunnel comes up and does not re-resolve, so a rare IP change is fixed by toggling the tunnel once DDNS catches up. The symptom is obvious and lossless — the SSE cursor design replays whatever was missed.
- Rejected: Tailscale and Headscale, which add a coordination service this setup does not need at two or three devices; forwarding the HTTPS port directly, which puts every internet scanner one pre-auth bug away from RCE on a machine holding SSH keys.
- The server refuses to start without TLS, so the token cannot travel
unencrypted by misconfiguration, and binding fails closed — refusing to
start if
wg0is absent rather than falling back to 0.0.0.0.--bind <ip>is an explicit, logged override for development, a deliberate flag and never a fallback.
Iris Android application contract (2026-09-11)
An Iris Android application supplies an initialization function and its own
state/resources; Iris supplies the JNI host and APK packager.
#[iris::android_init] marks the initializer Android calls when it creates the Iris
view. The macro target-gates the initializer and generates the single exported
JNI_OnLoad plus the concrete android-view registration callback. The
simple form mutates AndroidUiState through a reference. An application with
custom state returns that state instead, and its AndroidAppState::Resources
associated type selects the resource bundle. StdRsc is the default, never a
host requirement; a custom bundle works by implementing the narrow
AndroidResources capabilities.
AndroidUiState and DesktopUiState implement their own host-state traits,
so an application with no additional fields uses them directly. Desktop takes
the widget-building initializer through DesktopApp::run_with; custom state
types retain the lifecycle trait hooks and derive their host-state accessors.
Background work crosses back to either UI thread through TaskCtx::update.
Each update wakes the host, Iris applies all queued closures, and the retained
tree's existing dirty-state check requests a frame only when a root, layout, or
widget changed. Winit's user-event proxy and Android's posted callback are
private implementations of that wake; applications do not define platform
event types or manually request redraws.
Iris ships no fonts, and font families are application-named strings
(2026-09-12). Applications register their own font bytes on Ui and select
them by the same string used by text widgets. The public boundaries accept
AsRef<str>, so an application can use bare strings or put its own semantic
enum in front of them without Iris hardcoding the roles. Text buffers, layouts,
and prepared glyphs live in a per-Ui Resources<TextRsc> generational arena;
a text widget's TextHandle wraps the UI-local RscHandle into it. The same
generic arena owns texture and managed-paint lifetimes. Its StrongRscId and
WeakRscId are sendable IDs rather than data pointers; clone and drop events
cross one arena-owned standard channel, and reference counts remain in the
arena instead of allocating one atomic counter per resource. The ordinary
register_font(family, data) call needs no policy argument and uses the shared
default glyph-atlas bucket; register_font_in opts a reloadable or sparse font
into a named bucket. replace_font unregisters the old faces, releases only
that bucket's pages, and invalidates the live text entries so their active owner
widgets reshape automatically. Freed array layers are reused without moving
live pages. Glyph rasterisation retains four horizontal quarter-pixel phases,
while final primitive edges (including retained scroll moves) snap to physical
pixels; this favours low-DPI text stability and keeps vertical scrolling from
resampling atlas masks. ai-app owns the
ai-app-icons family, its Nerd Fonts subset, its codepoints, its license and
the script that rebuilds it; the CSS generic names sans-serif and monospace
continue to resolve through the platform.
A Len is geometry; a LayoutLen is a length plus a claim on remaining
space (2026-09-12). Both carry absolute pixels, density-independent pixels,
and a fraction of their reference. Only LayoutLen carries rest, because
rest is allocation policy rather than a coordinate unit. Ordinary lengths
convert into layout lengths without claiming any remainder; there is no lossy
conversion back. Widget sizes, padding, and gaps use LayoutLen, while values
such as corner radius and text's position within horizontal overflow use
Len. Flexible padding participates in the same proportional allocation as a
span rather than silently discarding its rest component.
Text overflow is treatment plus position, not alignment (2026-09-12).
TextOverflow selects Visible, Wrap, Hidden, or the single Ellipsis
treatment. Unwrapped hidden and ellipsized text retains one canonical shaped
layout and is translated behind a retained mask; the ellipsis is a separately
shaped presentation glyph, never a byte in the source buffer. Consequently it
cannot be copied or activate a source span. Selecting the marker selects the
omitted source range. overflow_position: Len places the canonical text only
when it exceeds its viewport: zero keeps the text start at the viewport start,
rel(1) keeps the text end at the viewport end, and intermediate or animated
values expose both ends. This is independent of alignment when the viewport is
spacious. An editable caret that enters clipped content writes the needed
absolute position back into this same public field; callers animating it own
stopping their animation when editing begins. Position changes and changes
among the unwrapped treatments reuse shaping; entering or leaving Wrap
reshapes at the viewport width, while content, font, spans, or density retain
the normal shape invalidation path.
An ellipsis marker maps its visually omitted side to one contiguous source
range. This is exact for ordinary LTR and RTL runs; mixed-bidi text can place
discontiguous logical ranges on one visual side, which Iris's deliberately
single-range selection model cannot represent exactly.
cargo-iris is an installable Cargo subcommand, rather than a script callers
must find inside an Iris checkout. cargo iris apk builds the Rust cdylib
with cargo-ndk and packages it directly with the installed Android SDK tools:
javac, d8, aapt2, jar, zipalign and apksigner. Gradle is not in the ordinary
path because Iris's fixed Java view host has no Maven/AAR dependency or
variant graph for it to manage. An application that later embeds Iris in a
larger Gradle project can use that project as the packaging authority instead.
--example NAME packages the example's sibling android.rs as the Android
cdylib; desktop.rs remains Cargo's example target and both import their
shared lib.rs. This keeps the same widget tree runnable on both platforms
without adding another crate or making shared content select its host.
The tool discovers and validates prerequisites but never installs an SDK,
NDK, JDK, system image or emulator. cargo iris run requires an explicit
device serial; it never chooses, creates, starts or stops a device. Debug APKs
use Android's conventional debug key. Release signing requires the caller's
explicit keystore, alias and environment-supplied passwords, since Iris must
not create or own an application's permanent update identity.
App (app/)
One Rust crate owns platform-free client logic and Iris widget trees. Android
and desktop entry points contain only their host integration. Android is
packaged by a thin Java activity under android-project/; it does not contain
a second UI implementation.
- Session list — kind icon, title, setup, model, status, last activity. Sessions awaiting an answer sort to the top: the "your turn" inbox.
- Import — Claude Code sessions the machine already has, selected in
batches (hold to enter, tap to add), with Delete and Import along the
bottom. Submitting clears the selection immediately and marks every chosen
row, so the bar goes away and the affected set is what says the work is
happening. Rows are taken out as each one lands rather than all at the
end: a finished row still sitting there looks exactly like one that has
not been imported, and tapping it starts a second CLI on the same
transcript. That makes rows below slide up under the reader's finger, so a
row that has just moved ignores taps for
SETTLE_MS. - Models and Setups — browsing and downloading GGUFs; adding, renaming, re-probing and removing machines.
- Session screen — the core:
- The transcript rendered from the event stream: markdown, inline images, tool cards, question cards.
- Anything that is a note about the conversation rather than a turn in it is closed by default — a tool call, a peer message, a memory note. Open-ness is the screen's, never the card's: a card that remembered for itself forgets the moment the lazy list stops composing it, so a note opened and scrolled past would shut behind the reader.
- An answered question keeps its options and marks the one taken, in
the same purple that says "picked" while it is open — it does not
collapse into a line repeating the answer. The options are what the
question was, and "Deny" alone does not say Allow was the alternative.
One rule in two places (
AskedQuestionandPermissionAsk). An answer typed into Other matches no option, so that one is still written out. - Expanding a row keeps still the end nearest the tap: touch a row's
upper half and its top edge holds, so it opens downwards; touch its
lower half and the bottom edge holds, as the list does by default. Which
half, rather than which control, so everything that opens behaves alike
whether or not it has a control at each end. The transcript is laid out
from the bottom, so a bottom edge is anchored for free and the top one
has to be arranged:
Modifier.holdTopEdgeasks the list to shift during the layout phase, before anything is drawn. From an effect instead, the wrong position is drawn once first, which reads as a flick. - The full-screen image lives on the screen, not in the row that drew
the thumbnail (
SessionImageViewer). AReadwhose result is an image is a row of one call until the next call arrives and makes it a group — a different composable in a different part of the tree, so the old subtree and its open dialog go. Somebody looking at a screenshot was thrown back to the transcript because the session made another tool call. - All transcript text is selectable, from one
SelectionContaineraround the whole list. Not per row: a transcript is one body of text, so a selection has to run from a reply into the tool output under it — and a container per row leaves whatever was drawn without one silently unselectable. An inline code chip is drawn behind the text rather than as the renderer's span background, because a span background is part of the text's own drawing and hid the selection under it. - Input bar: text, attach, send — always enabled; mid-run sends become steering messages. A queued message can be tapped to take it back.
- The composer's process button (interrupt / stop / start) as above.
Markdown
A reply is drawn as pieces of one parse, never as re-parsed substrings.
A Piece addresses a top-level block of the message's tree, or one item of a
top-level list, and every piece is drawn from the same cached parse. That is
what bounds a lazy-list item without parsing a message more than once, and it
is why a forty-item list of sources is forty units rather than one. Links are
spans with one tap detector per text, not a layout node per link — the cost
that made a list of sources bumpy.
A table wraps its cells and never cuts one off. The renderer's defaults
draw every cell at one line with an ellipsis, which on a phone loses most of
a table — and an elided cell looks exactly like a short one. LinkedTableRow
gives a cell as many lines as it needs, aligned to the top of the row so a
two-line cell does not re-centre its neighbours. A column narrows to 136dp
and no further, past which the whole table scrolls sideways; 136 because it
is the widest floor that still fits three columns across a phone. Exercise it
with the echo driver's /table N, which writes long cells on purpose — a
fixture of tidy one-word values renders fine either way.
The transcript cache
The backend's transcript is the source of truth, and the app keeps a copy of
what it has already been sent — see TRANSCRIPT_CACHE.md (2026-09-04),
because reopening a session over the tunnel was re-downloading a conversation
the phone had just read. It is the server's own event lines, per session,
under cacheDir; it is checked against the server before a stream is resumed
from it, thrown away rather than patched when that check fails, and never
load-bearing — every path that reads it has a network path beside it giving
the same answer. What the app does not keep is anything derived: the folded
rows are rebuilt from events every time.
Notifications: two places, never both (2026-08-30)
GET /notifications is one SSE stream of attention-wanting moments, and the
app decides where each one is said. Three outcomes, in one place
(NotificationService.show):
- Nothing at all if the session is the one on screen. The transcript in front of the reader is already saying it.
- A banner over the app if the app is up —
SessionAlerts, queued, one per session replacing that session's own, dismissable by a push in either direction and otherwise retiring itself when the bar across its foot runs out. - A row in Android's drawer otherwise, which is what the foreground service exists for.
Never two of them for one moment. A drawer that fills up behind an app that showed you each one is a drawer nobody reads. Which of the three applies is answered without a flag anybody has to keep level: the session on screen is registered by the one composable that draws one, and "the app is up" is the banner queue being collected, since it collects only while it is on screen.
What counts as finished is decided in notification_for, and since
2026-08-31 it takes the number of messages the session has been given and not
started reading. With one waiting, a turn ending is not the work ending: a
message written into the tail of a turn is read the moment that turn's
result lands, so the session goes idle and immediately runs again — and the
phone that sent it was told its work had finished seconds before any of it
was done. The count is kept in pump, the one place that sees every event in
transcript order. It does not suppress awaiting input: a question is worth
saying whatever is queued behind it.
Rejected: giving the app its own connection to /notifications while it is
in front. That is a second stream per device saying the same thing, and it
puts the "which of these two shows it" decision in two processes' worth of
code instead of one function.
Deferred polish
Noticed and deliberately not fixed, so they are not re-found from scratch.
- The session screen's header is lopsided. The row is
padding(horizontal = 8.dp), so the status on the right sits exactly 8dp from the edge while "Back" on the left is aTextButtonwhose touch target is wider than its text. It is the "align the mark, not the box" case: either align the button's content or size the button to what it draws, rather than nudging with a hardcoded offset.
Status
Phases 1–3 (the skeleton pipe, the full Claude driver, the usage screen) done
2026-08-24. Phase 4 (llama.cpp: model browsing, downloads, and llama-server
through its OpenAI-compatible endpoint) and phase 5 (ssh) done 2026-08-28,
except for the remote llama-server and its port forward, which landed
2026-09-04. The file explorer and the transcript cache followed in September. What is
left is real-phone/WireGuard bring-up, which is operational rather than code.
Each phase ended runnable and verified against the real thing. The backend gets tests where logic is pure — event normalization, transcript cursors, config persistence, the syntax scanner; the app is UI over the API and is verified by running it, matching dev-updater's posture.
Open questions and risks
- The Claude stream-json control protocol is the least-documented
dependency and is version-coupled to the installed CLI. What works is
pinned in
session/claude.rs's module doc against the version it was measured on. - The usage endpoint is undocumented and has changed rate-limit behaviour before; treat as best-effort.
- Compaction for llama sessions is not built.
LlamaDriver::compactrefuses. The design when it is built: every response reports prompt and completion token counts, so track them againstn_ctx(from/props), and at ~75% summarize all but the last few turns and replace them, keeping the full pre-compaction transcript on disk so the phone's view never loses history. llama-server's own--context-shiftis rejected as the strategy: it truncates old KV cache entries, which is silent forgetting with no summary, and it corrupts the harness's view of what the model knows. Fine as a server-side safety net; not memory management. - Remote llama-server needs its port forwarded (
ssh -L) and is not built; such a session is refused rather than misdirected. - Claude sessions over ssh need the remote machine logged in to Claude. Usage reporting reads each machine's own credentials, so this is visible rather than silent.
References
- llama-server API (
/health,/props, OpenAI-compatible endpoints): https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md - Usage endpoint (
GET https://api.anthropic.com/api/oauth/usage, bearer token from~/.claude/.credentials.json, headersanthropic-beta: oauth-2025-04-20+User-Agent: claude-code/<version>, ≥180 s polling; a wrong User-Agent lands in an aggressive 429 bucket): https://github.com/anthropics/claude-code/issues/31637 - The sibling project this repo's conventions mirror:
../dev-updater(README.md + AGENTS.md — server/registry/routes layout, cert scheme, testing posture).