1324 lines
80 KiB
Markdown
1324 lines
80 KiB
Markdown
# ai-app — plan
|
||
|
||
A phone interface to AI coding sessions — Codex, 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)
|
||
│ ├─ CodexDriver (persistent codex app-server JSONL)
|
||
│ ├─ 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 configured machine
|
||
├─ usage.rs (provider usage meters, 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
|
||
|
||
### Machines and providers (2026-08-28; terminology corrected 2026-09-12)
|
||
|
||
**A machine is an execution environment, and it carries the providers that
|
||
environment has.** It may be a physical computer, a VM, or an SSH target;
|
||
"host" is reserved for the address used to reach one. A session is therefore
|
||
the pair of a machine and one provider installed there. Optional ssh details,
|
||
plus the list of what can be run there. Spawning is two choices in order: pick
|
||
a machine, then pick one of its providers.
|
||
|
||
The first implementation called this whole object a "setup", even though the
|
||
word was originally meant to name the machine/provider pair. The public model,
|
||
Machines tab, source names and HTTP surface now consistently say `machine` and
|
||
`/machines`. Existing RON accepts `setups` and a session's `setup` as read-only
|
||
aliases so an update cannot orphan configured environments or conversations;
|
||
the next write uses `machines` and `machine`. There is deliberately no legacy
|
||
HTTP alias: the server and APK are versioned together.
|
||
|
||
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 machine 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.
|
||
- `machines.rs` — machines and provider discovery.
|
||
- `files.rs` — the file explorer (`EXPLORER.md`).
|
||
- `usage.rs` — provider 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/codex.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.
|
||
- `AssistantTextFinal { text }` — the provider's authoritative value for the
|
||
open assistant message. It replaces its preceding provisional deltas while
|
||
remaining an append-only transcript event, so live SSE, replay and paging
|
||
converge on the same words.
|
||
- `ToolStart / ToolUpdate / ToolEnd { tool, input, output }`.
|
||
The tool vocabulary is common too (2026-09-09), not just the envelope:
|
||
Codex's `/usr/bin/bash -lc` argv and Claude's Bash call are both
|
||
`Bash { command }`, while Codex file changes and Claude Edit calls are both
|
||
`Patch { diff }`. Patch success boilerplate is omitted and failures remain
|
||
as output. This normalization belongs in the drivers, before persistence;
|
||
the phone never decodes a provider's tool schema.
|
||
- `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 /
|
||
**waiting** / exited / unknown. `waiting` (2026-09-06) is the session's own
|
||
turn being over while work it started is not: a backgrounded subagent, or a
|
||
command left running. Its own state because `idle` and it differ in *kind* —
|
||
`idle` means the session is waiting for a person, and this means it is
|
||
waiting for itself and will speak again with nobody having typed anything.
|
||
Reporting it as idle sent a "finished" notification at the one moment that
|
||
was untrue.
|
||
- `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: a dialect with
|
||
a live input channel injects it at the next tool boundary. A turn-at-a-time
|
||
dialect persists it and starts the next turn as soon as the current process
|
||
ends.
|
||
|
||
### 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.
|
||
|
||
### Codex driver specifics (2026-09-09)
|
||
|
||
Codex uses one persistent `codex app-server --stdio` per session. The original
|
||
2026-09-07 implementation used one `codex exec --json` process per turn, but
|
||
that surface cannot steer: a message typed during work was held until the turn
|
||
ended, and Pause killed the whole process before starting another resume. The
|
||
app-server protocol provides the operations the interface actually promises:
|
||
`turn/steer` injects a message into the active turn and `turn/interrupt` stops
|
||
that turn while leaving the conversation process alive.
|
||
|
||
The process's stdin is a fifo and its output is a detached log, with protocol
|
||
state persisted beside the thread id. It therefore survives and is adopted
|
||
across a backend restart like the Claude CLI. A steer is sent immediately and
|
||
is announced where Codex emits its user-message item; if Codex says the active
|
||
turn is not steerable, the message remains queued and starts the next turn
|
||
instead of being lost. An interrupt requested while a turn is still starting
|
||
is applied once Codex supplies that turn's id, so it cannot leak forward and
|
||
hide a later failure.
|
||
|
||
A missing thread is recoverable (2026-09-14). Codex returns a thread id before
|
||
its first turn creates a rollout, so restarting in between can leave ai-app
|
||
holding an id that `thread/resume` rejects as either "thread not found" or "no
|
||
rollout found" (other CLI versions say "invalid thread id", and malformed ids
|
||
are "invalid session id"). A rollout can disappear later too, including between
|
||
a successful resume and `turn/start`. Both mean the model context is gone but
|
||
ai-app's common transcript is not: the driver forgets only that stale id,
|
||
reports Codex's refusal, records a context-clear boundary, starts a fresh Codex
|
||
thread and then delivers anything queued. A turn request that discovers the
|
||
loss returns its in-flight message to that queue before recovery, so the message
|
||
that triggered recovery is delivered to the replacement rather than
|
||
disappearing. The error stays visible because losing model context is material
|
||
even when the process can heal it.
|
||
Other resume failures remain errors rather than silently discarding context.
|
||
The replacement's `thread/started` notification is a new root despite not
|
||
matching the translator's old root id; its null `parentThreadId` distinguishes
|
||
it from a subagent and moves the translator to the replacement conversation.
|
||
That notification retries the waiting queue, so delivery does not depend on a
|
||
later message happening to retry it. A message accepted while initialization or
|
||
recovery is still finding a thread is recorded as queued; the phone can therefore
|
||
reopen without losing the only visible copy before Codex acknowledges it.
|
||
Clearing does not eagerly create that replacement: it forgets the old id and
|
||
the next message starts a thread and its first turn together. An empty replacement
|
||
would become exactly the unresumable id above if the app-server restarted between
|
||
the clear and the next message.
|
||
|
||
Codex subscription limits come from the CLI's `account/rateLimits/read`
|
||
app-server request on the machine that runs Codex. This keeps login and
|
||
token refresh inside the CLI. Its primary and secondary windows are normalized
|
||
into the existing usage snapshot shape, under provider name `codex`, so the
|
||
phone and auto-resume need no Codex branch. This is the CLI's local protocol
|
||
and is treated defensively for the same reason as Claude's undocumented usage
|
||
endpoint: missing fields or a refusal degrade to an unavailable snapshot.
|
||
|
||
The model picker asks the selected machine's Codex app-server for `model/list`
|
||
when it is opened. The catalog is account- and CLI-version-specific, so it is
|
||
never copied into the app or inferred from another machine; a lookup failure is
|
||
shown as unavailable while the free-text escape remains. Permission choices
|
||
are likewise reported per provider: Codex offers its read-only,
|
||
workspace-write and full-access modes, while Claude keeps its own modes.
|
||
|
||
Resuming passes `excludeTurns: true`: this app already owns and pages its
|
||
common transcript, so asking app-server to hydrate the complete Codex history
|
||
only sends the rollout a second time. That is especially costly for image tool
|
||
results, whose protocol records carry base64 data. Live structured tool
|
||
results are split at the driver boundary: text becomes tool output and each
|
||
image is saved under the session and emitted as `Image`, never serialized into
|
||
a transcript line. Images attached to a remote Codex session ride the stdio
|
||
protocol as inline image input, since the server's local attachment path does
|
||
not exist on that machine. `thread/tokenUsage/updated.last.inputTokens` is the
|
||
measured context (cached input is already included), while `last.totalTokens`
|
||
remains the turn's usage. If an older common transcript has no such event yet,
|
||
the server seeds the same measurement from the last `token_count` in Codex's
|
||
own rollout, including when that rollout is on an SSH machine.
|
||
|
||
App-server's `item/agentMessage/delta` notifications are provisional: safety
|
||
buffering can revise their words before `item/completed` supplies the durable
|
||
text. The driver records that completion as `AssistantTextFinal`; the phone
|
||
replaces the open message both live and on replay. A distinct append-only event
|
||
also makes adoption safe: if a backend restart falls between the deltas and the
|
||
completion, the correction is still meaningful without process-local memory
|
||
of which item ids streamed.
|
||
|
||
**Codex subagents share that one app-server process** (2026-09-13). Every
|
||
thread in the session tree is multiplexed onto its stdout and identified by
|
||
the notification's `threadId`. `collabAgentToolCall` carries the spawn prompt;
|
||
`subAgentActivity` carries the child thread id, path and lifecycle. The driver
|
||
uses that child thread id as the existing `Subagents` registry key, routes the
|
||
child's ordinary items through a separate translator into its own transcript,
|
||
and keeps only the root thread's process state in `CodexDriver`. A child turn
|
||
ending does not finish the child: `completed` or `interrupted` activity does.
|
||
An `agentMessage` marked `delivery: async` is a peer message delivered to a
|
||
thread, never that thread's own assistant reply.
|
||
|
||
### 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 its configured machine** (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
|
||
machine has 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 /machines/{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 machine'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)
|
||
|
||
**Pressing Send always makes a quiet local bubble first (2026-09-10).** It remains until the
|
||
provider's `UserMessage` records that the message was received. A server `MessageQueued` replaces
|
||
the local bridge with its durable queue entry rather than adding a second bubble; an immediate
|
||
`UserMessage` removes it directly. If the request cannot reach the server, the local bubble stays
|
||
and carries that network failure underneath the message. Local bridges without a successful server
|
||
response are stored per server and session on the phone, so leaving and reopening the screen cannot
|
||
eat the only copy. Once the server accepts the request, the bubble remains in memory until the
|
||
provider event but the phone stops storing it: ownership has crossed to the server, whose transcript
|
||
and driver state survive the screen. Accepted queued messages remain the server transcript's fact
|
||
and are replayed from it on every device. A failed local bubble can be discarded after an explicit
|
||
confirmation that this removes the phone's only copy; otherwise the persistence that protects it
|
||
would also leave it on screen forever with no way out.
|
||
|
||
Reconciliation uses the first local message with the same text and attachments because the current
|
||
message route has no caller-supplied id. Identical sends are therefore consumed in wire order. A
|
||
client id on the route and events would make cross-device identical simultaneous sends unambiguous,
|
||
but expanding the protocol solely for a transient display bridge was rejected.
|
||
|
||
`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.
|
||
|
||
### A steer says that it is one (2026-09-13)
|
||
|
||
A message typed during a turn reaches the model with a bracketed note in
|
||
front of it saying it was written without having seen the rest of that turn
|
||
(`driver::message_body`, and `STEERING_NOTE` beside it). The transcript keeps
|
||
the words that were typed; only the copy the CLI is handed carries the note.
|
||
|
||
The reason is that where a steer lands is not ours to choose. It reaches the
|
||
model at the next model call if the turn has one left, and otherwise as the
|
||
opening line of the *next* turn -- Claude's read out of the fifo after the
|
||
turn ended, Codex's requeued when `turn/steer` is refused as
|
||
`activeTurnNotSteerable`. In that second case nothing distinguishes it from
|
||
an ordinary reply, so the model treats the answer it just gave as read and
|
||
answers around it. Bryan reported this as the ordinary experience of steering
|
||
from the phone: the interruption is meant to arrive mid-work, and it lands
|
||
after the fact often enough to matter.
|
||
|
||
It is prefixed on every steer rather than only on the ones that land late,
|
||
because the two are the same message until the CLI reads it, and the note is
|
||
true either way: a steer never saw the rest of the turn it was typed into.
|
||
|
||
### 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 /machines/{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".
|
||
The session's usage dialog applies the same machine-and-provider match and
|
||
shows every billing pool for that provider; it does not turn opening one
|
||
session into a comparison with the other providers on that machine.
|
||
For a provider with several pools, the compact bar selects the pool named by
|
||
the session's model (including Luna's `gpt-reserve` name), falling back to the
|
||
provider's generic pool, and shows the shortest cycle that pool actually
|
||
reports. A weekly-only pool therefore gets a weekly bar; it is never relabeled
|
||
as five-hour merely because the provider called it `primary`.
|
||
|
||
`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
|
||
machine that offers Claude. The HTTP call stays on the backend, so the far end
|
||
needs nothing but a shell.
|
||
|
||
The snapshot says what happened rather than carrying a flag and a message:
|
||
`ok`, `notLoggedIn`, `authenticating`, `loginRequired`, `unreachable`, or
|
||
`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 machine read
|
||
as broken. A machine with no Claude provider is not asked at all.
|
||
|
||
**Refresh and sign-in are serialized per machine and metered provider
|
||
(2026-09-12).** OAuth credentials rotate in a store shared by every CLI process
|
||
on that environment. Concurrent `/usage` requests used to be able to run two
|
||
refresh probes against the same file, while globally serializing would make an
|
||
unreachable machine stall unrelated accounts. `UsageMonitor` therefore has one
|
||
gate for each `(machine id, usage provider)` and rechecks the cache after taking
|
||
it. A concurrent read serves the last answer when there is one; a first read
|
||
waits for the single producer. Different machines and providers proceed
|
||
independently.
|
||
|
||
When Claude cannot renew an expired login, the driver records that as an
|
||
actionable authentication event rather than leaving the phone to recognise
|
||
the CLI's error sentence. The phone opens sign-in directly over the affected
|
||
session, and also offers it from the Machines tab and the usage dialog. The
|
||
backend starts the configured Claude CLI's headless `auth login` on that
|
||
machine, returns only its Anthropic authorization URL, and accepts the one code
|
||
copied back from the browser. The CLI remains the OAuth client and the only
|
||
credential writer: the backend keeps the URL and code only for the live attempt
|
||
and never sees or persists an access token or refresh token. An explicit login
|
||
holds the same machine/provider gate as usage refresh, is cancelable, expires
|
||
after ten minutes, and is killed when the backend stops.
|
||
|
||
**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. **Two detectors, since 2026-09-06**: that sentence, and
|
||
the CLI's own `rate_limit_event` lines, whose `rate_limit_info.status` says
|
||
where the account stands and whose `resetsAt` is the same hint. One detector
|
||
was a single point of failure for a feature whose whole job runs unattended
|
||
— if the wording or the shape of a failed turn ever changes, nothing is
|
||
scheduled and the session simply never comes back, with nothing on screen
|
||
saying why. Only the change *into* being refused is reported, and only a
|
||
status that is not an `allowed…` word counts as refused: an unfamiliar word
|
||
is read as out of quota and logged, because the cost of being wrong that way
|
||
is one extra question to the meter, and the cost the other way is the
|
||
feature silently not existing. 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.
|
||
|
||
### Two turns must never be drawn as one (2026-09-06)
|
||
|
||
A turn can start with nothing recorded in front of it — a subagent reporting
|
||
back, a peer message the CLI only owns up to at the end, a conversation the CLI
|
||
picks up by itself. The phone's fold grew the last reply rather than starting a
|
||
new one, so two answers were drawn as one paragraph, running together
|
||
mid-sentence with not even a space between them.
|
||
|
||
**The fold refuses to grow a *settled* reply**, so a turn boundary is always a
|
||
message boundary whatever caused it; `joinPages` carries the same rule across a
|
||
page boundary. Where two replies then abut, the fold puts a `TurnBreak` between
|
||
them: a hairline rule, no words, no colour. It is made by the fold rather than
|
||
sent by the server because it is not something that happened — it is the
|
||
boundary between two things that did.
|
||
|
||
**Nothing else about a background task gets a row of its own.** That was tried
|
||
and was wrong: a row per finished subagent is a screenful of dividers about work
|
||
the reader was not asking after, and one of them turned out to be a whole shell
|
||
command drawn as centred prose, because its words came from somewhere with no
|
||
reason to keep them short. The parent's transcript gets a row for a message a
|
||
subagent genuinely *sends* it, which arrives by the peer path and already has
|
||
one. The live provider-reported count beside the parent session's status is
|
||
deliberately the only background-task UI until there is a design for inspecting
|
||
them.
|
||
|
||
**The report goes to whichever record is the only one of it**, and the two cases
|
||
are different places. A subagent has a transcript of its own, and its closing
|
||
words are that transcript's last line. A backgrounded *command* has none: its
|
||
own tool card is the only record of it anywhere, and until the notification
|
||
arrives that card is showing the launch result, which says the command is
|
||
running. So the card is updated (`Event::ToolUpdate` against the call's own id)
|
||
rather than left making a claim nothing will ever correct — including for the
|
||
endings that carry no summary, which are exactly the ones that went wrong and
|
||
the ones a stale "running in background" reads worst on.
|
||
|
||
**The edge stream is detail; Claude's background-task level is authority**
|
||
(2026-09-15). Claude Code 2.1.261 added
|
||
`background_tasks_changed { tasks: [...] }` with replace semantics expressly so
|
||
a missed bookend cannot wedge a running indicator. Its ids are not correlated
|
||
with the edge stream; this side uses the authoritative empty/nonempty level and
|
||
its measured size. That size is exposed as `backgroundTasks` in the session row
|
||
and event stream, and is drawn beside the status; background tasks do not become
|
||
subagent cards.
|
||
The driver sends a repeated `initialize` when it adopts a CLI, which prompts a
|
||
full snapshot without restarting the conversation. A parent already recorded as idle or waiting can
|
||
apply it immediately; one adopted mid-turn waits for the result boundary,
|
||
because a foreground agent is correctly absent from a background-only set.
|
||
The ordinary notifications still supply outcomes and summaries, including when
|
||
one is ordered after the level already corrected the status. Older CLIs send no
|
||
level and retain the edge fallback below.
|
||
|
||
What the notification is still used for is the status: it is what closes a task
|
||
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
|
||
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
|
||
there first is the one that finds the task open, in the translator's own
|
||
`open_tasks` or, failing that, in the registry. That second lookup is what makes
|
||
an **adopted** session work: a backend restart picks a session's stdout back up
|
||
from a recorded offset, so the `task_started` lines for anything already running
|
||
are behind it and the translator never sees them. `Subagents::any_open` covers
|
||
those, and `open_tasks` covers the backgrounded command, which has no subagent
|
||
to be found in the registry at all. Both are needed and neither subsumes the
|
||
other.
|
||
|
||
### A transcript outlives this enum (2026-09-06)
|
||
|
||
**The set of event kinds a transcript can hold only ever grows.** It is
|
||
append-only and permanent, so what this build *writes* is not what it may have
|
||
to *read*: a line can come from a newer server, or from an older one that wrote
|
||
a kind since dropped.
|
||
|
||
That was learned the expensive way. `Event::TaskNote` was added and removed
|
||
again within hours, and every transcript that had recorded one became
|
||
unreadable — `Transcript::open` parses every line, so `launch` failed for those
|
||
sessions and `SessionManager::new` skipped them. On the phone that is a session
|
||
with no status, no history and nothing sendable: one unfamiliar word took down
|
||
every live conversation it appeared in.
|
||
|
||
Two rules now. `Indexed::parse_at` degrades a line it cannot make sense of to
|
||
`Event::Unreadable { kind }` rather than failing the file, keeping its seq —
|
||
which is what the cursors, the bisection and the next-seq counter are all
|
||
addressed by — and carrying the word the line called itself, so the reader is
|
||
told what they are missing rather than that something is. The seq is still
|
||
required: a line that cannot say where it sits is not one this file can hold,
|
||
and dropping it silently would hand out a seq the file already contains.
|
||
|
||
And a variant is **retired, not deleted**: kept deserializable, never
|
||
constructed, with the date and the reason on it. `Event::TaskNote` is the
|
||
example, and the phone folds it to no row — which is the point, since an
|
||
unreadable line correctly draws a placeholder and one per background task is
|
||
the wall the row was removed for.
|
||
|
||
### A limit a subagent hits is the session's (2026-09-06)
|
||
|
||
A background Task runs on long after its parent's turn ended, so **the account
|
||
running out while the main agent is idle is the ordinary shape of the problem
|
||
rather than an edge of it.** `translate_child` used to record everything a
|
||
subagent produced into the subagent's own transcript and return nothing, which
|
||
meant `Event::LimitReached` never reached the session — and the session is the
|
||
only thing `resume.rs` can schedule against. That session then waited for a
|
||
person for ever, with nothing anywhere saying so. The limit is hoisted now: it
|
||
goes into the subagent's transcript, where it happened, *and* out to the
|
||
session, which is what auto-resume needs.
|
||
|
||
### 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: Claude's Task helpers and Codex's collaboration threads
|
||
already speak their parent's event stream with a child identifier, 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.
|
||
|
||
Claude Code and Codex also keep their own durable transcript. The delete
|
||
dialog names that owner and can remove its copy too: Claude files are resolved
|
||
under `~/.claude/projects`, while a Codex thread id resolves only the matching
|
||
rollout under `~/.codex/sessions`. The provider-owned copy is deleted first, so
|
||
a remote-machine failure leaves the app session intact rather than reporting a
|
||
half-delete as success.
|
||
|
||
**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 **machine**
|
||
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 configured machine. 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 machine 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, machine, 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 **Machines** — 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.
|
||
- **A run of adjacent tool calls is one collapsed card, except for the
|
||
call still running and the last call in the transcript** (2026-09-15).
|
||
What the session is doing right now, or did last, is the one thing worth
|
||
seeing without opening anything, and a heading counting it hides it. What
|
||
folds a call back into its run is not finishing but being *overtaken*:
|
||
anything arriving behind it, a reply included, makes it history, and a
|
||
session that has run its last command and is composing its answer leaves
|
||
that command standing until the answer starts. Grouping is a display
|
||
decision (`groupToolRuns`) and a cut run's pieces are keyed there — the
|
||
first piece keeps the run's name, since that name is what survives a page
|
||
of history landing in front of it.
|
||
- **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.
|
||
- **The image viewer fits against the whole physical display, including the
|
||
system-bar regions** (2026-09-12). It hides the status and navigation bars
|
||
independently when the fitted, zoomed or panned image reaches them, and
|
||
restores either one when it does not. `100%` recenters at one bitmap pixel
|
||
per screen pixel; opening only shrinks an image that needs it to fit and
|
||
never enlarges a smaller one.
|
||
- **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: the record and the interruption (2026-08-30, revised
|
||
2026-09-15)
|
||
|
||
`GET /notifications` is one SSE stream of attention-wanting moments, and the
|
||
app decides how each one is said, 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 row in Android's drawer** for everything else, which is what the
|
||
foreground service exists for.
|
||
- **A banner over the app as well** 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. The drawer's row is posted **silently** in that case
|
||
(`setSilent`), because the banner has already done the interrupting.
|
||
|
||
The two are not two versions of one thing, which is why both go up. A banner
|
||
is six seconds long and reaches only somebody whose eyes were on the screen,
|
||
so it is what interrupts; a row waits however long it has to, so it is what
|
||
records. Until 2026-09-15 the banner suppressed the row outright, and a
|
||
notification that arrived while the phone was face-up on a desk left nothing
|
||
behind at all.
|
||
|
||
What keeps the drawer from filling up is the other end rather than
|
||
suppression: **opening a session clears whatever is posted about it**
|
||
(`NotificationService.showing`), whichever way the reader got there, because
|
||
opening it is reading the notification. Which case 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, and the Machines tab
|
||
can run that machine's CLI login without requiring an interactive SSH shell.
|
||
|
||
## 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).
|