Commit Graph
80 Commits
Author SHA1 Message Date
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 7cf36005ae Browse, download and delete models from the phone
The point of the llama.cpp work was that models are managed from the app,
not by editing the backend's filesystem, so this is the screen for it:
search HuggingFace, expand a repository to see its GGUFs with sizes,
download one and watch it, cancel it, delete what is no longer wanted.

Everything shown is the server's state rather than the screen's. A
download started here keeps going when the screen closes, is visible from
any enrolled device, and its outcome outlives it -- demonstrated by
accident while testing, when a 538 MB download finished during an app
rebuild and was still there, complete, after reinstalling.

Polled rather than streamed, at 1.5s. A download belongs to the machine
rather than to any session, so it has no event stream of its own; this is
the one screen in the app that asks repeatedly instead of being told.

Three things the screenshots decided rather than the diff:

- **The list header no longer squeezes its title.** Adding a fourth action
  to the row wrapped "AI Sessions" onto three lines. Title and actions now
  have a row each, so a fifth costs nothing and the title is never what
  gives.
- **A repository's files render inside its own card**, not as a section
  after the list -- drawn after every card they read as belonging to
  whichever was last.
- **A file already downloading says so** and is disabled, rather than
  offering a Download button whose effect nobody can see.

The progress bar is determinate only when the server reported a size, and
says "total size unknown" otherwise rather than inventing a position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 05:32:38 -04:00
irisandClaude Opus 5 2cefede450 Compose Multiplatform 1.12.0
The last four lint warnings were all this one thing, and they were right:
1.11.1 with 1.12.0 out. Nothing else here is behind -- material3 1.9.0 and
activity-compose 1.13.0 are both current, checked against Maven Central and
Google Maven today, which is also why the header comment's date moves.

Android Lint now reports no issues at all, from 11 errors and 8 warnings
this morning.

Verified by running it rather than by the build succeeding, since a Compose
minor can change how things draw: the session list, the usage screen and a
session transcript with a message sent through it all render as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:09:44 -04:00
irisandClaude Opus 5 0c13090d70 Take ktfmt's defaults for the Kotlin half
The server half went to rustfmt's defaults earlier today; this is the same
move for the app, and the same reasoning. Kotlin ships no formatter with
the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org
owned now (it moved from facebook/ktfmt), is a formatter rather than a
configurable linter, and has essentially nothing to tune -- which is what
rule 27 is asking for. ktlint's .editorconfig surface is the thing that
rule warns against, and detekt is static analysis, whose job Android Lint
already does here.

One setting, and it is a choice between the tool's own two styles rather
than a tuning: kotlinLangStyle() is the 4-space one, which is what this
code already was. The 2-space default would have reindented every file to
say nothing.

  ./gradlew :androidApp:ktfmtFormat   to apply
  ./gradlew :androidApp:ktfmtCheck    to verify

Formatting only. The one thing worth checking by hand was the generated
PEM constant, since a leading newline there costs Android's
CertificateFactory its preamble sniff and fails at runtime nowhere near
the cause: ktfmt moved `.trimMargin()` onto its own line and left the
template alone, and the regenerated constant still starts at the opening
quotes.

Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and
the APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:03:14 -04:00
irisandClaude Opus 5 dde5042b12 Run the linter the app build already had, and fix what it found
`./gradlew :androidApp:lintDebug` had apparently never been run. It
reported 11 errors, 8 warnings and 3 hints, and one of the errors was a
crash: UsageScreen formats its reset countdown with java.time --
OffsetDateTime and Duration, both API 26 -- while minSdk is 24 and core
library desugaring was off. On 24 and 25 that is a NoClassDefFoundError,
and the `catch (_: Exception)` around the code does not stop an Error, so
the usage screen would have died rather than degraded.

Core library desugaring is now on, with desugar_jdk_libs 2.1.5. Verified
by looking in the built APK rather than trusting the flag: it now carries
Lj$/time/Duration and Lj$/time/OffsetDateTime, the backported classes the
call sites are rewritten against.

The rest: the two KTX suggestions taken (SharedPreferences.edit's block
form, which cannot forget its apply(), and String.toUri), the three
autoboxing hints taken (mutableIntStateOf/mutableLongStateOf), and
androidx.core:core-ktx declared at 1.19.0 rather than inherited through
activity-compose, since this code now calls its extensions directly.

Two are suppressed with their reasons, both scoped to the one element.
DiscouragedApi on the scanner's screenOrientation, which is not a pin but
the removal of the library's landscape lock. MissingApplicationIcon,
because there is no icon yet and that is a decision to make later, not an
oversight -- an app with no icon is obvious to anyone who opens a
launcher, so the warning tells nobody here anything.

0 errors now. The 4 warnings left are one thing: Compose Multiplatform
1.12.0 is out and this is on 1.11.1. That is an upgrade to decide on, not
a defect, so it is left for its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 04:00:57 -04:00
irisandClaude Opus 5 78c054918f Don't offer an empty spawn form when the options never arrived
SpawnScreen kept its own `loading` flag and `error` string, the third
mechanism in this app for a state two screens already share. That was not
just untidy: on a failed fetch it set the error, left `providers` and
`hosts` at the empty lists they started as, and rendered the form anyway --
so "couldn't reach the server" arrived as a Provider row with no providers
in it, which is what a server offering nothing would also look like. The
error sat below both empty pickers.

It now holds `LoadState<SpawnOptions>` like the others: loading shows the
spinner, a failure reports and stops, and the form exists only where there
is something to fill it with.

The spawn action keeps its own error, renamed `spawnError` so the two can't
be confused again. They are different in kind and the distinction is the
one the session list just learned: a fetch that never answered leaves no
form worth showing, while a spawn the server refused leaves a filled-in
form the user still wants, so that one stays beside the button that
produced it.

Verified on the emulator: the form loading with both providers, the Claude
fields appearing when claude-cli is selected (which also exercises the
snake_case kind the phone now compares against), and the failure state with
the server stopped -- reporting alone, with Cancel still working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:45:42 -04:00
irisandClaude Opus 5 585e4a0369 Spell the driver kind the way Rust and RON do
`kind: r#claude-cli` was the config file paying for a serde default. RON is
modelled on Rust, a hyphen is not an identifier in either, and this file is
edited by hand -- so the escape existed only to write a name nobody would
have typed that way. Snake case, and it reads `kind: claude_cli`.

The same string is the one the phone compares against to decide whether to
offer models, a working directory and permission modes, so SpawnScreen.kt
moves with it. That is a wire-format change: Dev Updater delivers the
server before the APK, so between the two an installed build sees a kind it
does not recognise and drops the Claude-specific fields from the spawn
form until the APK lands. It recovers on its own, and nothing else reads
the value.

The provider *name* is left as "claude-cli". It is a label a person picks
and may edit, and sessions reference providers by it -- renaming the
default would orphan them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:41:13 -04:00
irisandClaude Opus 5 7a02e79613 Put a refused delete on the session it was refused for
Deleting a session the server no longer has replaced the whole list with an
error. The server answered -- it said "no session <id>" -- so what it told
us is about one row, and every other row was still exactly as fetched. The
list going away said otherwise.

The line between the two scopes is whether the server answered. Answered
and refused is about one item and belongs on that item; never answered
leaves every row stale against a server that has stopped talking, and that
is the list's own state to report. Both cases exist here and had been
sharing one slot, which is why the message had to hedge about which it was.

So failures acting on one session live in a map keyed by its id and render
inside its card. The path out is the next successful load, which clears the
map: an entry would otherwise outlive the session it names and reappear
against whatever the phone fetched next.

The shape is dev-updater's `cardStates`, which solved this first; the rule
above is theirs too, and it moves their "gave up waiting" case the other
way, to list-level, which is a good sign it is a rule rather than a
description of what either of us already had.

Verified on the emulator by producing the real failure -- delete a session
out from under the app with curl, then delete its stale row from the phone.
The refusal appears on that card, the other card is untouched, the list
stays, and Refresh clears it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:33:47 -04:00
irisandClaude Opus 5 3c4c728ed5 Stop the session list saying "Couldn't reach the server" twice
On screen it read: "Couldn't reach the server: Couldn't reach the server at
https://10.0.2.2:8443 (ConnectException ...)". Api.kt writes a whole
sentence -- the address, the exception, and what to check -- because that
message has to be actionable on a device with no logcat; the list then
prefixed it with a shortened version of the same claim. The usage screen,
showing the identical failures, prints the message alone and reads
correctly, so this was one caller disagreeing with the other about what a
failure looks like.

The prefix was also a guess the caller could not make. `LoadState.Error`
here also carries a failed *delete*, which the server may well have
answered -- reaching it fine and refusing -- and the prefix said it had
not been reached at all.

Found by looking at it with the server stopped, which is the only way this
was ever going to show up: it compiles, and the happy path looks perfect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:21:58 -04:00
irisandClaude Opus 5 56882d5fa3 One load state for the two screens that had the same one twice
`ListState` and `UsageState` were the same three cases -- Loading, Loaded,
Error -- differing only in what Loaded carried, which is the shape rule 17
asks to parameterize rather than copy. They are now one `LoadState<T>`,
covariant so a single `LoadState.Loading` serves both.

Worth keeping as a type rather than a value beside a nullable error: it is
what stops "we couldn't find out" from sharing a representation with
"there is nothing", so a failed fetch cannot render as an empty list.

`LoadState.failed(e)` also collects the `e.message ?: "Unknown error"` both
screens were spelling out, so there is one answer to what an ApiException
looks like on screen instead of one per caller.

No behaviour change. Verified on the emulator against a real ai-server, not
just compiled: the list empty, the list with two sessions, usage with three
real windows, and both screens' error state with the server stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 03:21:42 -04:00
irisandClaude Opus 5 5f8a2146e6 Ask Dev Updater for the checkout, not for the app inside it
Dev Updater's unit is a project -- a directory in a checkout -- that
produces components, so this declaration belongs at the repository root
where it can say where each half lives. `cwd` is how a component says
that, and it is what the old file was working around: sitting in `app/`,
it reached the backend with `../server/Cargo.toml` and `../server/service`,
which described the layout backwards.

So the file says what this repository produces: the backend, built and
serviced in `server/`, and then the APK, built in `app/`. The order is
unchanged and still the point -- a failing APK build leaves the phone the
APK it already had rather than half of a matched pair.

The service script gains dev-updater's note about lingering. A user
service stops at logout unless `loginctl enable-linger` is set, which for
this one matters more than for a build server: the phone reaches ai-server
whether or not anyone is logged in at the desk.

**This changes the project path, so the phone's existing entry (at
~/repos/ai-app/app) has to be removed and the checkout root added
instead**, and its components accepted once.

Verified against dev-updater's current parser rather than by reading its
schema: the declaration loads, `cargo build --release` resolves into
server/, app/build-apk.sh into app/, the service script to an absolute
path, and APK discovery from the root still finds the built APK.
`./server/service status` prints not-installed and exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 02:26:02 -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 effefdeb03 Carry a service script, and declare the backend as a component
ai-server is now something Dev Updater can install, start and stop from
the phone: the project declares a Server component naming ./server/service,
and that script is where knowing about systemd and OpenRC lives.

Detection asks rather than looks -- `systemctl --user show-environment`
answering, or `rc-service --user` existing -- because a machine can carry
both binaries and an OpenRC below 0.60 has rc-service without --user.
Neither present is an error with a message, not a guess at a fallback.

Two things the script will not do. It never prompts: Dev Updater runs it
with stdin closed, so a sudo prompt would hang rather than fail, and
anything needing root exits telling you to run it yourself once. And on
OpenRC it refuses when XDG_RUNTIME_DIR is unset rather than proceeding --
user services store their state there, and the failure otherwise reads as
a broken service instead of a missing variable.

The server is declared before the app so it is built and delivered first;
a failed APK build then leaves the phone with the APK it already had
rather than half of a matched pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-26 00:48:25 -04:00
irisandClaude Opus 5 0da6a542bd Report build progress to Dev Updater
Counts Gradle tasks: --dry-run gives the total, the build gives one
'> Task :x' line each as it goes. Gradle offers no direct route -- an
init script using taskGraph.afterTask is rejected by the configuration
cache, and whenReady never fires on a cache hit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-26 00:02:43 -04:00
irisandClaude Opus 5 1f76985193 Declare a component instead of a prepare block
Same command, said as what this project produces. See dev-updater's
commit for the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 23:44:08 -04:00
irisandClaude Opus 5 bc2bfc007c Write the build command as a line
Both forms parse to the same argv; the line is what this one reads best
as. See dev-updater's commit for the boundary conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 22:34:04 -04:00
irisandClaude Opus 5 4d243c7b55 Follow dev-updater's config to RON
Same file, same keys, new format -- see dev-updater's own commit for why
it moved. The one thing to know when editing this: it is the *body* of the
config, so there are no outer parentheses and nothing is indented for
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 22:31:21 -04:00
irisandClaude Opus 5 f53a1ca214 Hold the camera permission before the scanner opens
The library's capture activity asks for the camera itself, and opens the
camera without waiting for the answer. The first scan on a fresh install
therefore comes up as a live preview with "Sorry, the Android camera
encountered a problem. You may need to restart the device." over it, and
works on the second try. Nothing is wrong with the camera, so nothing
should say there is.

Asking before launching it means the activity always starts with the
permission already held. A denial now says what to do instead of leaving
a dialog blaming the device.

The scan options move to one function while there are two callers -- the
button when the permission is already held, and the permission result
when it has just been granted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 21:11:27 -04:00
irisandClaude Opus 5 bf82166736 Scan without asking the user to frame or turn the phone
The library's CaptureActivity has three defaults that put work on the
person holding the phone, so the scan now goes through our own subclass
instead:

- It decodes only the framing rectangle, inset 10% from every edge. A code
  filling the viewfinder still decodes -- measured against the library's
  own decoder rather than assumed -- but only by spending its quiet zone
  on the crop, and anything further out is never looked at. The whole
  preview is decoded now, so framing is not something to get right.
- It draws a red laser line and scatters dots wherever the detector finds
  a candidate pattern. That is the library's house style, and it looks
  like a fault. The preview is plain now.
- Its manifest entry pins the activity to landscape. The code being
  scanned is usually on a monitor in front of someone holding the phone
  upright, so ours follows the sensor.

Verified on the emulator: the scanner opens on a bare preview with no
laser, no dots and no status line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 20:54:15 -04:00
irisandClaude Opus 5 0522123a33 Scan the enrollment QR in either polarity
ai-server prints the QR as block characters, which take the terminal's
foreground colour. On a dark-themed terminal that comes out as a
photographic negative, and ZXing looks only for a dark code on a light
ground, so the in-app scanner silently never matched it -- while the
phone's own camera app, which tries both, did.

Fixing it in the scanner rather than by having the server force its
colours means however the code gets displayed stops mattering: a light
terminal, a dark one, a screenshot someone inverted, a printout.

MIXED_SCAN alternates normal and inverted frames, so the cost is half the
frame rate at each polarity. Checked against the library's own
MixedDecoder off-device: the plain decoder reads the normal image and
returns nothing for the inverted one, and the mixed decoder reads both
within two frames.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QQz6R4kBQcWSHBNZMgBnjL
2026-08-25 20:36:12 -04:00
irisandClaude Opus 5 ba545aa595 Declare the phone build step for Dev Updater
Dev Updater serves this app's APK to the phone. Carrying the build step
here rather than in that machine's config keeps it with the project that
knows it, and means a second build machine doesn't have to work it out
again.

package and label are declared too, so the project can be added from a
fresh clone with nothing built: without them there is no APK to read an
identity out of, and adding it is the prerequisite for running the build
step that would produce the first one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iK6pJhPvKjSFwxK4DkCqM
2026-08-25 18:05:36 -04:00
irisandClaude Sonnet 5 da2c110429 Scan the enrollment QR in-app instead of relying on the camera app
Not every phone's camera app hands a scanned aiapp:// URI off to the app
reliably, which made enrollment an annoying multi-step process. The
Settings screen now scans the QR itself via zxing-android-embedded's
ScanContract (offline, no Play Services/ML Kit download) and feeds the
decoded URI through the existing parseEnrollmentUri. The aiapp://enroll
deep link stays as a fallback for cameras that do redirect.

Verified on the emulator: Scan QR code launches the scanner, prompts for
camera permission, shows a live preview, and backing out returns to
Settings cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 16:25:14 -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 eeb2dde4ab App embeds the CA of the machine that builds it
PinnedCert.kt no longer carries a pasted certificate. The build reads
$XDG_CONFIG_HOME/ai-app/certs/ca.pem (AI_APP_CA overrides) and generates
the constant, so the trust anchor follows the build machine: an APK built
on the backend host pins that host, and one built in the dev VM pins the
VM's throwaway CA and is good only for its emulator.

That removes the reason to add a second trust anchor for development --
there is nothing to add and then forget to remove -- and it means the
private key never has to exist near this repo, which the VM can write.
Regenerating a CA now needs a rebuild instead of a paste, so a stale
constant can't quietly disagree with the server.

build-apk.sh is the missing counterpart to run-android.sh: it produces
the APK to install through Local Updater and touches no emulator. It
finds the SDK from ANDROID_HOME/ANDROID_SDK_ROOT before falling back to
~/Android/Sdk, since the host doesn't share the VM's layout, and prints
the fingerprint of the CA being pinned so a wrong one is visible there
rather than as a handshake failure on the phone.

Verified end to end on the emulator against a server using a freshly
generated CA -- which is how the first attempt was caught: the generated
constant began with a newline, so CertificateFactory lost the "-----BEGIN"
sniff, tried DER, and failed at runtime with an ASN.1 decode error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-25 04:16:15 -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 3c97a5ef28 Phase 3: usage screen
GET /usage serves the numbers behind Claude Code's /usage, read with the
CLI's own stored OAuth credentials (nothing to configure). The endpoint
is undocumented, so parsing is defensive -- the generic limits[] array
becomes labeled window bars, unknown kinds surface under their raw name,
and any failure degrades to an 'unavailable' snapshot with the reason.
One UsageProvider per paid service behind a caching monitor that
enforces the >=180s minimum poll regardless of phone refreshes; no
background polling at all. ureq (rustls) does the outbound call, with
the process-level CryptoProvider now chosen explicitly in main -- ureq
brings ring while axum-server brings aws-lc-rs, and with both in the
graph rustls refuses to guess.

App: a Usage screen off the session list -- per-window bars colored by
utilization with relative reset times. Verified live: 74%/26%/16%
windows rendered against the real endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-24 21:47:51 -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 213bc72b64 Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS
Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

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