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
`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
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
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
`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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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