Everything that opens now behaves alike. Touch a row's upper half and its top edge holds, so it opens and closes downwards; touch the lower half and the bottom edge holds, which is what the list does on its own. A group's heading and the bar at its foot fall in the halves they already occupy, so they keep the behaviour they had, and a single tool call -- one card, with no bar -- gets the same choice for the first time: tapping low on an open Bash card now shuts it downwards exactly as a group's bar does. That makes the position of the tap the one mechanism, and RowEdge goes away with the pair of hardcoded ends it existed to name. Controls report where they were touched in root coordinates, which is all a control can know -- a group is one row with a control at each end and calls in the middle, and only the row knows where its own ends are -- and the row turns that into an edge. `clickableAt` is built on `clickable` rather than replacing it, so the ripple and the click action assistive technology reads are unchanged; the down position is observed on the initial pointer pass and nothing is consumed. Verified with ui-trace: on a collapsed group, a tap at y=1370 holds the heading and one at y=1450 lets the row grow upward instead. On the same nested call inside an open group, opening it from the group's upper half holds the heading at 565 and from the lower half moves it to 296. ktfmt, lint and 85 tests clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
47 KiB
ai-app — plan
A phone interface to AI coding sessions — Claude Code and llama.cpp for now — 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 the things the official app gets wrong (e.g. it won't deliver a typed message until the session fully finishes its turn, where the TUI injects it at the next tool boundary).
Same shape as ../dev-updater: a Rust (Axum) backend on the desktop, a
Kotlin/Compose Android app, pinned self-signed TLS between them.
The one idea everything hangs off
Both session types are a child process speaking JSONL over stdio:
- Claude Code:
claude -p --input-format stream-json --output-format stream-json— bidirectional streaming JSON. User messages sent while a turn is running are injected at the next opportunity (the TUI behavior we want), a control protocol carries interrupts and permission requests,--resume <id>picks a session back up after a backend restart. - llama.cpp: pi in RPC mode (
pi --mode rpc), pointed at a llama-server endpoint. Same deal: JSONL on stdio,prompt(with images),steerfor mid-run injection,abort,set_model,compact/set_auto_compaction, session files that survive restarts, structured events for streaming text and tool executions.
So the backend has one abstraction — spawn a process, translate its dialect to
a common event stream, keep an append-only transcript — and two translators.
SSH support falls out of the same shape: a remote session is the identical
command run as ssh <host> <cmd>; stdio doesn't care.
Decisions already made (2026-08-24):
- llama.cpp harness: pi RPC now, with the session abstraction kept clean enough that a custom Rust agent loop can be added as a third driver later.
- The backend manages llama-server itself (start with a chosen GGUF, stop, swap models), locally and over SSH.
- Claude permission prompts are interactive in the app, with a per-session permission mode chosen at spawn.
- One backend on the main machine; the phone talks only to it, and it reaches other hosts via SSH. Remote hosts need the CLIs installed but no backend.
Architecture
Setups and providers (decided and built 2026-08-28, superseding the below)
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 then two choices in order: pick a setup, then pick one of its providers.
This replaces the independent providers × hosts model recorded below,
which is what the code does today. What went wrong with it: the two axes
are not actually independent. A provider is only real on a machine where
that CLI is installed, so a free cross-product offers combinations that
cannot work — claude-cli on a machine with no claude, and every
provider paired with a host the driver ignores entirely (EchoDriver
takes no host, so "Run on" is a control that silently does nothing for
it). Grouping providers under the machine they exist on makes the picker
show only what is true.
Settled while building it:
- 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, which is the opposite of what this app is for. - Migrated once, then the migration was deleted (2026-08-28). Unknown
fields default away, so a
providers:/hosts:file would have loaded as an empty config and then been seeded over, losing everything silently. The first answer to that was to refuse such a file, which was the wrong trade and proved it: this process is how a phone reaches the backend at all, so refusing to start stranded the person who would have to fix it, as a crash loop with nothing reachable to explain it. It was replaced by a migration that kept the token hashes, backed the old file up, and rebuilt the rest — which is discoverable now anyway. That migration has since run on the one host there is, so it is gone again, per the standing rule that migration code is deleted once the update carrying it has been received. With one backend and one phone, nothing is left on the old shape, and a second parsing path nothing exercises only constrains later changes to the schema. A file in the old shape now fails to parse, which is correct because no such file exists. - Still to do: editing setups from the phone.
GET /setupsexists; writing them is not built, so a new machine is still a hand edit on the backend. That is the remaining gap against the standing preference that configuration be reachable from the app. Key material is the honest exception — a setup names an identity file that must already exist on the backend machine, because a private key must not travel.
The superseded model, for the reasoning it recorded:
Two independent axes, configured separately and chosen per session:
- A provider is what runs: a driver kind, the command to invoke, and
the models worth offering.
claude-cliis the first — named for the CLI specifically, since bare "claude" would suggest the credit-billed API, which this is not. llama.cpp becomes a second provider later. - A host is where it runs: an ssh target. Absent means the backend machine itself.
Sessions name both. Keeping them independent is what the motivating setup requires: the backend runs on the machine the phone can reach (where WireGuard terminates), which is not necessarily where a CLI is installed — here the Claude CLI lives only in a VM on that machine, while llama.cpp will be on the host itself. Pinning a host into a provider would make "the Claude CLI" and "the Claude CLI over there" two things to configure and choose between, and would stop the same provider from being sent somewhere else for one session.
Android app (Compose)
│ HTTPS (pinned CA) — REST for actions, SSE for live events
▼
backend (Rust/Axum, desktop)
├─ SessionManager ── Session ── Driver (trait)
│ ├─ ClaudeDriver (claude stream-json)
│ └─ PiDriver (pi --mode rpc)
│ each driver's process is spawned locally or as `ssh host …`,
│ decided per session by the host it names
├─ LlamaServerManager (llama-server lifecycle, local + SSH)
├─ UsageMonitor (Anthropic OAuth usage endpoint)
└─ config.ron + per-session transcript files
Backend layout (server/)
Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit.
main.rs— bootstrap, TLS listener.routes.rs— the whole HTTP table in one module doc comment (as in dev-updater).session/mod.rs—SessionManager: the live session registry, every mutation funnels through it (theregistry.rspattern: in-memory and on-disk state can't come apart).session/driver.rs— theDrivertrait and the common event model.session/claude.rs,session/pi.rs— the two translators.session/transcript.rs— append-only JSONL event log per session, with monotonically increasing sequence numbers (the phone's resume cursor).llama.rs—LlamaServerManager.ssh.rs— the ssh command builder (host configs ended up inconfig.rswith the rest of the schema, so this module is only the wrapping; named for what it does rather thanhosts.rsas first sketched).usage.rs— Anthropic usage polling.config.rs— persisted schema.certs.rs— the TLS certificates, generated in process on first start (added 2026-08-25, replacing agen-dev-cert.shthat shelled out to openssl).private.rs— creating files and directories owner-only. One module owns the modes so "nothing this server writes is readable by anyone else" is checkable in one place instead of re-argued at eachcreate(added 2026-08-25; config, certs, and session dirs had three copies).media.rs— the image media-type/extension table, shared by the four places that have to agree on it: storing an upload, serving it back, handing one to a driver's dialect, and saving one a tool produced.
session/pi.rs and llama.rs are phase 4 and not built yet; everything
else above exists.
The common event model
Driver output, whatever the dialect, is normalized into one event enum before it touches the transcript or the phone:
UserMessage { text }— what the user sent, echoed into the transcript by the manager (not by drivers) so every device renders the conversation from the one stream. (Added 2026-08-24 during phase 1: without it, reconnects and second devices would lose the user's side.)AssistantText { delta }— streaming text (rendered as markdown).ToolStart / ToolUpdate / ToolEnd { tool, input, output }— the "view tools it's running" screen is just these.Image { ref }— images in output (screenshots from tools, etc.) are saved under the session dir and referenced by id; the phone fetches them by URL.Question { id, prompt, options }— anything the session needs a human for: Claude's AskUserQuestion, and permission requests (canUseTool) are the same shape with approve/deny options. Answered via one endpoint.Answered { id, answer }— the manager's record of a question being answered, so a rendered question card resolves on every connected device, not just the one that answered (added 2026-08-24, same reasoning asUserMessage).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, where the dialect reports them (both do).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 and no size the model ever held. It is carried rather than summed by readers because it goes down — a compaction replaces it with what the compaction reports, and a clear leaves it unmeasured.driver::context_afteris that rule, and the phone folds with the same one (2026-08-30: this replaced a running spend total, which could only climb and so kept reporting a context a compaction or a clear had already taken away). A session the server has no measurement of asks the CLI's own file instead of waiting for a turn —import::context_of, the same three fields the import list reads, in the background at load so a start never waits on an ssh. A clear needs no special case: it gives the CLI a new session id, so the lookup lands on a file with no usage in it and answers "unknown", which is true.Error { message }.
Every event is appended to the session's transcript file with a sequence number, then fanned out to any connected SSE subscribers. The phone renders purely from this stream: reconnecting means "give me events after seq N" — no separate "load history" path to drift from the live one.
Inbound, the driver trait is small:
trait Driver {
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
fn answer_question(&self, id: QuestionId, answer: Answer);
fn interrupt(&self); // stop mid-run, session survives
fn set_model(&self, model: &str);
fn compact(&self); // pi: native; claude: /compact
fn shutdown(&self); // graceful process exit
}
send_user_message during a run is the point of the whole app: both dialects
queue it for injection at the next tool boundary rather than the end of the
turn. Claude's dialect: a user message on stdin mid-stream; pi's: steer.
Claude driver specifics
- Spawn:
claude -p --verbose --input-format stream-json --output-format stream-json --permission-mode <mode>in the chosen working directory, plus--modelat spawn. Permission mode (default/plan/acceptEdits/ bypassPermissions) is chosen on the spawn screen. - Interactive permissions: run with the stream-json control protocol's
permission request flow (the same mechanism the Agent SDK's
canUseTooluses) so tool approvals arrive as control requests, becomeQuestionevents, and our answer goes back as the control response. Verify the exact control-request wire format against the current CLI early in implementation — it's the least-documented part of this plan. - Interrupt: control-protocol interrupt request.
- Model change mid-session: try the control protocol's set-model; if the
installed CLI doesn't support it, fall back to
shutdown+ respawn with--resume <session_id> --model <new>— cheap, since Claude persists sessions in~/.claude/projectsanyway. That resume path is the recovery story for a process that has genuinely died; a backend restart no longer uses it, because the process is still there to be adopted (see below). Resuming is only ever safe when nothing else has that session open. - Images in: base64 image content blocks in the stream-json user message.
- Working directory, host, and model are spawn-screen fields.
Session processes outlive the backend (decided 2026-08-29)
A session's process is left running when the backend stops, and adopted again when it starts. Restarting the server — a rebuild, a service restart, a crash — must not end a turn somebody is waiting on, and a turn can easily be minutes long.
What this replaces: shutdown_all asked every driver to stop, then the
process exited immediately. The SIGKILL escape hatch was a timer inside the
runtime that died with it, so the stop was unreliable; whatever survived was
orphaned with nothing written down to find it by. Processes leaked either
way. The change is that they are now left on purpose and can be picked back
up.
How it works, all 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 now, and onlyDriver::stopdoes it.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 was draining it, which would stall the very turn the leak exists to protect. Measured: the CLI writes to a file unbuffered, so streaming is unaffected.
Two consequences worth stating:
--resumeis reachable only when nothing is running. This is the same rule as the import refusal below, and for the same reason: two CLIs on one session file duplicate the conversation into it and bill the second for re-reading all of it.- Remote sessions are adopted too, and the recorded pid is the
sshclient's. This was written down as "local only" and that was wrong about the code:startrecords a pid whatever the transport, and for a remote session the process the backend owns is the ssh client. Adopting it is coherent — the fifo still feeds it, its logs still capture the far end's output, andsshlives exactly as long as the remote command does, so its liveness is the session's liveness. The consequence worth knowing: the remoteclaudealways has an sshd pipe on stdin, under old code and new alike, because the fifo is on the backend's side of the connection. So the far process's stdin says nothing about which version of this server started it.
Driver therefore has two ways out rather than one: 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 them.
Importing refuses a session that is already open (decided 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, not a heuristic, 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. This is not hypothetical: on 2026-08-29 an agent imported
the session it was itself running in. Two claude --resume processes then
edited one checkout and appended to one transcript, the whole 65 MB
conversation — 154 embedded screenshots — was duplicated into the file under
a new prompt id, and the adopted copy re-read all of it. It ended at the
account's session limit.
pi driver specifics
- Spawn:
pi --mode rpc --provider openai-generic --model <name>(endpoint = the llama-server the LlamaServerManager provides),--session-dirunder our session storage so transcripts and pi's own session files live together. - Auto-compaction on by default (
set_auto_compaction), threshold configurable per session; manualcompactexposed as a button. steerfor mid-run messages,abortfor stop,set_modelwhen the target endpoint changes.- pi's session JSONL gives resume-after-restart, same as Claude's.
Models (built 2026-08-28)
Bryan asked for listing and downloading models from HuggingFace and running them with different parameters, which makes model management part of the feature rather than something done by hand beforehand.
- 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 so an hour-long fetch survives a phone locking its 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.
totalis Content-Length, or Content-Range's last field on a resumed request, and absent when the server says nothing — never an estimate. - Resume is guarded by identity, not by hope. A partial carries the
ETag it was written against; 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. - Parameters reach a driver as an untyped
paramsmap on the session, so the shared schema does not grow llama.cpp's vocabulary.
llama-server management
config.ron lists models (name → GGUF path or llama-server args, per
host) and hosts. The manager runs at most one llama-server per
(host, model), spawned on demand when a session needs it:
- Spawn (local or
ssh host llama-server …) on an allocated port, wait on/health, hand the endpoint to the pi driver. - Refcounted by sessions. The path out, written in the same change as the spawn: the last session using an instance releasing it starts an idle timer (configurable, e.g. 10 min), after which it's killed. Delete of the last session kills it immediately.
- "Change model" on a llama session = acquire the new model's server,
set_modelon pi, release the old one. Context carries over (it's prompt-replayed by pi against the new endpoint). - Remote llama-server output is only reachable from the backend host, and
binds localhost on the remote side with an SSH local port forward
(
ssh -L) held by the manager — no LAN-exposed inference ports.
SSH
- Host entries in
config.ron: name,user@host, optional ssh options, which capabilities it has (claude / pi / llama-server, with paths if not on PATH). Key-based auth only, using the systemsshclient viatokio::process— no Rust SSH library; this inherits~/.ssh/config, agents, and jump hosts for free. (Rule 23: openssh is already here and battle-tested; a library buys nothing but a second config surface.) - A remote session is exactly a local one with the command wrapped in
ssh -T host …. Process death ≙ connection death; the session shows asexitedand both dialects resume (--resume/ pi session file) on respawn, so a dropped SSH connection is an annoyance, not data loss. - The transport wraps the driver, not the other way round (decided
2026-08-28). A driver says what to run — program, arguments, working
directory — and something above it turns that into a process, locally or
through ssh. Today
ClaudeDriver::spawncallsssh::commanditself, which puts transport knowledge inside a translator whose job is a wire format, and means every future driver has to remember to do the same. Inverting it also removes the "Run on" lie for free: a driver that emits no command, like the echo one, has nothing for a transport to wrap, and the picker can say so. - The interface that inversion needs is not just "run a command", and
llama.cpp is the case that shows it: a managed
llama-serveris started as a process but then spoken to over HTTP, so a remote one needs a forwarded port (ssh -L) as well as a spawned process. A transport is therefore "run this" plus "reach this port", and the second operation is a no-op locally. - Attachments need no file transfer, contrary to what this section said
before:
attachment_blockbase64s an uploaded image into the stream-json message itself, and produced images come back the same way for the translator to write out locally. Nothing has to exist on the remote filesystem, so there is noscpstep to get wrong.
Usage limits (Claude)
Poll https://api.anthropic.com/api/oauth/usage — the same endpoint behind
Claude Code's /usage — with the OAuth access token from Claude Code's local
credential store (~/.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 any Claude session exists or the usage screen is
open, cache the last answer. Surface: 5-hour and weekly window utilization %
and reset times. It's undocumented, so usage.rs treats every field as
optional and degrades rather than erroring. Structure it as one
UsageProvider per paid service so a second service later is a new impl,
not a parallel screen (rule 9).
Per machine, not per backend (decided 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. Reading this machine's was right only while the
backend and the CLI were the same box — and in the layout this is aiming
at they are not: ai-server belongs on the host, the host has no claude
CLI, and the CLI machine is a remote. So credentials are read through the
session Transport (ssh host sh -c 'cat $HOME/…', $HOME expanded by
the far shell because a path built locally is the wrong home), one snapshot
per setup that offers Claude, cached per machine. The HTTP call stays on
the backend rather than running remotely, 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. The one that
matters is notLoggedIn — 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 and gets no
row at all.
HTTP surface (phone ⇄ backend)
REST for actions, one SSE stream per open session screen for events, all over
the pinned TLS listener. SSE over WebSocket because resume-by-cursor
(Last-Event-ID = transcript seq) is native to it and the inbound direction
is plain POSTs anyway.
GET /providers what can be spawned (name, kind, models)
GET /hosts machines a session can be run on
GET /sessions list (id, provider, host, title, model, status, last activity)
POST /sessions spawn {provider, host, model, cwd, permission_mode, title}
GET /sessions/:id/events?after=N SSE: transcript replay from N, then live
POST /sessions/:id/message {text, attachment_ids}
POST /sessions/:id/answer {question_id, answer} (questions and permissions)
POST /sessions/:id/interrupt
POST /sessions/:id/model {model}
POST /sessions/:id/compact (llama sessions)
POST /sessions/:id/attachments multipart upload → id (referenced by /message)
GET /sessions/:id/files/:ref images the session produced or was sent
DELETE /sessions/:id kill process, release llama-server, delete transcript+files
GET /usage cached usage windows
GET/PUT /hosts, /models config editing from the phone
Sessions live in config.ron ($XDG_CONFIG_HOME/ai-app/) + a per-session
directory under $XDG_DATA_HOME/ai-app/sessions/ (transcript.jsonl,
attachments, produced images), owner-only. Deleting a session is the
complete path out of everything spawning one created.
Security
- TLS with a self-signed CA, pinned in the app — same
idempotent-CA/reissued-leaf scheme as dev-updater, same one-way-door
caveat about regenerating the CA, but generated in process on first
start (
certs.rs) rather than by a shell script calling openssl (2026-08-25). One place then 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 — and there is no setup step to forget.- Unlike dev-updater, the pinned CA is not a constant in the source:
the build reads
$XDG_CONFIG_HOME/ai-app/certs/ca.pemfrom the machine doing the build and generates the constant (generatePinnedCertinapp/androidApp/build.gradle.kts;AI_APP_CAoverrides). Decided 2026-08-25, and it does three things at once — the trust anchor follows the build machine, so an APK built on the backend host pins that host and one built in the dev VM pins the VM's throwaway CA and is only good for its emulator; there is no second anchor to add for development and forget to remove; and regenerating a CA needs a rebuild rather than a paste, so a stale constant can't quietly disagree with the server.
- Unlike dev-updater, the pinned CA is not a constant in the source:
the build reads
- The dev VM is untrusted (decided 2026-08-25): a machine that isn't
malicious but could become so. It matters because the repo is a
read-write virtiofs mount shared between the VM and the backend host, so
under this model everything in it — source,
server/target/binaries, and the shell scripts the host runs, some with sudo — is attacker-writable. Two consequences:- Nothing secret lives in the repo. Certificates are generated on
the machine that serves them and written to
$XDG_CONFIG_HOME/ai-app/certs(0700, keys 0600);config.ronand session transcripts go to the XDG config and data directories, per machine. 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 — pinning against a CA the attacker holds is no pinning at all. Transcripts move for a plainer reason: they are whole conversations. As a bonus this ends the host and VM sharing one config, which had already produced a test token live on the backend, and takes state out of reach ofgit clean -xdf. - 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.
- Development in the VM generates its own throwaway CA. Whatever is installed on the real phone must pin only the host's.
- The CA key is not needed by the server at all (only
leaf.pemandleaf-key.pemare read), so it can move offline once the setup is stable; reissuing a leaf is the only time it is wanted. - Not addressed, and 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.
- Nothing secret lives in the repo. Certificates are generated on
the machine that serves them and written to
- This server is strictly more dangerous than the updater: its 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. Threat model: the token
gates LAN-reachable RCE; it does not (and cannot) defend a compromised
backend host or phone — those are inside the trust boundary, and a
compromised phone is handled by rotation.
- Generation: 256 bits from the OS CSPRNG on first run, base64url. A machine credential, never typed twice, so unguessable costs nothing; at this entropy no key stretching is needed.
- Enrollment: printed once as a terminal QR code (
qrcodecrate, ANSI), encodingaiapp://enroll?host=…&port=…&token=…. The CA stays embedded in the APK (PinnedCert.ktpattern), so the QR carries no trust material — photographing the terminal leaks only the token (rotatable), never a way to weaken pinning. The app registers an intent filter for theaiapp://enrollscheme as a fallback, for a camera app that redirects a scanned URI straight toMainActivity(2026-08-24). That was meant to be the only path — "the app side needs no QR library at all" — but reversed the same day: not every phone's stock camera redirects a scanned URI to an app reliably, so the Settings screen also scans in-app viazxing-android-embedded'sScanContract(a ready-made scanner Activity reached through the AndroidX Activity Result API, fully offline, no Play Services/ML Kit model download) and feeds the decoded URI to the sameparseEnrollmentUri(2026-08-25). - Storage: server keeps only the SHA-256 in
config.ron(plain hash is enough for high-entropy random input; buys that a leaked config doesn't leak the credential). No "show token again" — lost means rotate. Phone side: sealed with an Android Keystore AES-GCM key (a small hand-rolled helper inServerConfig.kt— Jetpack's EncryptedSharedPreferences is deprecated with no drop-in successor, and Google's guidance is now "use Keystore directly"; 2026-08-24). - Transport:
Authorization: Bearerheader on every request including the SSE GET. Never a query parameter (URLs leak into logs). The tracing layer must not log the header — covered by a test so a logging change can't silently start leaking it. - Verification: one middleware wrapping the entire router in
main.rs, never per-route, so a new route can't 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 (infeasible at 256 bits) but so scanners show up in the log. - Rotation (the path out):
--rotate-tokenregenerates, invalidates the old hash immediately, reprints the QR. That's the whole lost-phone story. Config stores a list of{name, hash}(of one, today) so per-device tokens with individual revocation are a config entry later, not a schema migration. - Why not mTLS: stronger in theory (key never leaves the Keystore, no bearer secret to exfiltrate), 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 ceremony and a worse new-phone story than a QR scan. Revisit if this outgrows single-user-on-LAN.
- Off-network access: plain WireGuard (decided 2026-08-24; no third
party). The backend binds to the WireGuard interface (
wg0) only; the phone runs the official WireGuard app (always-on VPN, per-app tunneling), enrolled by scanning its config as a terminal QR (qrencode -t ansiutf8 < phone.conf— same gesture as token enrollment). The only internet-visible thing is one forwarded UDP port that is silent to unauthenticated packets — scanners see it as closed — so the app's pre-auth surface (rustls handshake, hyper parsing, auth middleware) is reachable only from enrolled peers, and the token becomes defense in depth rather than the sole gate. Addressing stays single-path: the phone reaches the backend at its WireGuard address (e.g.10.66.0.1) from everywhere — one address in the app, one SAN in the leaf cert (SERVER_IP=/SAN override in the cert script), no home/away distinction. Another machine later is one keypair + one[Peer]block.- Operational needs, accepted: a public endpoint hostname. The home IP is mostly static but not guaranteed, so the phone's endpoint is a DDNS name (free, e.g. DuckDNS, or the router's built-in client; a curl cron on the backend host works too) that tracks changes automatically. One WireGuard nuance: the phone app resolves the endpoint hostname when the tunnel comes up and does not re-resolve on its own, so on the rare IP change the fix is toggling the tunnel off/on once DDNS has caught up (minutes). The symptom is obvious (app can't reach the backend) and lossless — the SSE cursor design means reconnects replay whatever was missed. Also: at-home traffic rides NAT hairpinning on the router (verify early; most support it, and the fallback is toggling the tunnel off at home).
- Rejected: Tailscale — same WireGuard underneath with easier setup (no port forward, LAN peer discovery), but it adds a third-party coordination service and account this setup doesn't need at two or three devices; Headscale — self-hosting that coordination server is strictly more moving parts than one wg config per peer at this scale; forwarding the HTTPS port directly — puts every internet scanner one pre-auth bug away from RCE on a machine holding SSH keys.
- The server still refuses to start without TLS — no plaintext listener
exists even inside the tunnel, so the token can't travel unencrypted by
misconfiguration, and interface binding failing closed (refuse to start
if
wg0is absent, rather than falling back to 0.0.0.0) is part of the same guarantee. Development gets--bind <ip>as an explicit, logged override (loopback for curl, a LAN address for a pre-WireGuard phone) — a deliberate flag, never a fallback, so the fail-closed default is untouched (2026-08-24).
- The bootstrap-over-HTTP trick from the updater is unnecessary here — the app installs via Dev Updater.
App (app/)
Kotlin + Compose Multiplatform, single :androidApp module, same versions as
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
- Session list — cards: kind icon, title, host, model, status (running / awaiting answer / idle / exited), last activity. Spawn FAB; swipe/long-press to delete (confirm). Sessions awaiting an answer sort to the top — that's the "your turn" inbox.
- Spawn — kind, host (from config), model (Claude list is static+editable; llama list from config), working directory, permission mode (Claude), title.
- Session screen — the core:
- Transcript rendered from the event stream: markdown text, inline images,
collapsed-by-default tool cards (name + input summary, expandable to
output; a spinner while
ToolStarthas no matchingToolEnd). - Question cards inline: option buttons for AskUserQuestion, allow/deny for permissions, free-text where allowed.
- 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 that everything that opens
behaves alike whether or not it has a control at each end — a group's
heading and foot bar simply fall in the halves they already occupy. The transcript is laid out from the
bottom, so a row's bottom edge is anchored for free and the top one
has to be arranged. The correction lives in the layout phase
(
Modifier.holdTopEdge): the measurement that discovers the row's new height asks the list to shift by that much, viarequestScrollToItem, before anything is drawn. Doing it from an effect instead means the wrong position is drawn once first, which reads as a flick and gets worse the faster the screen refreshes (2026-08-30, asked for after groups opened upwards and sent their own heading off the top of the screen). - Input bar: text, attach (camera/gallery/file), send — always enabled; mid-run sends become steering messages.
- Top bar: model chip (tap to change), stop button while running, token
count, compact button (llama), overflow → delete. (The count settled as
context held rather than tokens spent, and sits on the status row under
the transcript — see
UsageDeltaabove.)
- Transcript rendered from the event stream: markdown text, inline images,
collapsed-by-default tool cards (name + input summary, expandable to
output; a spinner while
- Usage — window bars for the 5-hour and weekly limits with reset times.
- Settings — server address + token, hosts editor, llama model list editor.
Networking mirrors dev-updater's app layer (AppsApi.kt style thin client +
pinned transport), plus an SSE client with after= resume driven by
connectivity/lifecycle. The app keeps no persistent transcript store — the
backend's transcript is the source of truth; the app caches only for the
screen it's showing.
Notifications: two places, never both (decided 2026-08-30)
The backend's 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. Tapping one opens the session, through the same path a tapped notification uses. - A row in Android's drawer otherwise, which is what the foreground service exists for.
Never two of them for one moment. A notification that has already been shown in the app is not something to also find in the shade afterwards, and 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.
The alternative considered and rejected was 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 yet, so they are not re-found from scratch. None is a defect; each is a decision waiting for the app to have been used enough to say which way.
- 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 — the same 8dp reads as more. 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.
Compaction: options explored
Context: raw llama-server has no conversation memory management; the context window just fills.
- pi's auto-compaction — chosen. When the prompt nears the model's
context limit, pi summarizes older history with the model itself and
replaces it with a structured summary; threshold configurable; manual
compactalso exposed. Battle-tested, zero work for us. - Manual compaction in a custom Rust loop — the later third driver.
The design when we build it: every llama-server response reports prompt +
completion token counts; track them against
n_ctx(from/props); at a threshold (~75%), pause, run a summarization request over all but the last few turns ("state of the task, decisions made, open items, relevant file/tool state"), replace those turns with the summary as a system-adjacent message, continue. Keep the full pre-compaction transcript on disk — the phone view never loses history, only the model's view shrinks. Worth doing eventually for control over the summarization prompt and for tool-loop experiments pi doesn't allow. - llama-server
--context-shift— rejected as the strategy. It truncates old KV cache entries: silent forgetting, no summary, and it corrupts the harness's view of what the model knows. Fine as a server-side safety net; not memory management.
Phases
- Skeleton — done 2026-08-24. Repo layout, cert script, TLS + token
auth, wg0-bound listener (fail closed if the interface is missing),
config.ron, session registry with a fake
EchoDriver, session list + session screen in the app end-to-end over SSE. Proves the whole pipe before any AI is involved. Verified: 10 server tests + clippy clean; curl end-to-end over pinned TLS (auth rejection, spawn, SSE replay/resume by cursor, question round trip, restart continuing seq numbers, delete); the app on thetdepemulator against the real server (QR-style enrollment via deep link, spawn, streamed echo turn, question answer, tool card). - Claude local — done 2026-08-24. ClaudeDriver: spawn, stream
text/tools, mid-run send, interrupt, permission questions,
AskUserQuestion, images both ways, delete.
Milestone: daily-drivable Claude replacement on localhost.
Wire-format notes live in
session/claude.rs's module doc (pinned against CLI 2.1.237): permissions need the hidden--permission-prompt-tool stdioflag; AskUserQuestion answers rideupdatedInput.answerskeyed by question text;set_model/interruptare control requests; 2.x permission modes are acceptEdits / auto / bypassPermissions / manual / dontAsk / plan (no more "default"). Attachments/files were re-homed under/sessions/:id/…(table above) so their lifecycle is the session directory's — delete stays the complete path out. - Usage screen — done 2026-08-24. The undocumented endpoint's
limits[]array parsed defensively into labeled window bars; cached behind the ≥180 s minimum with no background polling. - llama.cpp — LlamaServerManager (local), PiDriver, model change, compaction controls. Deferred (2026-08-24): pi/llama-server aren't set up in this VM, so this phase isn't testable here — Claude first; the driver seam is ready when it is.
- SSH — host config, remote spawn for both kinds, remote llama-server
with port forward, attachment shipping. Host config and remote spawn
done 2026-08-25 (any session of any provider can name a host; the
command is the identical one wrapped in
ssh -T, with every argument shell-quoted). Attachment shipping turned out to be unnecessary for the Claude driver — images ride the stdio JSONL as base64 in both directions, so nothing needsscp. Still outstanding: remote llama-server with its port forward, which comes with phase 4. Two things learned doing it: a remote session inherits ssh's non-login PATH, which is narrower than an interactive shell's (pointcommandat an absolute path if a CLI isn't found), and the remote command is run withexecso dropping the connection takes the CLI down rather than orphaning it. - Polish — reconnect edges, notification when a session awaits an answer (the "your turn" push), transcript search, whatever daily use surfaces.
Each phase ends runnable and verified against the real thing (rule 22); the backend gets tests where logic is pure (event normalization, transcript cursors, config persistence, refcounting) — the app is UI over the API and is verified by running it, matching dev-updater's posture.
Open questions / risks
- Claude stream-json control protocol details (permission requests,
set-model, interrupt wire format) are the least-documented dependency and
version-coupled to the installed CLI. Phase 2 starts by probing the
installed version and pinning what works; the
--resumerespawn fallback covers whatever the control channel can't do. - The usage endpoint is undocumented and has changed rate-limit behavior before; treat as best-effort.
- pi RPC schema drift — pin a pi version; the translator is one file.
- Whether notifications need FCM or a foreground-service polling connection — decide in phase 6; the SSE cursor design already supports either.
- Claude sessions over SSH need the remote host logged in to Claude; usage reporting reads only the backend host's credentials. Acceptable for now (same account everywhere); revisit if not.
References
Research behind the decisions above (verified 2026-08-24; re-check against installed versions when each phase starts):
- pi RPC protocol: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md
— commands (
prompt,steer,follow_up,abort,set_model,compact,set_auto_compaction, session ops) and the event stream. - pi + llama-server in practice: https://medium.com/@tolgaeren/running-pi-with-local-llms-c596aa14b062
- llama-server API (
/health,/props, OpenAI-compatible endpoints,--context-shift): https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md and the offline-agentic-coding walkthrough: https://github.com/ggml-org/llama.cpp/discussions/14758 - 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; wrong User-Agent → aggressive 429 bucket): https://github.com/anthropics/claude-code/issues/31637 and https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202 - Sibling project this repo's conventions mirror:
../dev-updater(README.md + AGENTS.md — server/registry/routes layout, cert scheme, testing posture, Android env notes).