ee1c493559e38750d38b6e0913a489b14ed91901
130
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
31135e3f22 |
Wear Catppuccin Mocha, and move Usage to where the provider is
Two changes to the app, plus the one they turned up. **The theme.** Catppuccin Mocha, copied from dev-updater rather than shared: wg-app-link is the *link* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike is a preference rather than a contract, and the moment one wants a different accent a shared version becomes a thing to fight. dev-updater's ActionTone and its ANSI table did not come across; nothing here draws a log or a destructive button yet, and copying a vocabulary with no speakers is how a file starts lying about what the app does. **Usage is no longer a global button.** It belongs to the provider, and the session view is the only place a provider is currently named, so that is where the control sits -- beside the line that names it, rather than collected with the app-wide controls where its scope had to be guessed. It carries the session back with it, so Back returns to that session rather than dumping the reader on the list. Its real home is that provider's own settings, which do not exist yet. **And the bit that only running it could find.** I first subtitled the usage screen with the session's provider, which on an echo session put "echo" directly above a card reading "claude" -- Claude's account-wide numbers labelled as echo's, a claim about echo that nothing measured. The subtitle is gone; each card names the service that answered, which is the true scope, and the reason is written where the subtitle was so it does not get re-added. Looked at on the emulator rather than read: the palette, the session header, the usage screen, and Back landing on the session it came from. ktfmt, compile and Lint clean. |
||
|
|
b6b33dc9c5 |
Take the app half from wg-app-link as well
The four Kotlin files that were the link rather than this product now come from the submodule: the pinned TrustManager, the enrollment store and its Keystore sealing, the QR capture activity, and the local-network permission check. `:link` is a subproject resolved by path, so the app half is version-locked to the same commit the Rust half already was. What stays here is the two facts that are actually about this app, and both are load-bearing in a way that would fail quietly if got wrong: the `aiapp` URI scheme, and the Keystore alias `aiapp-token-key` that every enrolled phone's token is already sealed under. A wrong alias would leave those phones reading as not enrolled with nothing on screen to explain it, so the value is carried over exactly and the reason is written beside it. Call sites are unchanged. `ServerSettings` stays available unqualified as a typealias and `applyPinnedTls()` stays an extension, so the diff is the three files that bind the product-specific values plus two imports -- rather than every screen that happens to use a setting. Also clears a warning the build had been printing: `setup?.id.orEmpty()` where the compiler already knows `setup` is non-null, because `chosen` came from that setup's own provider list. Verified by running the build, not only by reading it: ktfmt, Kotlin compile and Android Lint are all clean with no warnings, and the APK still builds -- which exercises the pinned-CA generator, since that is the step that reads the CA off this machine. Still unpushed, per the hold until the rebuild bug is proven fixed. Note the submodule: a checkout of this commit needs `git submodule update --init` before `app/` or `server/` will build. |
||
|
|
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 |
||
|
|
9f54a80ca4 |
Tell the difference between a blocked app and a missing server
Two findings from dev-updater's session reading this codebase, both checked against the code here before acting on them, and both real. **A denied local-network permission was invisible.** The manifest requests ACCESS_LOCAL_NETWORK and MainActivity asks for it, but nothing ever checked whether it was granted -- and on Android 17 a denial is indistinguishable from an unreachable server at the socket, because the OS simply drops the traffic. So every screen would have shown "is ai-server running, and is this device able to reach that address (WireGuard up)?", blaming two things that were both fine. Stated once at the root as a standing condition rather than appended to each failure it might have caused: it is not a property of any one request, and repeating it per error is how a message ends up saying the same thing twice, which this app has already done once today. **The Keystore read path was creating keys.** `unseal` called the get-or-create key function, so a sealed token whose key had been lost -- a device reset, or the app's data restored onto a device the key cannot travel to -- generated a fresh key, then failed to decrypt with it, leaving a key nothing had ever sealed with. The behaviour was already right by accident (it fails soft to "not enrolled"), but the read side now asks for the key without making one, which is what it meant all along. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw |
||
|
|
3c144f8070 |
A screen for the machines, and failures a phone can act on
The other half of making setups editable: add, rename, rediscover and remove, with a Test that tries a machine before anything is saved. The screen cannot name a program, which is the point rather than an omission -- providers are what the server found when it asked, so this app has no way to introduce something to run. The dialog says so, because "what it can run is discovered, not typed" is the answer to the question a person will otherwise ask when they look for a command field. Two things running it changed. The card showed "this machine / this machine", because the seeded setup is *called* that and my fallback line for a local setup said the same -- the line now says something the name cannot also be. And the header row absorbed a fifth action without complaint, which is the earlier title-and-actions split paying off exactly as its comment predicted. **Host key verification is the failure that would have made this look broken.** Every machine fails it the first time, because its key is not in known_hosts yet, and ssh's own words -- "Host key verification failed." -- are written for somebody at a terminal on the backend, which is exactly who is not reading a phone. It now says what to do: ssh to it once from the backend and try again. Permission denied gets the same treatment. Deliberately *not* fixed by relaxing StrictHostKeyChecking. Accepting a new key is a decision somebody should make with the key in front of them, not something this app does quietly on their behalf while adding a machine. Verified on the emulator against a running server: the seeded setup renders with what was discovered on it, the add dialog explains itself, and Test against an untrusted machine produces the full explanation rather than ssh's four words. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |