Two decisions from Bryan today, written down before they are built, since this file is where the reasoning is supposed to live rather than in a conversation. A setup is a machine carrying the providers that machine has, and spawning picks a setup then one of its providers. The independent providers × hosts model it replaces is left in place below it, because its reasoning is worth keeping and the code still implements it. What that model got wrong is that the axes are not independent: it offers combinations that cannot work, and "Run on" is already a control that does nothing for the echo driver, which takes no host at all. The transport wraps the driver rather than the driver reaching for the transport. ClaudeDriver::spawn calls ssh::command itself today, which puts transport knowledge inside a translator whose job is a wire format, and obliges every future driver to remember the same. Inverted, a driver that emits no command has nothing to wrap, which is the same "Run on" problem solved structurally instead of by a special case. Also noted: llama.cpp is the case where "wrap a command" is not enough on its own, since a managed llama-server is spawned but then spoken to over HTTP, so a transport is "run this" plus "reach this port". And this section's claim that remote attachments need scp was never true of the code -- images are base64 inside the stream-json message in both directions, so nothing has to exist on the remote filesystem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
625 lines
35 KiB
Markdown
625 lines
35 KiB
Markdown
# 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), `steer` for
|
||
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 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.
|
||
|
||
Open, and worth settling before this is built:
|
||
|
||
- **Where the built-in echo provider lives.** It needs no configuration
|
||
and is the connectivity check that costs no tokens, so probably a
|
||
provider of an implicit local setup rather than something configured.
|
||
- **Migration.** Sessions store the provider and host names they were
|
||
spawned with; they would store a setup and a provider instead.
|
||
- **Setups are edited from the phone**, not by hand in `config.ron` — the
|
||
standing preference for this app. Key material is the exception that
|
||
cannot travel, so a setup names an identity file that must already exist
|
||
on the backend machine.
|
||
|
||
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-cli` is 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 (the `registry.rs` pattern: in-memory and
|
||
on-disk state can't come apart).
|
||
- `session/driver.rs` — the `Driver` trait 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 in `config.rs`
|
||
with the rest of the schema, so this module is only the wrapping; named
|
||
for what it does rather than `hosts.rs` as 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 a `gen-dev-cert.sh` that 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 each `create`
|
||
(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 as
|
||
`UserMessage`).
|
||
- `Status { state }` — idle / running / awaiting-input / compacting / exited.
|
||
- `UsageDelta { tokens }` — per-turn token counts where the dialect reports
|
||
them (both do).
|
||
- `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:
|
||
|
||
```rust
|
||
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
|
||
`--model` at 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 `canUseTool`
|
||
uses) so tool approvals arrive as control requests, become `Question`
|
||
events, 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/projects` anyway. That same resume path is the crash
|
||
recovery story: a dead backend or a killed process loses nothing.
|
||
- Images in: base64 image content blocks in the stream-json user message.
|
||
- Working directory, host, and model are spawn-screen fields.
|
||
|
||
### pi driver specifics
|
||
|
||
- Spawn: `pi --mode rpc --provider openai-generic --model <name>` (endpoint =
|
||
the llama-server the LlamaServerManager provides), `--session-dir` under
|
||
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; manual `compact` exposed as a button.
|
||
- `steer` for mid-run messages, `abort` for stop, `set_model` when the target
|
||
endpoint changes.
|
||
- pi's session JSONL gives resume-after-restart, same as Claude's.
|
||
|
||
### 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_model` on 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 system `ssh` client via
|
||
`tokio::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 as
|
||
`exited` and 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::spawn` calls `ssh::command` itself,
|
||
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-server` is 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_block` base64s 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 no `scp` step 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 to "unavailable" 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).
|
||
|
||
### 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.pem` from the machine
|
||
doing the build and generates the constant (`generatePinnedCert` in
|
||
`app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). 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.
|
||
- **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.ron` and
|
||
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 of `git 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.pem` and
|
||
`leaf-key.pem` are 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.
|
||
- 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 (`qrcode` crate,
|
||
ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays
|
||
embedded in the APK (`PinnedCert.kt` pattern), 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 the `aiapp://enroll` scheme as a fallback, for a camera app
|
||
that redirects a scanned URI straight to `MainActivity` (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 via `zxing-android-embedded`'s `ScanContract` (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 same `parseEnrollmentUri` (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 in `ServerConfig.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: Bearer` header 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, `/health` included. 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-token` regenerates, 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 `wg0` is 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:
|
||
|
||
1. **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.
|
||
2. **Spawn** — kind, host (from config), model (Claude list is static+editable;
|
||
llama list from config), working directory, permission mode (Claude),
|
||
title.
|
||
3. **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 `ToolStart` has no matching `ToolEnd`).
|
||
- Question cards inline: option buttons for AskUserQuestion, allow/deny for
|
||
permissions, free-text where allowed.
|
||
- 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.
|
||
4. **Usage** — window bars for the 5-hour and weekly limits with reset times.
|
||
5. **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.
|
||
|
||
### 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 a `TextButton` whose 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.
|
||
|
||
1. **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
|
||
`compact` also exposed. Battle-tested, zero work for us.
|
||
2. **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.
|
||
3. **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
|
||
|
||
1. **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 the `tdep` emulator against the real
|
||
server (QR-style enrollment via deep link, spawn, streamed echo turn,
|
||
question answer, tool card).
|
||
2. **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 stdio` flag; AskUserQuestion answers ride
|
||
`updatedInput.answers` keyed by question text; `set_model`/`interrupt`
|
||
are 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.
|
||
3. **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.
|
||
4. **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.*
|
||
5. **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 needs `scp`. 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 (point `command` at
|
||
an absolute path if a CLI isn't found), and the remote command is run
|
||
with `exec` so dropping the connection takes the CLI down rather than
|
||
orphaning it.
|
||
6. **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 `--resume` respawn 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`, headers
|
||
`anthropic-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).
|