Commit Graph
23 Commits
Author SHA1 Message Date
iris dcb158ee44 Fetch the transcript instead of replaying it one event at a time
**The five seconds of loading top-down.** Opening a session subscribed to
the event stream from sequence zero, so the backlog arrived as one SSE
frame per event -- 864 of them for an imported conversation, rendered as
they landed. That is not a slow list; it is a conversation being replayed
at network speed, and it looks like loading from the top because it is.

The newest page now comes as one request, and the stream starts from where
that page ended, carrying live events only -- which is what a stream is
good at. Scrolling back fetches the page before it, so history costs
something only when somebody actually reads it. 80 events instead of 864,
and the first frame is already the end of the conversation.

I had called this fixed after anchoring the list at the bottom, on the
strength of an emulator on the same machine as the server. That test could
not have shown the problem: the whole backlog arrived in one frame's worth
of time over loopback. Iris's phone, over a tunnel, took five seconds.

**Send disappearing while running.** It was never conditional -- the row
simply ran out of width. A Row hands out intrinsic widths in order and
clips the overflow, so when Stop appeared the pickers I had added pushed
Send off the screen: the app's central control, gone at exactly the moment
the app is most in use. The settings now share what is left after the
actions have taken what they need.

While a turn is in flight the button says **Queue**, because that is what
sending then does -- the message is injected at the next tool boundary
rather than starting a turn of its own. The backend has always done this;
the button was describing something else.

**And the model picker no longer dismisses the keyboard**, which it did by
taking focus. Changing the model mid-sentence is an aside, not a departure
from what you were typing.

Verified on the 864-event import: at the newest message within a second,
history paging back continuously past the first page, and Stop beside
Queue while running.
2026-08-28 23:12:34 -04:00
iris 3f8805a610 Say which kind of delete this is, and stop offering what is already open
**Deleting was one word for two different acts.** An imported session's
real transcript belongs to Claude Code and outlives anything this app
does, so removing it here undoes a view. A session started here has no
copy anywhere, and removing it ends the conversation. The dialog warned
"this can't be undone" of both, which makes the warning worthless on the
one where it is true -- and frightening on the one where it is not, since
what it actually deletes is a cache of a conversation still sitting on
the machine.

Sessions now report whether they were imported, and the dialog says which
act this is. No new mechanism: the soft delete already existed, it was
just indistinguishable from the hard one.

**And a session already open here is no longer offered for import.**
Importing one twice would leave two `--resume` processes appending to the
same transcript, each seeing the other's writes as work done elsewhere and
replaying them -- both sessions then showing a conversation neither is
having. The route refuses it as well, so the rule holds for anything not
going through the app.

Left out of the list rather than shown and disabled. The usual argument
says absence is ambiguous, and it is wrong here: an imported session has
not disappeared, it has moved to the session list, which is where it now
belongs. Absence means "already somewhere you can reach it". Deleting the
app's copy puts it straight back -- verified: 68 offered, 67 after
importing one, 68 again after the soft delete, which is also the clearest
demonstration that a soft delete keeps the conversation.
2026-08-28 22:53:42 -04:00
iris a9ea84c96c Stop choosing a model, and keep an imported session up to date
**Why the model became fable.** `spawn_session` fell back to the
provider's first listed model when none was given. That list is a shortcut
for the spawn screen, written in whatever order somebody typed it, and its
first entry is `fable` -- so every session spawned without a model, which
is every import, silently became a fable session. It looked like a default
and was an artefact of list order. Absent now means absent: no `--model`
flag, and the CLI uses whatever the person configured for themselves.

**Model and permission mode are now visible and changeable** from the
session, as buttons that read as their current value rather than labels
beside one. The mode was spawn-only; the CLI turns out to accept
`control_request{subtype:set_permission_mode}` and echo the mode back,
probed against 2.1.237 the same way the rest of the protocol record was.
Both default to `auto` -- on a phone every ask is a round trip to a
question card, which is how "allow Bash?" became the most-answered
question in the app.

The mode is reported by the API so the picker shows what the session is
actually set to, and it is kept in the live session beside the model for
the reason the model already was: `meta` is the shape a session was
*launched* with, so reporting from it shows the value a change replaced.

**And an imported session keeps itself level with its source file**, so
work done at a terminal arrives without a button. `--resume` appends to
the same transcript rather than forking -- measured, not assumed -- so the
only hard question is which new lines came from here.

Answered by counting the events this session has recorded. Status is the
obvious signal and is wrong, which cost a round trip to find: a turn that
starts and finishes between two polls reads as idle at both, so its output
is replayed on top of itself. It showed up on screen as `donedone`, and
only because the reply was one word -- with a longer answer it would have
looked like the model repeating itself.

Verified against both halves: text appended to the source file the way a
terminal writes it appears within one interval, and a message sent through
the app appears exactly once, before and after a turn.
2026-08-28 22:44:41 -04:00
iris 233689ced6 Name a session in the import list, and let one be deleted
Three things about finding a session in a list of seventy, and one about
getting rid of it.

**A name beats anything inferred.** `/rename` appends a `custom-title`
record, so if somebody has said what a session is, that is the row. Eleven
of the seventy here turned out to be named already and none of it showed.

**Otherwise the last thing said, not the first.** The question this list
answers is "which one was I just in", and a session's opening line is the
least distinctive thing about it -- several of these begin with the same
slash command.

Finding that last message took three tries, and the two wrong ones are
worth recording because they failed in opposite directions. Grepping the
user record type caught tool results, which are *also* user records -- so
a session that ended mid-tool showed a tail of empty records and a row
saying nothing was said, when plenty had been. Narrowing to a string
`content` fixed those two and broke twenty others, because a message
carrying an attachment stores its text in a list. Excluding `tool_use_id`
keeps both shapes of a real message and drops the one that is not: seven
rows still have nothing to show, and those are sessions that really are
empty.

**Sorted by when it was last used**, and the time is on the row. Naming
was tried as the first sort key and is a worse list -- it buries what
somebody was just doing under everything they ever named. A name is for
recognising a row, not for ordering it, so it stays as the title and as a
word beside it.

**And a session can be deleted**, which is asked before it is done. The
transcript *is* the session, so this ends any chance of resuming that
conversation, and the dialog says exactly that rather than "are you
sure?". Deletion resolves the id against what the machine reported, like
importing, so no path crosses the wire in either direction.

Looked at on the emulator, including the dialog -- which is where the
delete button turned out to be missing entirely after a patch that
compiled fine, and where the row layout got its second look.
2026-08-28 22:08:43 -04:00
iris 6bbc829a3e Import a Claude Code session the machine already has
Claude Code keeps every session as JSONL under `~/.claude/projects/`, and
the CLI continues one with `--resume <id>`. `claude.rs` already resumes
whenever it finds a resume token in the session directory, for crash
recovery -- so importing is that same path with the token written before
the driver starts, and there is deliberately no second way to begin a
session. The seed goes through `launch` with the ordinary spawn, so the
driver never learns which kind it got.

Two things the machine answers and the phone does not.

**Which sessions exist.** One command per setup rather than one per file,
for the reason discovery already gives: over ssh each would be its own
connection. Titles come from the first few user records rather than the
first, because a session opens with records the CLI injected -- slash
commands, caveats around local command output -- which are stored as
ordinary user records without the meta flag, so titling by "first user
record" produced a list where most rows read `<command-name>/clear`.

**Which file an id names.** The phone sends an id and never a path; the
server looks it up again among the sessions it enumerated. An enrolled
token must not be able to turn a spawn into "read me this file", which is
the same rule that keeps a provider's command out of `POST /setups`.

Only the tail is replayed. The imported conversation is for reading --
continuing it is the CLI's job, and it reads the whole file itself -- so
this is a display budget, and it has to be one: the session this was
written in is 39 MB, and all of it would otherwise cross a tunnel to a
phone.

A recorded working directory can outlive itself, which this found
immediately: every session from before the checkouts moved to `~/repos`
still records `~/host/repos/...`. Resuming into one fails at `cd` before
the CLI starts -- a confusing way to meet a feature whose promise is
"carry on where you left off" -- so the directory is checked, and a
missing one is dropped with a log line naming it rather than being passed
on to fail.

Verified against this very session: 905 events replayed from the tail
(351 tool calls, 350 results, 185 assistant messages, 19 mine), the resume
token pointing at its id, and the stale directory reported and dropped.
The list was read on the emulator, where the top row is that session under
its opening sentence.
2026-08-28 21:45:14 -04:00
iris 2a1bc84c1e Expand a leading ~, and say what the failing command said
Three things, two of which are the same failure seen from opposite ends.

**A working directory of `~/repos/ai-app` never worked.** Everything
crossing to the remote side is single-quoted, which is right for paths,
model names and prompts alike -- unquoted they would be shell syntax
rather than data. It is wrong for exactly one character: `~` means "expand
me", and quoting is what stops expansion. So the remote shell was handed
the literal four-character directory `~` and correctly said it did not
exist, which reads as the path being wrong rather than the quoting.

Paths now go through `quote_path`, which emits `"$HOME"` for a leading
`~/` and single-quotes the rest. The variable expands, the expansion is
not re-split or globbed because it is double-quoted, and nothing after it
gains a meaning -- there is a test that pushes a quote-and-semicolon
injection through the tilde branch and gets back one absurd path rather
than three commands. `$HOME` is set by every shell this can land in, so
this does not depend on the remote side being POSIX; verified by running
the generated script under both sh and fish, which is what the dev VM
actually uses.

**The phone could not have told you any of that.** The exit report kept
the last line of stderr, and a shell's error message ends with a blank
line -- so the last line was empty, the report was a bare exit status, and
the seven lines of fish complaining sat in the server's log where nobody
holding a phone is looking. It now keeps the last 50 lines in a ring and
reports them with blank lines trimmed from both ends. The tests use the
real fish `cd` failure as their fixture.

**The status bar was unreadable.** `isAppearanceLightStatusBars` was
hardcoded to `true` -- dark icons -- which was right against the default
light surface and wrong the moment the app wore Mocha. It now asks the
scheme's own background for its luminance, so changing the palette cannot
reintroduce it.

**And the address field takes `user@host:port`.** One field rather than
two, because that is how an address is written everywhere else and a port
that is nearly always 22 does not deserve its own box on a phone keyboard.
Absent means absent rather than 22: the backend already decides that
default, and writing it here would be a second answer in a second place.
A colon only means "port" when it can -- brackets for IPv6 as ssh writes
them, otherwise exactly one colon followed by digits.

Looked at on the emulator: the status bar, and the form, whose label I
then shortened because it wrapped onto a second line and made that field
taller than the two beside it.
2026-08-28 21:05:17 -04:00
iris 4370c467ca Discover this machine's providers instead of asserting them
A fresh install wrote a `claude-cli` provider into the local setup
unconditionally. Nothing looked for `claude`; the list was hardcoded in
`Config::seed`, so on any machine without it -- which is every machine but
the dev VM -- the phone was offered a provider that cannot spawn, stated
with exactly the confidence of one that had been checked.

Discovery already existed and was already right: `setups::discover` probes
with `command -v` over the transport, includes echo for the local one
because it runs in-process, and records the resolved path rather than the
bare name. Only the local setup skipped it, which is the one place the
answer felt obvious enough not to ask.

So `seed` now takes the providers it is given, and seeding asks this
machine the same question it asks any other. It moved out of
`SessionManager::new` into an awaited step in main, because asking is I/O
and a constructor that quietly spawns a subprocess surprises every caller.
A discovery that fails seeds `echo` alone and says so, since echo is true
wherever this server runs -- falling back to the hardcoded list would be
the same bug with an extra step.

The test that covered this agreed with the bug, because both were written
from the same assumption: it asserted the seed contains `claude-cli`. It
now asserts the opposite -- that the seed invents nothing -- and the
session tests seed echo explicitly rather than relying on a constructor
that would make them pass or fail on whether `claude` happens to be
installed on whoever runs them.

Verified by running a server on a PATH holding only `sh`: it seeds `echo`
alone. With claude and llama-server present it finds both. 34 tests.
2026-08-28 20:15:44 -04:00
iris aa05ff9336 Take the link from wg-app-link instead of keeping a second copy
The five modules underneath this backend that were never about AI
sessions -- the pinned CA and leaf, QR enrollment and the bearer token,
wg0 binding and the certificate's SANs, owner-only files, and the RON
house rules -- were written twice, once here and once in dev-updater,
and had drifted. They now come from the submodule, as a path dependency
so both projects stay locked to one commit.

What stayed is what makes this project itself: the routes, the drivers,
the config schema, and the auth middleware, which is generic over this
server's state. Sharing a transport is worth doing; sharing an API would
mean inventing a vocabulary neither project wants.

Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs
are no longer named here at all -- and the three that remain are now
described by what still uses them rather than by what used to.

Verified by running it, not only by building: a fresh server generates
its CA, prints an `aiapp://enroll` QR with the scheme now passed as a
parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf,
answers an enrolled token and returns 401 without one, and writes
config.ron in the house rules with every file owner-only. 36 tests pass,
clippy is silent, rustfmt is clean.
2026-08-28 17:14:33 -04:00
irisandClaude Opus 5 6187958de3 Let a llama session actually be started from the phone
The driver worked and the models could be downloaded, but the spawn screen
had no idea llama.cpp existed: the model field and every extra setting were
gated behind `isClaude`, so a llama provider offered nothing, `model`
arrived null, and the driver refused with "a llama.cpp session needs a
model". The feature was reachable only by curl, which is not what was asked
for.

A llama provider now gets the models this backend has downloaded, as a
picker rather than free text -- there is nothing sensible to type, and a
name that is not on disk is a session that cannot start. Context size and
temperature are there too, blank meaning llama.cpp's own default rather
than a zero. Spawn stays disabled until a model is chosen, because without
one the button could only fail.

**Two bugs that only appeared by pressing the button**, both mine, both
from changing the server without re-driving the app:

- The app sent the setup's *label* where the server had started resolving
  by *id*. The failure was almost self-diagnosing -- `no setup named "this
  machine" -- configured: this machine` -- and that message now says "no
  setup with id" and lists ids, since listing labels was what made it read
  as a contradiction.
- The session header showed `on local`, the id, because the app read
  `setup` where the server had begun sending both `setup` (id) and
  `setupName` (label). The app now carries only the label: nothing in it
  addresses a setup, and holding both is what let it show the wrong one.

Verified by doing it: rediscovered the local machine from the phone so
`local-llama` appeared, spawned a session on Qwen3-0.6B-Q8_0 with a 4096
context, sent "Reply with exactly one word: ready", and it replied "ready"
with 125 tokens counted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 13:38:00 -04:00
irisandClaude Opus 5 19e3531c5d Add, rename and remove machines from the phone -- without letting it name commands
The gap PLAN.md recorded: setups were readable but only hand-editable, so
adding a machine meant a shell on the backend.

**The design decision, made with Bryan, is that the phone never composes a
command.** A setup carries providers, and a provider carries something to
run -- so a route that accepted a command from the request body would make
the enrolled token arbitrary code execution on every machine a setup names,
and the transport already reaches those over ssh. Instead the phone sends
connection details, and the server asks the machine itself what it has:
one `command -v` round trip per setup, matched against a table of the
drivers this server knows. The phone's authority is "add this machine",
never "run this".

Worth recording that this was a narrower change than it first appeared: the
token could already run anything on the backend, because the spawn screen
offers `bypassPermissions` with a free-text working directory. Discovery
does not close that door. What it does is keep the *list of what can run*
out of the phone's reach, and make adding a machine a thing you cannot get
wrong by typing.

It is also simply better to use. Nobody wants to type an absolute path on a
phone keyboard, and a machine whose binaries have moved answers correctly
on the next probe. The cost is that a program somewhere unusual is
invisible -- `command -v` follows PATH under a non-interactive ssh session,
which is not the PATH a person sees when they log in. That is the trade,
and the escape hatch is editing config.ron on the backend, which is exactly
the authority the phone is not being given.

Setups now have an **id separate from their label**, so renaming a machine
does not orphan the sessions that name it; a session stores the id, and
every row resolves the current label when it is built. `POST /setups/probe`
tries a machine without saving anything, so a wrong address or an
unauthorised key is caught while the form that caused it is still on
screen. Deleting is refused while sessions still run there, and says which
ones rather than cascading.

Every mutation goes through one `update`: clone, apply, save, then commit,
so a failed write leaves the previous state intact and reports why.

Verified against a running server, including a real ssh machine (this VM,
via a throwaway loopback key since removed): probing here found echo and
claude-cli; probing over ssh found claude-cli and correctly no echo, which
runs in-process and exists only where this server does; an unreachable
machine came back with ssh's own words ("connect to host ... Connection
timed out"); adding derived the id `loopback-vm` from "loopback vm";
renaming kept the id; deleting was refused while a session used it, naming
it, and succeeded once nothing did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 13:08:44 -04:00
irisandClaude Opus 5 ecac404fd4 A setup is a machine, and it carries what that machine can run
Providers and hosts were two independent lists, and a session named one of
each. They were never independent: a provider only exists on a machine
where that program is installed, so the spawn screen offered the whole
cross-product, including "the Claude CLI on the box that hasn't got it".
The picker could not know, because nothing in the model said.

Now a setup is a machine -- optional ssh, plus the providers it has -- and
spawning is two choices in order: pick a setup, then one of its providers.
The impossible pairs stop being expressible rather than being validated
against. Provider names are unique within a setup and only within one, so
two machines can each have a `claude-cli`, which was previously either a
name collision or two entries called things like "claude" and "claude on
the vm".

It also settles the "Run on" problem properly. That control was offered for
every provider but honoured only by the Claude driver -- an echo session
sent to a host ran locally and said otherwise. There is no such control
now: the machine is chosen first, and echo is a provider of the setup with
no ssh, where it belongs, since it runs in-process and has no transport to
cross.

The built-in echo provider is gone as a concept. It used to be conjured at
read time and never written to the file, which meant a provider nobody
could see or edit; it is now seeded into the config on first run alongside
claude-cli. What the file says is what there is, and deleting it is a
choice rather than a state to be repaired.

A config in the old shape is refused with instructions rather than loaded.
`Config` defaults unknown fields away, so `providers:` and `hosts:` would
otherwise have vanished into an empty config that was then seeded over --
a migration nobody would notice until their setups were gone.

Verified against a running server and on the emulator: a fresh install
seeds "this machine" with echo and claude-cli and the file reads cleanly;
a two-setup config lists both with their own providers; spawning on a
setup works and the session row names it; asking for a provider a setup
lacks says which it offers, and an unknown setup says which exist. On the
phone, selecting "dev vm" narrows the provider chips to that machine's one
and shows its address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 12:56:14 -04:00
irisandClaude Opus 5 3deeffd1e7 Run GGUF models through llama-server, and stop orphaning them
The second half of the llama.cpp work: a session can now name a downloaded
model and talk to it. `llama-server` is spawned through the same transport
as any other driver, polled until the model is loaded, then driven over its
OpenAI-compatible streaming endpoint and translated into the same events
the Claude driver emits -- so the transcript, the SSE stream and the phone
need to know nothing new.

**The conversation is rebuilt from the transcript, not held in the driver.**
llama-server is stateless between requests, so the whole history goes with
every one, and the obvious place to keep it is a Vec in the driver. That
fails the requirement: memory in a driver is invisible to a second device
and gone on restart, and this app is meant to work across devices. Reading
it back also means the model is prompted with exactly what the phone was
shown -- including a reply that was interrupted half way, which is in the
transcript because the deltas were already emitted.

That leaves the Claude driver as the odd one out rather than this one: the
CLI's memory of a conversation is a cache in front of the same transcript,
not a second truth. Said so at the top of llama.rs, because it is the sort
of inconsistency that gets "fixed" in the wrong direction.

Session settings arrive as a driver-interpreted `params` map rather than
new typed fields, so the shared schema does not grow one dialect's
vocabulary. Context size, gpu layers and threads become server flags;
temperature and the rest ride on each request, so changing them need not
reload a model.

**Also fixes an orphan this feature would have created.** Drivers set
kill_on_drop, which covers a session being deleted -- but nothing drops on
the way out of a SIGTERM, so signalling the server left its children
running. For the Claude CLI that is untidy; for a llama-server holding a
model it is gigabytes belonging to nobody. The server now stops its
sessions on SIGTERM and SIGINT. Found by killing a test server and noticing
two 600 MB processes still resident.

Remote llama sessions are refused rather than half-working: the model is
reached over HTTP, and forwarding that port to an ssh host is the "reach
this port" operation the transport does not have yet.

Verified end to end against a real model: downloaded Qwen3-0.6B Q8_0
through the app's own download route, spawned a session on it, and held a
two-turn conversation -- "my favourite colour is teal" then "what is my
favourite colour?", answered "teal", which is the transcript replay doing
its job. Token counts arrive. An earlier attempt with the IQ2_XXS quant
produced fluent nonsense, which turned out to be the quantisation rather
than the pipeline: llama-cli produces the same from that file directly.
Four unit tests cover the fold and the path guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 05:23:12 -04:00
irisandClaude Opus 5 4cdcbd204a Put the transport above the drivers instead of inside one
ClaudeDriver::spawn called ssh::command itself, so a translator whose job
is a wire format also knew how sessions reach other machines, and every
future driver would have had to remember the same. It now emits a `Launch`
-- program, arguments, working directory -- and hands it to a `Transport`
the manager chose from the session's host.

This is the inversion Bryan asked for, and it pays for itself immediately
in a place I had reported as a UI bug: "Run on" is offered for every
provider but only the Claude driver honoured it, so choosing a host for an
echo session silently ran it locally. With the transport above the driver
that cannot be written -- EchoDriver builds no Launch, so there is nothing
to wrap and nothing to misreport. The picker still needs to stop offering
it, but the code no longer lies underneath.

crate::ssh keeps the quoting, the forced options and the remote script,
with its tests; transport.rs only decides which of the two it is. The two
failure messages move with it, since they are transport-specific -- a
missing ssh client here is a different thing to check than a program
missing from a remote PATH.

Noted in transport.rs rather than built, because nothing needs it yet: a
remote llama-server is spawned as a process but spoken to over HTTP, so a
transport eventually needs "reach this port" as well as "run this".

Verified: cargo test (35), clippy, fmt. Nothing outside transport.rs
mentions ssh now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:49:55 -04:00
irisandClaude Opus 5 4e760a4a72 Separate the Claude dialect from the Claude process
claude.rs held two things that change for unrelated reasons. One spawns the
CLI, resumes it with --resume after a crash, writes lines to it and shuts it
down; the other turns a stream-json line into common events. A CLI wire
format change touches only the second, a change to how sessions are
launched only the first, and at 830 lines a reader had to work out which
half they were in.

So the translator, the pending-request bookkeeping and the answer outcome
move to session/claude/translate.rs, and all twelve tests go with them --
every one was already a translation test, replaying recorded lines with no
process involved, which is the clearest evidence the seam was already
there. 366 lines and 628, from 830 plus tests in one file.

Pure code motion: no behaviour, no renames, and the only edits are the
visibility the split makes necessary. The probing record stays in the
driver file, since it is the provenance for both halves -- the flags are
that file's, the message catalogue is what translate implements.

Verified: 35 tests pass (the same 35), clippy clean, fmt clean, and cargo
doc resolves with broken_intra_doc_links denied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:43:01 -04:00
irisandClaude Opus 5 c12ab7f098 Take rustfmt's defaults
The code was hand-formatted -- close to rustfmt's output but not it, mostly
in keeping chains and call arguments on one line where the formatter would
break them. That is a per-line decision every future change has to make
again, and reproducing it would mean a config whose only job is to preserve
how the code already looks.

So this is `cargo fmt` at its defaults, with no rustfmt.toml, which is
where the sibling dev-updater checkout already sits: it is clean at the
defaults today, so the two repos now agree on layout without either of them
configuring it.

Formatting only -- no behaviour, no renames, nothing reordered. Verified
after: cargo test (35 pass), cargo clippy --all-targets clean, cargo fmt
--check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:13:36 -04:00
irisandClaude Opus 5 19de699bfa Follow dev-updater's own config to RON
The same move, for the same reason: this file is written and read by hand,
and JSON has no comments to say why a host is configured the way it is.
Both house rules come across with it, in config.rs's `format` module and
nowhere else -- a file is the *body* of the config, so no outer parentheses
and nothing indented for them, and `Some` is implicit, which is what makes
`skip_serializing_if` on every optional field load-bearing rather than
tidiness.

The switch is outright: there is no reader for the old format. That is
invisible everywhere except here, because this file holds the enrolled
token hashes -- starting empty leaves the phone unable to talk to the
server and looks, from the phone, like the config having been lost. So a
config.json left beside the new file is named in the log and left alone,
rather than read or deleted.

One wart, documented at DriverKind: the kebab-case spelling is the string
the phone compares against, so it stays, and the file pays for it with
`kind: r#claude-cli` -- a hyphen is not a RON identifier. Renaming the
variant would change what an already-installed build is talking to.

Verified: cargo test, cargo clippy --all-targets, and a real start against
a scratch state directory -- a hand-typed config with comments and a bare
`port: 2222` loads, and what the server writes back sits at column 0 with
no Some(...) in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 02:25:50 -04:00
irisandClaude Opus 5 99bcc341c1 Cleanup pass: one home for duplicated logic, stale comments out
Nothing behavioral except two status codes; mostly removing places where
the same rule was written down more than once and could drift.

- server/src/private.rs: the owner-only create/write helpers, which
  config.rs, certs.rs, and the session dirs each had their own copy of
  (certs.rs even duplicated the explanatory comment). One module owns the
  modes now, so the "nothing this server writes is readable by anyone
  else" property is checkable in one place.
- server/src/media.rs: the image media-type/extension table, which the
  four places that have to agree on it each spelled out separately --
  storing an upload, serving it back, building a content block, saving a
  produced image. The differing *defaults* stay at the call sites with
  the reasoning, since they genuinely differ by direction.
- routes.rs: a missing file was a 400 and an unreadable one a 400 with a
  hand-rolled log line; they are now 404 and Internal respectively.
  UnknownSession became NotFound, since it was the only 404-with-message.
- main.rs: xdg_dir takes the variable's value instead of reading the
  environment, which drops the unsafe set_var from its test and lets the
  test actually assert the relative-path rule.
- echo.rs had its own 4-byte hex generator beside session::random_hex.
- claude.rs: the two impl Translator blocks were one type's methods.
- Stale comments: phase-2 markers on shipped work, a permission-mode list
  that had drifted from the CLI's, "dev-updater" as the leaf certificate's
  fallback common name, a half-written sentence in build-apk.sh.
- App: the JSONArray walk written out in four fetchers, the four
  near-identical BackHandlers in AppRoot, and SessionScreen's inline
  fully-qualified names where the file otherwise imports.
- server/wg-test.log was committed by accident; *.log is ignored now, and
  the gitignore comments describe where state actually lives.
- PLAN.md's backend layout gains the new modules and drops hosts.rs for
  the ssh.rs that was built instead.

Verified: 35 server tests, clippy clean, app compiles warning-free, and a
scratch server driven over curl -- attachment upload/serve round-trip with
both a known and an unknown content type, the new 404s, transcript and
session-dir deletion, plus a real claude-cli session answering a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 16:10:33 -04:00
irisandClaude Fable 5 56491f84b0 Follow the sibling project's rename to dev-updater
It is no longer "local" -- it serves over WireGuard rather than the LAN --
and it is specifically for developing new apps. Renaming the references
here at the same time keeps one name to search for across both repos.

Also drops the last references to gen-dev-cert.sh, which the in-process
certificate generation replaced: the build script and the Gradle task now
say to start the server once, and test-wg-tunnel.sh reads the
certificates from the XDG directory rather than the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 05:10:20 -04:00
irisandClaude Fable 5 d2d2832ec8 Keep state and keys out of the shared repo
The dev VM is treated as untrusted, and the repo is a read-write virtiofs
mount shared with the backend host -- so a CA private key sitting in it is
a key that machine can sign with, and a leaf signed by this CA is one the
phone's pinned app accepts without question. Pinning against a CA the
attacker holds is no pinning at all.

So certificates are now generated on the machine that serves them, into
$XDG_CONFIG_HOME/ai-app/certs at 0700 with 0600 keys (AI_APP_CERTS
overrides), and config.json and session transcripts move to the XDG config
and data directories. Transcripts move for a plainer reason than the keys:
they are whole conversations, and they were world-readable at 0644.

Two smaller things fall out. The host and VM stop sharing one config,
which had already put a test token on the production backend. And state
stops living where `git clean -xdf` would take the enrollment and every
transcript with it.

State that predates the move is still read from the repo, with a warning
naming where to move it, so an existing install keeps working rather than
silently coming up on an empty config -- the precedence is covered by a
test, since picking the wrong file would otherwise be silent.

Verified: 31 tests, clippy clean; the certificate script writing 0700/0600
into an overridden directory; and the server logging the fallback and
serving from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 03:49:34 -04:00
irisandClaude Fable 5 fff1fb49e8 Providers and hosts: what runs, and where, as independent choices
A session now names a provider (what: driver kind, command, models) and
optionally a host (where: an ssh target). Keeping them independent is
what the real setup needs -- the backend runs where the phone can reach
it, which isn't where the CLI is installed -- and it means any provider
can be sent to any host rather than a machine being baked into one.

The first provider is claude-cli, named for the CLI rather than bare
"claude", which would suggest the credit-billed API. A fresh config is
seeded with it so a new install has something to spawn and a worked
example to edit; echo stays a built-in provider needing no config.

ssh.rs builds the child process either way: locally, or `ssh -T` with
BatchMode and keepalives, every argument single-quoted for the remote
shell (a working directory that tries to close the quote and start a
command is covered by a test), and `exec` so dropping the connection
takes the CLI down instead of orphaning it.

App: the spawn screen reads /providers and /hosts instead of hardcoded
lists, so config changes need no rebuild. Chip rows are FlowRow, fixing
the reported bug where a row of models that didn't fit wrapped *inside*
each chip -- one letter of "haiku" per line -- rather than onto a second
line.

Verified: 29 tests, clippy clean; the same claude-cli provider run once
locally and once over ssh, with the remote one visibly in a different
environment; an unknown host name refused with the configured list; and
the spawn screen on the emulator showing server-driven providers, hosts,
and models that wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 03:34:33 -04:00
irisandClaude Fable 5 f2430671a2 Phase 2 complete: images both ways
Inbound: POST /sessions/{id}/attachments stores a picked photo under the
session; message attachmentIds become base64 image blocks in the
stream-json user message (verified live: an uploaded red PNG answered
"Red."). Outbound: image parts in tool results are decoded into the
session's files/ dir and referenced by Image events -- the transcript
stays lean -- and GET /sessions/{id}/files/{ref} serves them (verified
via the Read tool round-tripping the same PNG). The app grows an attach
button (system photo picker, upload-on-pick) and renders Image events
inline with an authenticated pinned fetch. Sent attachments are echoed
into the transcript as Image events so every device shows them.

Attachments and files are addressed under their session (a deviation
from PLAN.md's original bare /attachments -- recorded there) so their
lifecycle is the session directory's: deleting the session is still the
complete path out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 21:40:25 -04:00
irisandClaude Fable 5 95d389e2b8 Phase 2 core: ClaudeDriver over stream-json, permissions and questions on the phone
The second driver behind the same trait: claude -p with stream-json both
ways, the hidden --permission-prompt-tool stdio flag (without which no
permission ever reaches a client), text deltas streamed from raw API
events, tool_use/tool_result mapped to tool events, and can_use_tool
control requests surfaced as Question events -- plain permissions as
Allow/Deny, AskUserQuestion as one Question per sub-question with the
chosen labels sent back in updatedInput.answers keyed by question text
(wire shapes pinned by live probes against CLI 2.1.237, recorded in the
module doc). The CLI session id is persisted per session dir, so a
backend restart respawns with --resume and loses nothing. set_model
rides the control protocol and persists through the manager; the spawn
screen grows model/cwd/permission-mode fields.

Also: the dev CA now carries proper keyUsage/basicConstraints
extensions (strict verifiers reject it otherwise) -- regenerated and
re-pinned before any real phone has installed the app.

Verified: 20 unit tests + clippy clean; scripted end-to-end over the
HTTP API (AskUserQuestion round trip, Bash permission allow, streaming,
restart with --resume remembering earlier work, delete); and on the
emulator, a live haiku session asking Tea-or-coffee and acknowledging
the tapped answer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 21:31:50 -04:00
irisandClaude Fable 5 967fc814ab Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors
The whole pipe behind one Driver trait and a common event model:
spawn/list/delete sessions, message + question answering, append-only
JSONL transcripts whose sequence numbers are the phone's resume cursor
(surviving backend restarts), bearer-token middleware wrapping every
route including the fallback, wg0-only binding that fails closed, and
first-run token enrollment via a terminal QR.

Verified: cargo test (10), clippy clean, and curl end-to-end over pinned
TLS -- auth rejection, spawn, streamed SSE replay/resume, /question
round trip, restart continuing seq numbers, delete removing everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 20:51:34 -04:00