5396da76c7496f99aba0c466aeece35124cf9fe1
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
362d436d4f |
Let sessions outlive the backend, and never resume one twice
Three `claude` processes ended up running against this checkout on 2026-08-29, and the account hit its session limit. One cause, several ways in. An agent imported the Claude Code session it was *itself* running in. That is an ordinary import, and importing runs `--resume` -- so a second CLI attached to a file the first was still writing. The whole 65 MB conversation, 154 embedded screenshots included, was re-appended to the transcript under a new prompt id; both copies then read each other's writes as work done elsewhere, and the adopted one was billed for re-reading all of it. Meanwhile `shutdown_all` asked each session to stop and the process exited immediately, so the SIGKILL timer died with the runtime, the stop was unreliable, and whatever survived was orphaned with nothing written down to find it by. The processes leaked either way. So leak them on purpose, and be able to pick them back up. A session's process now outlives the backend and is adopted again on the way up, which is worth having for its own sake: restarting the server no longer ends a turn somebody is waiting on. Its stdio lives in the session directory -- a fifo opened read-write so the process is its own last writer and never reads EOF, plus stdout/stderr logs read from a byte offset. `session::process` records the pid *and* the kernel's start time for it, because a pid alone is reused and adopting a stranger's would mean never resuming the real conversation. That makes the fix structural rather than a check: everything goes through `ClaudeDriver::launch`, which adopts if it can and starts if it cannot, and `--resume` is reachable only on the second path. `Driver` gains two ways out where it had one -- `detach` (coming back) and `stop` (the session is being deleted, so the process must not survive). Importing a session that is open is now refused outright. Claude Code keeps `~/.claude/sessions/<pid>.json` for every live session, so this is a measurement rather than a guess; it reports no/yes/unknown, because a machine that keeps no such record cannot answer and "could not check" is not "nobody is using it". `SessionStatus` gains `Unknown` for the same reason. Also here, found on the way: - A reconnecting phone was sent the entire backlog. Opening a session was bounded to a page but reconnecting was not, so a long disconnect delivered thousands of events one frame at a time. Past `CATCH_UP_LIMIT` the stream sends a `reset` frame and the newest window, and the client rebuilds from it as it does on open -- without the reset the window is spliced onto rows no longer adjacent to it. - A session's status was assumed idle at launch. Read from the transcript instead, so a restart stops claiming an exited session is waiting for you. - `llama-server`'s stdout was piped and never drained, so a chatty one blocked on a full pipe buffer mid-load. It goes to a log now. - A turn that exited or errored never emitted `Idle`, so the queue stayed "running" for good: every later message was held forever and, since a message is only recorded when taken, vanished with nothing on screen. - Two doc comments had drifted onto the wrong functions. Verified by killing the server mid-turn: the process survived, finished its turn unattended (12.8 KB of output nothing was reading), and the restarted server adopted it -- one process, all 700 lines in the transcript, no hole, and it still took a new message afterwards. Deleting a session stops its process; a 266-event backlog resets while a 16-event one streams. 46 tests, clippy and rustfmt clean, app compiles and lints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
aa05ff9336 |
Take the link from wg-app-link instead of keeping a second copy
The five modules underneath this backend that were never about AI sessions -- the pinned CA and leaf, QR enrollment and the bearer token, wg0 binding and the certificate's SANs, owner-only files, and the RON house rules -- were written twice, once here and once in dev-updater, and had drifted. They now come from the submodule, as a path dependency so both projects stay locked to one commit. What stayed is what makes this project itself: the routes, the drivers, the config schema, and the auth middleware, which is generic over this server's state. Sharing a transport is worth doing; sharing an API would mean inventing a vocabulary neither project wants. Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs are no longer named here at all -- and the three that remain are now described by what still uses them rather than by what used to. Verified by running it, not only by building: a fresh server generates its CA, prints an `aiapp://enroll` QR with the scheme now passed as a parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf, answers an enrolled token and returns 401 without one, and writes config.ron in the house rules with every file owner-only. 36 tests pass, clippy is silent, rustfmt is clean. |
||
|
|
3deeffd1e7 |
Run GGUF models through llama-server, and stop orphaning them
The second half of the llama.cpp work: a session can now name a downloaded model and talk to it. `llama-server` is spawned through the same transport as any other driver, polled until the model is loaded, then driven over its OpenAI-compatible streaming endpoint and translated into the same events the Claude driver emits -- so the transcript, the SSE stream and the phone need to know nothing new. **The conversation is rebuilt from the transcript, not held in the driver.** llama-server is stateless between requests, so the whole history goes with every one, and the obvious place to keep it is a Vec in the driver. That fails the requirement: memory in a driver is invisible to a second device and gone on restart, and this app is meant to work across devices. Reading it back also means the model is prompted with exactly what the phone was shown -- including a reply that was interrupted half way, which is in the transcript because the deltas were already emitted. That leaves the Claude driver as the odd one out rather than this one: the CLI's memory of a conversation is a cache in front of the same transcript, not a second truth. Said so at the top of llama.rs, because it is the sort of inconsistency that gets "fixed" in the wrong direction. Session settings arrive as a driver-interpreted `params` map rather than new typed fields, so the shared schema does not grow one dialect's vocabulary. Context size, gpu layers and threads become server flags; temperature and the rest ride on each request, so changing them need not reload a model. **Also fixes an orphan this feature would have created.** Drivers set kill_on_drop, which covers a session being deleted -- but nothing drops on the way out of a SIGTERM, so signalling the server left its children running. For the Claude CLI that is untidy; for a llama-server holding a model it is gigabytes belonging to nobody. The server now stops its sessions on SIGTERM and SIGINT. Found by killing a test server and noticing two 600 MB processes still resident. Remote llama sessions are refused rather than half-working: the model is reached over HTTP, and forwarding that port to an ssh host is the "reach this port" operation the transport does not have yet. Verified end to end against a real model: downloaded Qwen3-0.6B Q8_0 through the app's own download route, spawned a session on it, and held a two-turn conversation -- "my favourite colour is teal" then "what is my favourite colour?", answered "teal", which is the transcript replay doing its job. Token counts arrive. An earlier attempt with the IQ2_XXS quant produced fluent nonsense, which turned out to be the quantisation rather than the pipeline: llama-cli produces the same from that file directly. Four unit tests cover the fold and the path guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw |
||
|
|
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 |
||
|
|
65743b899d |
Generate the TLS certificates in process
gen-dev-cert.sh is gone. The server ensures its own certificates on start, which removes a setup step to remember, a dependency on whatever openssl was installed, and a second place for the "which addresses?" answer to live -- the leaf now covers every local IPv4 plus loopback and the emulator's host alias, so nobody maintains a hardcoded IP. The split that mattered in the script is kept and now enforced by tests: the CA is generated once and left alone, because the app pins it and replacing it strands every installed copy; the leaf is cheap and reissued every start, so covering a new address is a restart. Both are written owner-only into a directory outside the repo. Two things the tests caught. DirBuilder's mode applies only when the directory is created, so a directory that already existed kept whatever permissions it had while holding a private key -- the mode is now set explicitly, in the session directories too. And loading the leaf into the real RustlsConfig needs the crypto provider installed, which main does but tests don't. Verified end to end: deleted the certs, started the server, watched it generate a CA and warn that installed apps now pin the wrong one, rebuilt the APK against the new CA, and reinstalled -- the emulator connects over a certificate that never existed as a pasted constant. 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 |
||
|
|
967fc814ab |
Phase 1 server: TLS + token auth, session registry, EchoDriver, SSE with cursors
The whole pipe behind one Driver trait and a common event model: spawn/list/delete sessions, message + question answering, append-only JSONL transcripts whose sequence numbers are the phone's resume cursor (surviving backend restarts), bearer-token middleware wrapping every route including the fallback, wg0-only binding that fails closed, and first-run token enrollment via a terminal QR. Verified: cargo test (10), clippy clean, and curl end-to-end over pinned TLS -- auth rejection, spawn, streamed SSE replay/resume, /question round trip, restart continuing seq numbers, delete removing everything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw |