The count beside the status said how much work was going and never what,
so "3 bg tasks" was a number with no way to find out what it was about.
Drivers now report the tasks themselves rather than a size:
`Driver::background_tasks` returns `Vec<BackgroundTask>` -- id, the
provider's own description, and a kind -- served by
`GET /sessions/{id}/background`. It is runtime state, never persisted,
and `null` is "nobody has said", which is what a session with no process
answers and what the panel says in words rather than drawing as an empty
list. `description` is optional because Codex names a background terminal
by a process id, and a number drawn as a name is worse than admitting
there is none.
Claude's `background_tasks_changed` entries turn out to be objects
carrying `task_id`, `task_type` and `description`, so each is read rather
than counted -- and an `ambient` one is now dropped from the list and the
count alike, on the CLI's own instruction: a live-update watcher is not
activity, and counting one left a session reading `waiting` with nothing
to wait for.
The phone draws them in the right-hand panel above the subagents,
collapsed to "2 bg tasks running" and pushing the subagents down when
opened. Both lists are items of one lazy column, so neither can run off
the panel, and the section is refetched whenever the live count moves --
a card for work that has finished is exactly the stale measurement the
count exists not to be.
Verified against the real Claude CLI (2.1.261): a backgrounded `sleep 120`
came back as `{"id":"br16327wr","description":"Sleep for 120 seconds",
"kind":"command"}`, and on the emulator against the echo rig the section
appeared, expanded, and dropped a card as its task finished.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1768 lines
110 KiB
Markdown
1768 lines
110 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; a machine's GGUFs and 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 the GGUFs on a machine with the
|
||
downloads putting them there. Addressed by transport and directory, never
|
||
by this process's own disk.
|
||
- `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.
|
||
- `Thinking { delta }` / `ThinkingDone { ms }` (2026-09-19) — the model's
|
||
working, streamed the way its reply is, and its own kind because it is not
|
||
what the session *said*: the phone draws it as a card of its own, shut, and
|
||
no driver folds it back into the next prompt. Only a provider that actually
|
||
streams its reasoning sends it — llama.cpp does, as `reasoning_content`;
|
||
nothing is inferred for one that does not, since a card that appeared
|
||
whenever a turn was slow would be a guess wearing a measurement's clothes.
|
||
The duration is **measured by the driver**, because a reader only knows when
|
||
an event arrived: the last fragment of a block followed by a slow tool call
|
||
is indistinguishable from thinking that went on that long. A block with no
|
||
`ThinkingDone` is one still being thought, which is what the card's spinner
|
||
says; one closed by the turn ending without a duration says "Thought" and
|
||
names no span rather than inventing one.
|
||
- `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 /
|
||
**loading** / **reading** / **waiting** / exited / unknown. `loading` is a
|
||
process that is up and cannot be spoken to yet (a model coming off disk);
|
||
`reading` (2026-09-19) is the model holding the prompt and not yet
|
||
answering, which on a long conversation is tens of seconds -- measured at
|
||
9.5s for 6,068 tokens and 22s for 14,068 on the 27B here. Reported as
|
||
`running` that was indistinguishable from a model thinking, which is the
|
||
thing the reader is actually waiting for. Both are working states: nothing
|
||
settles and nothing is invited. `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, tokensPerSecond, prefillMs }` — what a turn
|
||
cost, how much the model was holding when it ended, how fast it was
|
||
generated, and how long the provider spent reading the prompt first. The last
|
||
two (2026-09-19) are the provider's own measurements or nothing: llama.cpp
|
||
reports `timings.predicted_per_second` and `timings.prompt_ms`, and the
|
||
coding CLIs report neither, so dividing what this server watched a reply
|
||
arrive over would count the network, the tool calls and the reader's own
|
||
permission answers as generation. The phone draws both under the reply they
|
||
measured. `prefillMs` is small on a turn whose prompt the server still had
|
||
cached -- 22ms against 64s for the first turn after a model loads, measured
|
||
on the 0.6B -- which is a fact about the turn rather than a missing figure. `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 **machine**, in router mode, shared by every session on
|
||
it and reached over HTTP on a loopback port through the same `Transport` as
|
||
any other process. A session has no process of its own. 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.
|
||
- **The machine's server is shared, outlives this backend, and stops only
|
||
when somebody says so** (2026-09-19, `session/llama/router.rs`). It was one
|
||
`llama-server` per session until then, which meant two sessions on one model
|
||
held two copies of it in memory and a model change bought a load only the
|
||
session that asked for it benefited from. `llama-server` started with no
|
||
`-m` is a **router**: it reads a preset file naming models and their flags,
|
||
starts a child server per model that is asked for, and routes each request
|
||
by the `model` field in it. So "one server per model, with that model's own
|
||
settings" is what a machine runs, while this backend has one process, one
|
||
port and one record to keep track of.
|
||
- **The record is the same mechanism every other driver uses**, so a
|
||
restart adopts it: `process.json` in the router's own directory beside
|
||
the session directories. A session records the *same* pid in its own
|
||
directory as a `Detail::Shared`, which is what makes "is the thing I am
|
||
talking to still there?" one question with one answer — and `process::
|
||
stop` refuses to signal a `Shared` record, so stopping, deleting or
|
||
cleaning up after one session cannot take a model out of memory for
|
||
every other session on that machine. A flag would have been a rule to
|
||
remember in five places; the variant is checked in the one function that
|
||
signals.
|
||
- **Nothing unloads a model on its own.** The last session closing leaves
|
||
it loaded on purpose — the next session to want it would otherwise pay
|
||
the load again — so the memory is freed from the machine's provider
|
||
settings, where what it costs everybody is visible. `--models-max`
|
||
(default 1 here, editable) is the one automatic eviction, and it is LRU:
|
||
a machine with one GPU wants the second model to replace the first.
|
||
- **How a model is loaded belongs to the model, not to the session**
|
||
(`ProviderConfig::model_settings`, `LLAMA_MODEL_PARAMS`). Context size,
|
||
GPU layers, threads, slots, speculative decoding: one loaded copy answers
|
||
several sessions, so a session cannot own these without one of them being
|
||
silently ignored. They are written into the preset file as
|
||
`llama-server`'s own argument names, and saving them re-reads that file —
|
||
which **unloads** the model if it was loaded. That is the change taking
|
||
effect rather than a side effect, and the dialog says so before you save.
|
||
What stays the session's is everything that rides on a request:
|
||
temperature and the rest, thinking, the permission mode, and which tools
|
||
it offers.
|
||
- **The preset file lives on the machine that serves the models**, written
|
||
over the same transport (base64 through `sh`, so an INI value never
|
||
passes through quoting twice). It is read back before every edit rather
|
||
than remembered: a router adopted from a previous run is already serving
|
||
models whose sections this process has never seen, and rewriting the file
|
||
without them would unload them at the next re-read. Only a text that
|
||
actually differs is written, for the same reason.
|
||
- **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.
|
||
- **A llama session's thinking level is the model's, asked of the model**
|
||
(2026-09-19). Thinking effort is a chat-template argument rather than a
|
||
server flag, so it rides on the next request and changes nothing about the
|
||
loaded model -- which is why it is a `params` entry (`thinking`) and not the
|
||
`effort` a coding CLI reads at launch. The levels templates use disagree:
|
||
the 27B here takes `low`, `medium` and `xhigh` and **raises** on `high` and
|
||
`max`, so a fixed list would be a turn that fails on send. The driver asks
|
||
the loaded server instead (`thinking_options`): `chat_template_caps.
|
||
supports_reasoning_effort` says whether levels mean anything at all -- the
|
||
gate that stops the control silently doing nothing on a template that
|
||
ignores the argument -- and `/apply-template` says which of them render, one
|
||
cheap round trip each at load time. `off` is a separate question and a
|
||
separate argument (`enable_thinking: false`), taken as supported when
|
||
turning it off renders a different prompt. A level the loaded model cannot
|
||
take is dropped from the request and said out loud, naming what it does
|
||
take.
|
||
- **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}/providers/{p}/models`, that machine's list, rather than
|
||
anything about this backend's own disk. **Downloading to that machine was
|
||
deliberately not built until 2026-09-19** -- the objection was a
|
||
multi-gigabyte transfer with no progress anywhere -- and what changed is
|
||
that the transfer happens *on* that machine and reports progress: see
|
||
"Models" below.
|
||
- **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-router.log`, which on a
|
||
remote machine is the only copy anybody reading the phone can see — the
|
||
router's children write into it too, so a model that would not load says
|
||
why. A load is two questions since the router arrived: the router
|
||
answering at all (30s, because it loads nothing), and the model reaching
|
||
`loaded` in `GET /models` (300s, because it is disk). A model the router
|
||
reports `unloaded` with an exit code is a failure now rather than a wait.
|
||
- **Loading is a state of its own** (2026-09-19, `SessionStatus::Loading`).
|
||
A multi-gigabyte model takes tens of seconds to reach memory and refuses
|
||
everything until it has, and the session used to report `running` for that
|
||
whole time — indistinguishable from a model thinking, with the added
|
||
detail that any message sent meanwhile came back as an error. It is now
|
||
`loading` on both screens, and a message sent into a load **waits** for it
|
||
rather than failing. The waiting is the driver's (`Shared::await_ready`, a
|
||
condvar on a three-state `Serving`), because "there is a process and it is
|
||
not ready" is a fact only a driver can have. The third state matters as
|
||
much as the first two: a model that will never load has to answer a waiting
|
||
message with what went wrong rather than holding it for ever.
|
||
**Where it waits is the queue** (corrected 2026-09-20). It first waited
|
||
inside the turn, which recorded the message as *read* the moment it arrived
|
||
-- so the phone drew it as sent and answered nothing for the next minute,
|
||
and the turn then folded the conversation out of a transcript that by then
|
||
held that same message, sending it to the model **twice**. A message
|
||
arriving during a load now queues exactly as one arriving during a turn
|
||
does: drawn as waiting, takeable back, and opening the first turn when the
|
||
model arrives (`LlamaDriver::open_queued`, which is also what decides
|
||
whether the end of a load is `idle`). What reaches `await_ready` is now
|
||
only a turn whose model was changed under it. The conversation is read
|
||
*before* the message is announced, which is what makes "everything before
|
||
this message" true rather than a race against the pump.
|
||
- **Which tools a session offers is a filter here, not a flag there**
|
||
(2026-09-19). The router is always started with `--tools all` and hosts one
|
||
set of tools for the machine — one per session is not a thing a shared
|
||
server can have — so the `tools` param picks from the definitions this
|
||
backend sends with each request. It costs no reload, and it is worth
|
||
choosing: all seven are ~2,000 tokens of every prompt, measured at 2,181
|
||
against 698 with none, which on a small context window is the difference
|
||
between a usable session and one that overruns. `POST /tools` carries an
|
||
`x-tool-cwd` header, which is what lets one shared server run each
|
||
session's tools in that session's own directory.
|
||
- **The driver runs the agent loop, and therefore owns the permission gate**
|
||
(2026-09-19). `llama-server --tools all` *hosts* the built-in tools —
|
||
`GET /tools` is their definitions, `POST /tools` runs one — but it does not
|
||
drive a conversation: a completion comes back with tool calls in it and
|
||
stops. So the loop is here, which is what puts "may I run this?" somewhere
|
||
a phone can answer it. Two modes, `manual` and `bypassPermissions`, which
|
||
is what the mechanism actually has: llama.cpp's own web UI asks before
|
||
every call and remembers the tools you said "always" to, and a third mode
|
||
between them would have to invent a rule about which tools count as edits.
|
||
The allowances are folded out of the transcript's `Answered` events, like
|
||
everything else this driver remembers, which is why the answer carries the
|
||
tool's name in it.
|
||
- **Tools run where the model does; MCP runs here** (2026-09-19). The
|
||
built-in tools are the far machine's, for the same reason the model file
|
||
is — they act on that machine's disk. An MCP server is reached from *this*
|
||
backend instead (`session/llama/mcp.rs`), which is both what llama.cpp's
|
||
own web UI does (it connects to `https://mcp.exa.ai/mcp` from the browser)
|
||
and the right side to be on: a web search wants the machine with a route
|
||
out, not the machine with the GPU. `llama-server`'s own `--mcp-servers-json`
|
||
is deliberately not used — it can only spawn local commands, so a remote
|
||
server would mean a Node bridge on whichever machine serves the model.
|
||
- **A model change asks for another model rather than being refused**
|
||
(2026-09-19). Nothing is stopped: the machine's server holds whichever
|
||
models it has been asked for, and the one this session is leaving may be
|
||
somebody else's. It costs a load where nobody had that model open and a
|
||
round trip where somebody did. The conversation survives either way because
|
||
it was never in the server. What is lost is the prompt cache, which is
|
||
exactly what the phone already warns about before a switch.
|
||
- **One slot by default, and the draft head where the file has one**
|
||
(measured 2026-09-19). `parallel = 1` unless that model is told otherwise:
|
||
a session is one conversation making one request at a time, so the other
|
||
three slots `llama-server` picks on its own are context nobody asked for.
|
||
Sharing one server makes the number a real choice — a second session's turn
|
||
waits behind the first at one slot — which is why it is a per-model setting
|
||
rather than a constant, with the trade-off measured below. It is also what
|
||
decides whether
|
||
multi-token prediction pays — on the 27B here, **41.5 tok/s** plain at any
|
||
slot count, **61.4** with `--spec-type draft-mtp` at one slot, and **28**
|
||
with the head at four. Speculating against a split KV cache is worse than
|
||
not speculating, and it reads exactly like the head being broken.
|
||
The flag is conditional because it must be: asked for on a model without a
|
||
head, `llama-server` exits. `crate::gguf::has_mtp_head` reads the answer out
|
||
of the file — on the machine that will serve it, in the round trip the spawn
|
||
was already making — and `params["speculative"] = "off"` is the way out.
|
||
|
||
### Models (2026-08-28, rebuilt per machine 2026-09-19)
|
||
|
||
- **A download belongs to the model, not to the request.** Keyed by
|
||
`owner/repo/file.gguf`, so a second device can watch one it did not start
|
||
and an hour-long fetch survives a locked screen. Asking for one already
|
||
going joins it rather than starting a second writer.
|
||
- **A download runs on the machine that will serve the file** (2026-09-19),
|
||
because that is where `llama-server` has to read it from -- so it is a
|
||
detached `curl` started by a script this backend writes over the same
|
||
transport everything else about a machine goes through. What this replaced
|
||
was a fetch onto the backend's own disk, offered under a Models tab, which
|
||
could not put a model on any other machine at all.
|
||
- **Its state is a file beside the partial**, `x.gguf.download`, holding the
|
||
worker's pid, the sha it is downloading against, the published size and a
|
||
state word. Nothing about a run is held in this process, which is what
|
||
makes it survive a backend restart, and what lets the answer be read off
|
||
the disk that has the file rather than remembered about it.
|
||
- **Progress is measured**, never estimated: the bytes are `wc -c` of the
|
||
partial and the total is the size HuggingFace published, absent when it
|
||
published none. A run whose pid is gone is reported failed rather than
|
||
left saying "running" for ever -- `kill -0` at each listing is the check --
|
||
and there is no `finished` state, because a download that finished is a
|
||
model and is in the list beside the ones still going.
|
||
- **Resume is guarded by identity, not by hope.** The state file records the
|
||
sha256 the partial is a piece of, and a partial written against a different
|
||
one is deleted rather than resumed onto. The same hash is checked, on that
|
||
machine, before the file takes its real name. The earlier ETag scheme went
|
||
with the local fetch; `If-Range` was never usable, since HuggingFace's CDN
|
||
ignores it (probed 2026-08-28).
|
||
- 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.
|
||
|
||
### A llama session steers at its own tool boundary (2026-09-19)
|
||
|
||
A message typed into a running llama session is handed to the model at that
|
||
turn's **next tool call**, in the request the driver is about to build
|
||
(`session/llama/mod.rs`'s `take_steers`), rather than waiting for the turn to
|
||
end and opening one of its own. Before this it waited: a turn that spent two
|
||
minutes on a chain of tool calls read nothing sent during it, which is the
|
||
one moment steering is for.
|
||
|
||
This is the same landing place as the Claude CLI's, reached the other way
|
||
round. Claude's steer is written into stdin on arrival and *the CLI* decides
|
||
it lands at the next model call; here the loop is ours, so nothing is handed
|
||
over until the boundary is reached. Two things follow that the CLI cannot
|
||
offer: a waiting message can still be taken back right up to the moment it is
|
||
read, so `Driver::unqueue` keeps answering `Dropped` rather than
|
||
`AlreadySent`; and an interrupted turn deliberately takes nothing, leaving
|
||
the message in the queue to open the next turn, because a request that is not
|
||
going out must not record a message as read.
|
||
|
||
A turn with no tool call left still has no boundary to interject at -- the
|
||
server is generating until it returns -- so such a message opens the next
|
||
turn as it always did. The `STEERING_NOTE` is on the model's copy only, so a
|
||
steer is folded back out of the transcript as the words that were typed, and
|
||
`a_steer_taken_mid_turn_folds_back_between_the_two_replies` is what keeps the
|
||
fold matching what the turn built: the prompt cache depends on the next turn
|
||
rendering this one byte for byte.
|
||
|
||
### 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.
|
||
The compact bar names no window (2026-09-19): the provider's own name for it
|
||
("5-hour window") became a denominator after the span instead — "42% · 3h 20m
|
||
left / 5h" — which says in one reading both how much of the cycle is to come
|
||
and which cycle it is. Where the provider reported no duration there is
|
||
nothing after the span, since the name it gave is not a measurement of one.
|
||
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 measured 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.
|
||
|
||
**They are listed in the session's panel, above its subagents** (2026-09-20).
|
||
The count beside the status says how much is going and never what, which left
|
||
"3 bg tasks" as a number with no way to find out what it was about. The list is
|
||
`GET /sessions/{id}/background`: whatever the driver says right now, never
|
||
written to a transcript and never kept here, because it is a measurement of a
|
||
running process and a session with none has nothing to report -- that route
|
||
answers `null` for one, which the panel says in words rather than drawing as an
|
||
empty list. Each entry is an id the reader never sees, the provider's own
|
||
description, and a kind; `description` is optional because Codex names a
|
||
background terminal by a process id, and a card with nothing to say says the
|
||
kind instead of dressing the number up as a name. The section is collapsed to
|
||
its one-line count by default and pushes the subagents down when opened, and it
|
||
is refetched whenever the live `backgroundTasks` count moves, since a card for
|
||
work that has finished is exactly the stale measurement the count was designed
|
||
not to be. A backgrounded subagent appears in both lists: the background one
|
||
because it is running, the subagent one because it has a transcript -- and only
|
||
the subagent card opens, because only it has anything to open.
|
||
|
||
**An `ambient` task is not background work.** The same level signal carries
|
||
live-update watchers and housekeeping marked `ambient`, which the CLI says
|
||
outright to exclude from activity indicators; counted, they would leave a
|
||
session reading `waiting` with nothing to wait for. They are dropped from the
|
||
count and the list alike.
|
||
|
||
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.
|
||
|
||
Codex's collaboration lifecycle supplies the subagent half of the same fact:
|
||
the registry counts its open child threads, including ones found on disk after
|
||
adoption. App-server's experimental `thread/backgroundTerminals/list` supplies
|
||
the command half as an authoritative set of process ids. The driver keeps that
|
||
set only in memory, refreshes it at terminal lifecycle boundaries and once a
|
||
second while nonempty, and adds its size to the open-child count. A response
|
||
for an unknown request cannot alter the set, and a lifecycle edge never
|
||
blindly decrements it. Starting a replacement app-server resets the command set
|
||
to zero; adopting one asks it for a fresh snapshot. Every total change emits
|
||
the same `backgroundTasks` event, and the UI does not infer a count from tool
|
||
cards.
|
||
|
||
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.
|
||
|
||
**The phone presents them beside their open session, not inside the main
|
||
session list** (changed 2026-09-17). A left swipe pulls a panel over the live
|
||
transcript; the transcript remains composed beneath it so opening the panel or
|
||
a child's read-only transcript does not stop its stream, discard its draft or
|
||
lose its scroll position. Horizontal transcript content keeps first claim on
|
||
the gesture; collapsing it or starting on the ordinary session surface gives
|
||
the gesture back to the panel, while Android keeps its own edge Back gesture.
|
||
This is also the intended home for background work once that has a list of its
|
||
own; only subagents are shown there now.
|
||
|
||
### The main screen is the other panel over a session (2026-09-19)
|
||
|
||
**A right swipe pulls the main screen over the open session**, the mirror of
|
||
the subagent panel, and for the same reason: switching conversation was a step
|
||
back to the list and a step down into another, which disposed the session
|
||
being left and refetched its transcript over the tunnel on the way back. The
|
||
session under the panel stays composed, so swiping it back off returns to a
|
||
live stream, an unsent draft and the scroll position it had.
|
||
|
||
**It is `MainScreen` itself and the whole width of the screen** (`MainPanel.kt`),
|
||
not a list of its own: what the swipe gets is the screen Back would have got,
|
||
moved over the session instead of replacing it — tabs, title, refresh and
|
||
settings included. A panel narrower than the screen leaves a sliver saying
|
||
what it is over, which is right for the subagents and wrong here, and a
|
||
full-width panel takes no tonal step either, since a screen standing in for
|
||
another must be the same colour as it.
|
||
|
||
**One gesture drives both panels** (`SidePanels.kt`, which is now where the
|
||
subagent panel's drag and animation live). Two `draggable` modifiers over the
|
||
same content cannot share a horizontal drag — the inner one claims it
|
||
whichever way the finger went and the outer never sees a thing — so the
|
||
position is a single signed reveal, negative for the left panel and positive
|
||
for the right, which also makes it impossible to have both open. A side with
|
||
no panel cannot be dragged toward at all, and that is what makes the gesture
|
||
absent until a session has been opened: the main panel exists only inside the
|
||
session screen, and nothing on the main screen swipes anywhere.
|
||
|
||
Tapping the session already open is the same act as swiping the panel back off
|
||
— reopening it would hand `SessionScreen` a new summary for the conversation
|
||
it is already showing. Tapping any other session is a screen of its own, so
|
||
the panel and the session under it go with it. Deleting the session the panel
|
||
is drawn over is the one thing the list can do that leaves nothing to swipe
|
||
back into, so that closes the screen (`onDeleted`, reported by the list to
|
||
whoever is showing it elsewhere).
|
||
|
||
**The list keeps its rows while it asks again.** The panel refetches every
|
||
time it opens, and blanking the list for each of those handed the reader an
|
||
empty screen about something never in doubt; a bar over the top says an answer
|
||
is outstanding, and only a first load with nothing to keep shows the spinner.
|
||
|
||
Where the panel has got to is read from draw lambdas only, never from the
|
||
composable body: it changes every frame of a drag, and a body that reads it
|
||
recomposes the session beneath once per frame. Composition sees booleans that
|
||
change twice per gesture — the same correction the keyboard inset needed in
|
||
`SessionScreen`.
|
||
|
||
### 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.
|
||
**A session with a process stays where it is, and the order is when each
|
||
agent was turned on** (2026-09-15, replacing the awaiting-answer inbox
|
||
sort): the running sessions come first, oldest start first, so one that is
|
||
started joins the bottom of that group and nothing it goes on to do —
|
||
beginning a turn, finishing one, asking a question — can move it. A list
|
||
that reorders itself is one nobody can keep their place in, and the status
|
||
word and its colour already say which session wants an answer without the
|
||
row having to move to say it. Stopped sessions are a group below, most
|
||
recently active first; "turned on" is what the order above is made of and a
|
||
session with no process has no place in it.
|
||
The order is the server's (`SessionConfig::started`, reported as
|
||
`started`), written each time a process is started for the session, so it
|
||
is the same on every device and survives a backend restart — which adopts
|
||
processes rather than starting them, and so could not work the times out
|
||
for itself. Applied on the phone (`sessionsInListOrder`), because
|
||
presentation order is a display decision.
|
||
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. **Machines** — adding, renaming, re-probing and removing machines, and
|
||
what each one can run.
|
||
**A machine's models are that machine's**, so browsing and downloading
|
||
GGUFs lives inside its llama.cpp provider rather than in a tab of its own
|
||
(2026-09-19, `MachineModels.kt`). A "Models" tab was a claim that there is
|
||
one such set; there is one per machine, and the screen deciding how a
|
||
model is loaded is the screen that should be able to fetch one. A download
|
||
in flight is a card above the models, with a measured bar, and it keeps
|
||
going when the app is closed because it is a process on that machine.
|
||
**A provider's card is a card**: bordered against the machine's own card
|
||
rather than tinted a step away from it, since two adjacent surfaces render
|
||
as one flat block, and with no chevron — a card that reads as a card does
|
||
not need an arrow to say it opens.
|
||
**A provider is a card that opens** (2026-09-19, `ProviderScreen.kt`).
|
||
Settings that belong to a *machine* had nowhere to live until one
|
||
`llama-server` came to serve every session on one: how each of its models
|
||
is loaded, how many it keeps in memory, and the only control that takes a
|
||
loaded model out of memory again. So each of a machine's providers is a
|
||
card, and tapping it opens that provider on that machine. What it shows is
|
||
fetched rather than carried from the card, because a stale copy of it
|
||
would be a second version of the same truth.
|
||
**Stop is shown and disabled rather than hidden**, with the confirmation
|
||
saying plainly what it costs: every model unloaded, every session on that
|
||
machine showing as exited, and the next message to one paying the load
|
||
again. Hiding the destructive option would not prevent the outcome, only
|
||
move it somewhere with no warning attached.
|
||
**A model with no status line is one nobody could ask about** — the server
|
||
is not running — rather than one that is unloaded. The two are different
|
||
facts and only one of them was measured.
|
||
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 a call
|
||
that is still running, last in the transcript, or held out of its run by
|
||
being read** (2026-09-15, corrected 2026-09-16). What the session is
|
||
doing right now, or did last, is the one thing worth seeing without a
|
||
heading hiding it. What folds a finished call back into its run is being
|
||
*overtaken*: anything arriving behind it, a reply included, makes it
|
||
history — except while somebody has it open, since a card being read is
|
||
not history to them, and a command finishing behind it used to shut it
|
||
and fold it away mid-sentence.
|
||
**Being open only ever holds a call out; it never takes one back out of
|
||
a group it is already in.** That was tried for a day and is what
|
||
"collapsing jumps" was: grouping gives a row its identity, so a rule
|
||
reading the open set both ways let one tap rebuild the rows around the
|
||
finger — closing a call replaced three rows with one, and no anchor
|
||
survives a row that has ceased to exist. A call inside an open group is
|
||
visible where it is and has nothing to gain by moving. The screen is
|
||
what remembers which calls have been in a group (`everGrouped` feeding
|
||
`heldOut`), because that is a fact about what the reader has been shown
|
||
rather than about the transcript. Grouping is otherwise 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.
|
||
- **The model's working is a card of its own** (2026-09-19) — "Thinking"
|
||
with the same spinner a running command has while it goes, and "Thought
|
||
for 12.4s" once it is over. Deliberately not a tool call, because a run
|
||
of tool calls collapses into "Called 6 tools" and the reasoning would be
|
||
filed as one of them; it therefore also breaks a run, which is right —
|
||
the model stopped to think in the middle of it. A block cut by a page
|
||
boundary is welded like a reply is (`healSplitThinking`), since the half
|
||
with no ending would otherwise spin for the rest of the conversation.
|
||
- **A finished reply carries a line under it saying what it cost to produce
|
||
and when it was sent** (2026-09-19) — "read 9.5s · 50.3 tok/s · 3:00 PM",
|
||
small and set back, right-aligned because it closes the message rather
|
||
than opening one. **The clock is last**, so it sits against the right edge
|
||
whatever else is on the line: the measurements in front of it belong to
|
||
the provider, and a reader who has learned where the time is should not
|
||
have to find it again because the session is on a different one. The time
|
||
is the transcript's own timestamp, so every device draws the same one; the
|
||
costs are the provider's own figures or nothing at all. It is a list unit
|
||
of its own (`ReplyFoot`), because a settled reply *is* its blocks and
|
||
there is no row left to hang it on.
|
||
- **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,
|
||
a thinking block.
|
||
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.
|
||
- **Opening keeps still the edge nearest the tap; closing lands the closed
|
||
thing centred on the tap itself** (2026-09-16), and the *thing* is
|
||
whatever the reader pressed: the row, or the one call inside an open
|
||
group. A row about to open is small, so both its edges are within a
|
||
heading's height of the finger and the wanted one is the edge pressed:
|
||
touch the upper half and the top edge holds, so it opens downwards;
|
||
touch the lower half and the bottom edge holds, as the list does by
|
||
default. A row about to close leaves a heading where a screenful of card
|
||
was, and the place that heading belongs is under the finger that shut it
|
||
— a card taller than the screen settles it, since both of its edges can
|
||
be a screen's length from the hand. Holding a *share* of the card's
|
||
height was the same miss in miniature, and holding the group's top edge
|
||
when a call inside it was shut was the same miss again: the card
|
||
appeared to collapse into its own top, a long way from the hand. Where
|
||
there is not enough conversation on the far side to scroll, it lands as
|
||
close as the list can put it.
|
||
A call is not a row, so the scroll is asked for against the group and
|
||
aimed at the call: `ToolGroup` reports how far down its own top edge the
|
||
call is drawn and how tall that card is (`onToolToggle`), which is the
|
||
part only it knows, and the calls above the one toggled do not move, so
|
||
shifting the group by the difference puts the call where the finger
|
||
wants it. A call opened in its lower half asks for nothing at all, which
|
||
is the same answer a row gets: the list holds the group's bottom edge, a
|
||
Column holds the calls below the one growing at their distance from it,
|
||
and so the growth comes off the call's top. Asking for the group's top
|
||
in every case — which it did for a day — was an open pressed at the
|
||
card's foot driving it downward instead.
|
||
**The scroll is asked for in the gesture, not from the layout that
|
||
discovers the new height**, and that is what makes it invisible: a
|
||
request made at the tap is consumed by the same measure pass that first
|
||
lays the row out at its new size, so the resize is drawn once, in its
|
||
right place. Everything else was tried and is a frame late — a request
|
||
from the placement phase is never picked up by another measure and does
|
||
nothing at all; one from the measure phase lands on the *next* frame,
|
||
with the uncorrected one drawn first; and correcting by the error each
|
||
pass can see is that again, once per pass. Nothing it asks for needs the
|
||
new height, which is what lets it be asked for before that height
|
||
exists: a top edge holds by placing the item *above* the row where it
|
||
already is, since that item's bottom edge is the row's top edge whatever
|
||
becomes of the row, and it does not have to be composed for the list to
|
||
place it. A close places the row against the height it had at the moment
|
||
it was opened — what the reader is shutting is the card they opened, so
|
||
that is the height it is going back to (`closedHeights`,
|
||
`closedCallHeights`).
|
||
- **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.
|
||
- **What a provider's settings are is declared by the server** (2026-09-19,
|
||
`DriverKind::params`). A spec is a key, words, a shape and whether a change
|
||
waits for a restart; the phone renders whatever arrives, so a driver that
|
||
grows a setting gets a control with no app change. The reason it is
|
||
declared rather than drawn is not tidiness: several `llama-server` flags
|
||
were hardcoded to what measured best on one machine, which is right as a
|
||
default and wrong as a constant — the next machine has a different GPU, and
|
||
nobody running this app can edit the source. `POST /sessions/{id}/params`
|
||
takes the whole map, so an absent key *is* the instruction to unset.
|
||
- **The context figure has a denominator where one can be measured**
|
||
(2026-09-19). `Event::ContextWindow` carries it, read from `llama-server`'s
|
||
`/props` once the model is up — the measurement rather than the request,
|
||
since a session that named no context size gets the model's own. Neither
|
||
coding CLI states its window, so those keep the bare figure: "2,042" and
|
||
"2,042 / 8,192" are deliberately different-looking, and a missing ceiling is
|
||
never drawn as a proportion of an assumed one.
|
||
The numerator was also wrong, by the length of the last reply: it was the
|
||
prompt alone, so a five-word answer reported 2,042 against a slot holding
|
||
2,355. It is the turn's total now, which matches `llama-server`'s own
|
||
`n_tokens` exactly.
|
||
- **MCP servers are configured in `config.ron`, not from the phone**
|
||
(2026-09-19). `mcpServers` on a llama provider, with Exa preset on a newly
|
||
discovered one. A phone screen for them is the obvious next step and was
|
||
deliberately left out of the change that added them.
|
||
- **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).
|