A session now names a provider (what: driver kind, command, models) and optionally a host (where: an ssh target). Keeping them independent is what the real setup needs -- the backend runs where the phone can reach it, which isn't where the CLI is installed -- and it means any provider can be sent to any host rather than a machine being baked into one. The first provider is claude-cli, named for the CLI rather than bare "claude", which would suggest the credit-billed API. A fresh config is seeded with it so a new install has something to spawn and a worked example to edit; echo stays a built-in provider needing no config. ssh.rs builds the child process either way: locally, or `ssh -T` with BatchMode and keepalives, every argument single-quoted for the remote shell (a working directory that tries to close the quote and start a command is covered by a test), and `exec` so dropping the connection takes the CLI down instead of orphaning it. App: the spawn screen reads /providers and /hosts instead of hardcoded lists, so config changes need no rebuild. Chip rows are FlowRow, fixing the reported bug where a row of models that didn't fit wrapped *inside* each chip -- one letter of "haiku" per line -- rather than onto a second line. Verified: 29 tests, clippy clean; the same claude-cli provider run once locally and once over ssh, with the remote one visibly in a different environment; an unknown host name refused with the configured list; and the spawn screen on the emulator showing server-driven providers, hosts, and models that wrap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
501 lines
28 KiB
Markdown
501 lines
28 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 `../local-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
|
|
|
|
### Providers and hosts (decided 2026-08-25)
|
|
|
|
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.json + per-session transcript files
|
|
```
|
|
|
|
### Backend layout (`server/`)
|
|
|
|
Mirroring local-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
|
|
local-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`.
|
|
- `hosts.rs` — host configs and the ssh command builder.
|
|
- `usage.rs` — Anthropic usage polling.
|
|
- `config.rs` — persisted schema.
|
|
|
|
### 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.json` 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.json`: 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.
|
|
- Images and attachments for remote sessions are written to the remote
|
|
session dir via `scp`/stdin before the message referencing them is sent.
|
|
|
|
### 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.json` + a per-session directory (transcript.jsonl,
|
|
attachments, produced images). Deleting a session is the complete path out of
|
|
everything spawning one created.
|
|
|
|
### Security
|
|
|
|
- TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and
|
|
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
|
|
scheme, same one-way-door caveat about regenerating the CA.
|
|
- 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 side needs no QR
|
|
library at all: it registers an intent filter for the `aiapp://enroll`
|
|
scheme, and the stock camera app hands the scanned URI straight to
|
|
`MainActivity` (2026-08-24).
|
|
- **Storage**: server keeps only the SHA-256 in `config.json` (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 Local Updater.
|
|
|
|
## App (`app/`)
|
|
|
|
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
|
local-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 local-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.
|
|
|
|
## 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.json, 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 local-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: `../local-updater`
|
|
(README.md + AGENTS.md — server/registry/routes layout, cert scheme,
|
|
testing posture, Android env notes).
|