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