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