Phase 4 is no longer deferred and phase 5 is exercised, so the status section says so. The two decisions worth not undoing by accident get named: the conversation lives in the transcript rather than the driver, and a llama session is refused on an ssh host rather than half-working. Also the local testing recipe, including the trap that cost me twenty minutes -- a 2-bit quant produces fluent nonsense that reads exactly like a broken driver, and llama-cli on the same file is how to tell the two apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
647 lines
36 KiB
Markdown
647 lines
36 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.
|
||
|
||
### 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.gguf` and 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.** `total` is 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-Range` would 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 `params` map 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_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).
|