Clearing out imported sessions was one confirmation dialog per row, which
is why it was not worth doing. Holding a row on the import screen now
selects it and plain taps add more; Delete and Import act on the whole
selection from a bar along the bottom.
Submitting hands the work over and puts the screen back as it was: the
selection clears, the bar goes, and what says the work is happening is the
rows it is happening to -- the one in flight marked with its operation, the
rest marked "waiting". Both are inert, so a queued row cannot be tapped
into starting a second CLI behind the batch already coming for it. Rows
leave as each one lands rather than all at the end, because a finished row
still sitting there looks exactly like one that was never imported; the
rows below it therefore move, so a row that has just moved ignores taps for
half a second.
That busy appearance is one composable shared with the session list, which
had its own dimmed row and its own word for it. It is a word rather than a
bare spinner because deleting and importing differ in kind.
Deleting a session can now take the machine's own transcript with it, as a
switch in the confirmation and only where the driver keeps a record this
app's delete cannot otherwise reach. Off by default, since leaving that
copy is what makes an ordinary delete recoverable -- and the paragraph is
rewritten rather than appended to when it is on, because the sentence
promising the conversation is still there to import again is exactly the
one the switch makes false. The server removes the machine's copy first, so
a machine it cannot reach leaves the session where it was.
`app/ui-sandbox.sh` is how all of this was driven: a second server with its
own $HOME, invented transcripts and a two-line `claude`. Against the
ordinary server, testing delete deletes somebody's conversation and testing
import spends a turn on a real account.
Gradle's install and connected-test tasks never went through that wrapper,
so they reached every attached emulator. emulator-tools now guards them with
a Gradle init script; this records what that means here, and how to name a
device without exporting a serial that goes stale.
Also stop naming one checkout's AVD in a file that is the same in every
clone -- it read as a suggestion to target ai-app-2 from ai-app.
A pass over every Kotlin file, fixing each place a rule the codebase had
already learned was applied to only one member of its set:
- Notifications: checkSelfPermission(POST_NOTIFICATIONS) on Android 12 and
below answers "denied" for a permission that does not exist there, so
every notification on API 24-32 was silently dropped. Version-guarded;
before 13 the app-level switch is the whole answer.
- The permission-mode list was written three times and had drifted: the
import screen was missing "plan". One list in Api.kt now.
- The import screen's delete refetched the whole list through a loading
spinner -- the exact fault the session list's delete already fixed and
documented. It now removes only the deleted row.
- The import screen truncated paths at a hardcoded 40 characters; it now
uses StartEllipsis against the row's real width, like the models screen.
- warm() bypassed the partsOf cache built for it, re-scanning every loaded
message per page, and warmed a multi-block memory note under keys no row
looks up. It now mirrors transcriptUnits through the same caches.
- The transcript's data model (TranscriptItem, foldEvent, joinPages, warm)
moved out of SessionScreen.kt into TranscriptItems.kt: pure folding with
no screen in it, changing for unrelated reasons in the same file.
- One image fetch/decode/failed block was written twice; it is
rememberSessionBitmap in SessionImage.kt now.
- Lint is fully clean: android.media.ExifInterface replaced with the
androidx one (the framework copy lacks the hostile-image parsing fixes,
and these images arrive from outside the phone), highlights bumped to
1.1.0, and the notification fix above closed InlinedApi.
- Dead weight out: an unused act() onFailure parameter, and five orphaned
or misattached doc comments (UserBubble carried SessionImage's doc).
Verified: ktfmt, compileDebugKotlin, lintDebug (0 issues), server suite
(93 passed), clippy and rustfmt clean; exercised on the emulator against a
real imported transcript -- paging, tool groups, block rendering, no
crashes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five-hour window's reset time is absent between blocks, because the
window is anchored to the block it started in and there is nothing to reset
until one is running. Measured against a live response: the reset came back
as exactly five hours after work resumed, and the weekly windows in the same
response carried the identical microsecond, so both are computed from one
now() at request time. The weeklies always have a reset because a week is
always running -- which is why only the five-hour row looked wrong.
`remainingUntil` returned null for that and for a timestamp it could not
parse, so the session bar announced "reset time unknown" about a machine
behaving perfectly, on the one row somebody reads before starting something
big. The usage dialog, looking at the same field, drew nothing at all and
printed a raw ISO string when a parse did fail. One missing value, two
rules, and neither of them right.
`WindowEnd` names the three answers and both callers go through it. A window
that is not running shows its percentage and no countdown, in the bar as
well as the dialog; an unreadable timestamp says so in words rather than
showing itself.
Looked at both on the emulator, the second by making the server drop the
field: 15% with "4h 43m left" when a block is running, "13%" alone when none
is, and the dialog's five-hour row with no reset line beside weeklies that
have one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two places said the history cushion's evidence was a swipe that moved
nothing for 689ms. That reading came from `ui-trace show` on a row taller
than the viewport, which reports clipped bounds and so prints "nothing
moved" for a list that is scrolling fine -- and it was taken on the software
renderer besides. The finding it supported is sound and has a better
witness: counted at the server, ten swipes asked for ten pages before the
change and three after, which does not depend on how anything renders.
Also records what the screen actually costs now, measured on the GPU
emulator: 5.2-5.9% janky frames and 0-2 slow UI-thread frames flinging fast,
against 3.3% for the stock Settings app on the same device. With the note
that matters for repeating it -- let the screen settle before resetting
gfxinfo, because the first seconds after opening a session are every row
being composed for the first time and read three times worse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three entries under "Things that have bitten": the whole-file read behind
every page, the fact that a page is events and a screen is rows with no
fixed ratio between them, and the withContext that wrapped the fetch and
left the work done with the result outside it. Each carries the number it
was measured at, since the shape of all three is that the cost grows with
the conversation while the answer stays one screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--throwaway-sessions, on by default in a debug build. Every session a
server started with it spawns is marked throwaway in the config, and a
marked session's process is stopped when the server exits or is
signalled, rather than left running for the next start to adopt.
Leaving processes running is the design and it is right for the sessions
somebody is using. It is exactly wrong for the ones a test made: those
leave a claude behind that every later server adopts, and nothing ever
says they are there -- twelve accumulated on this machine in a day, each
holding a conversation open.
The flag marks; the mark decides. What a server was told at startup
governs only the sessions it spawns, and the mark is the session's own,
so a session spawned deliberately keeps running whichever server is up
when one exits, and a throwaway one is cleaned away even by a server
started without the flag.
process::wait_gone does the waiting on the way out, because
process::stop leaves its SIGKILL on a tokio timer and a runtime that is
shutting down never runs it -- which is how the original shutdown_all
leaked the processes it reported stopping.
Its test found a second thing, in the same field the last commit was
about: a zombie read as Alive. /proc/<pid>/stat keeps the entry, with
the same pid and the same start time, until the exit status is
collected, so a process that had plainly finished answered "still
there" -- and Alive is the word that makes Exited unsayable, so the
session shows unknown, its Start button never appears, and Stop says
there is nothing to stop. stat_of reads the state field alongside the
start time now.
Exercised against a real server: a keeper spawned with the flag off
survives its server's exit and is adopted by the next one, a session
spawned with it on is stopped on SIGTERM within 20ms, and the
"left N running" line counts what is actually still out there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the ai-app-2 session, which used it to find a clock bug the tests did
not have. A `claude_cli` provider pointed at `#!/bin/sh` / `cat > /dev/null`
behaves the way the lifecycle code cares about -- holds the fifo open, records
a real pid, writes nothing, dies on a signal -- so adopt, stop, restart and
start are drivable without a real `--resume` and without spending a turn on
somebody's account.
Written down beside `debug-transcript.sh` because the two answer different
questions and the wrong one is expensive: this for whether a process is
running, that for what the transcript draws.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restarting the server relaunched a driver for every session in the
config, and ClaudeDriver::launch starts a process when there is none to
adopt -- so a session somebody had deliberately stopped came back at the
next rebuild, and the Idle its new driver announced stamped the session
as active at that moment. On the phone that read as every session idle
and "just now" after every restart, with the list sorted by that time in
an order that meant nothing.
A launch now says why it is happening. Launching::Restart takes charge
of the processes still running and leaves every other session as it
found it; Launching::Asked -- a spawn, a Start, a message -- starts one
where there is none. A session with no process therefore has no driver:
DriverCell is an option rather than a driver whose requests go nowhere,
and LiveSession::ask reports what could not happen instead of sending
into a dead fifo.
Two clocks that moved on their own, both the same lie in the same field
that Transcript::last_activity exists to prevent:
- The status a launch has to correct is written into the transcript at
the time of the last thing the session actually did. A backend killed
mid-turn leaves a transcript saying Running, which has to become
Exited -- but this server noticing is not the session doing something.
- A session that has never done anything reports when it was created. Its
transcript is empty, since a driver announcing the state it starts in
is not news, so it is the one session with no line to read a time off
and the clock was the fallback.
Exercised end to end against a real server: a stand-in CLI adopted
across a restart keeps its status and its time, a stopped session stays
stopped with no process started, a process killed while the backend was
down reports exited stamped at the last thing the session did, and Start
brings it back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both faults needed a real conversation to see, so `app/debug-transcript.sh`
now puts one on the emulator: it copies a Claude Code transcript into /tmp,
gives an ai-server a HOME of its own so the import can only see the copy, and
enrols the app against it. The transcript itself never enters this repository
-- those files hold whatever was said, read and written in a session. Beside
it, `ai-server --delay MS` holds every response back, because a phone's
requests take tens to hundreds of milliseconds over the tunnel and several
faults live entirely in what the app does while one is outstanding.
**Scrolling up threw the reader back to the newest end, once.** `followTail`
is deliberately a remembered answer, rewritten only when a scroll settles, so
for the whole of a fling it still reports the newest end -- where the reader
was when they threw it. A page of history landing during that fling is a
change in the item count, and the correction written for an insertion at the
newest end fired for one at the oldest. Captured on the emulator:
scrolling=true atNewest=false followTail=true
history: START last=20 total=28
history: page of 80 events -> rows now 36
countChanged count=36 followTail=true scrolling=true
>>> scrollToItem(0) SNAP
It could happen only once, which is what made it look arbitrary rather than
mechanical: the snap settles the scroll at the newest end, so the next fling
gets far enough to settle away from it, and from then on `followTail` is
false. So the list is no longer moved while a scroll is running, which is a
rule of its own rather than a refinement of that condition -- and skipping
the correction outright is right rather than merely safe, because the count
can only grow at the newest end while the reader is already there, `record`
holding everything else until they come back.
**A page of history stalled the frame it appeared in.** Parsing is the
expensive half of drawing a reply and costs in proportion to what was
written: against this transcript one message took 51ms and several took
10-25ms, where the synthetic replies this was tuned on took 4.6ms. So each
page's replies are parsed on a background thread as the page arrives --
after the join, since a boundary falling through a reply leaves a message
made of both halves whose text has existed for no time at all, and warming
the page alone warmed the two halves and missed the one thing drawn. A row
with no answer waiting still parses inline: a row measured at nothing before
it is measured at its real height collapses the transcript above it. Misses
are not stored, so a reply still streaming cannot fill the map with copies
of itself on the way to being finished.
Measured over the same twelve flings: 13.5ms average per composed reply
before, 7us after, the remaining parse being one message at session open.
Verified with ui-trace at 1kHz: with a page landing mid-drag the suppression
fires and the row the reader is on moves monotonically down, 266 -> 1063,
with no step backwards; at rest 0 of 65 elements move. 86 server tests pass,
ktfmt/lint/clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last commit left renaming out on the grounds that the name is
persisted and listed whether or not a process hears about it. That was
wrong, and Iris said so: Claude Code keeps its own copy of the name,
that copy is what its session picker shows and what other agents read
when they list sessions, and a session is only ever *given* a name at
birth -- every later start is a `--resume`, which passes no `--name`.
So a rename that reached no process left the two lists disagreeing
permanently, with this app's the only one that had moved. The cost of a
resume buys the one thing renaming is for.
It stays `rename_session` rather than becoming a command like the rest,
because the name is persisted and listed as well as forwarded and that
is one operation. The save happens first and the lock is dropped before
the telling, so a failure to start reports that the telling failed
rather than the rename, which by then has already happened.
`LiveSession::run_command` went with it. It read `shared.status` and
that read is exactly what a just-started session cannot be judged by, so
every caller now goes through the manager -- which is also what the four
tests that used it were standing in for.
Verified against a stand-in CLI that echoes its stdin: a session
reporting `exited` was renamed, the process started with `--resume`, and
the CLI received `/rename after the restart` on stdin. The list shows
the new name and the session reports idle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same reasoning as the message path a commit ago, and the same objection
to leaving it out: a command is something somebody asked the session to
do, and answering "its process has exited" hands back the work of
starting one. `/compact` on a stopped session is the case that shows it
-- what is being asked for is exactly what a stopped session needs
before it is useful again.
`POST /sessions/{id}/command` and `/compact` now go through
`SessionManager::run_command`. A rename is deliberately not one of them:
it is persisted and listed whether or not a process ever hears about it,
so starting a CLI to tell it a name would be spending a resume on
nothing. It stays a forward to a process that happens to be there.
A command needs one thing a message did not. `Commands::submit` refuses
on `Exited`, and a driver that has just started a process announces
`Idle` through the sink rather than writing it -- so a command judged
against the session's own status would be refused by the word the start
had just replaced, in a window narrow enough that only a test reliably
hits it. `start_if_exited` returning `Exited` is what says a process was
started, so the status the command is judged against comes from there
rather than from a re-read the pump may not have caught up with. The
test fails without it.
`LiveSession::compact` went with this: `/compact` the route and
"/compact" the typed command were two ways to the same command, and now
there is one.
Verified over the API against a stand-in CLI: with the session reporting
`exited`, both `/clear` and `POST /compact` started the process and were
delivered -- transcript order `idle`, `commandSent`, `running`, `idle`,
with no "this session's process has exited" anywhere.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refusing was work handed back: read the status word, find the other
button, press it, type the message again. Sending plainly means "do this
now", and `--resume` puts the new process on the same conversation, so
nothing about the message changes -- only whether there was anything
there to read it.
`POST /sessions/{id}/message` now goes through the manager, which starts
a process first when the session is known to have exited. Only on
`exited`: `unknown` has a process that may well be reading its fifo, and
starting a second CLI on that guess is the fault `session::process`
exists to prevent, so the message goes to the driver as it always did.
The Start button and this ask one function, `start_if_exited`, and want
opposite answers from it -- "there is already a process" is a refusal
worth showing to somebody who pressed Start, and nothing at all to a
message being sent. Deciding it in one place under the one write lock is
also what keeps two requests that arrive together from starting two
CLIs.
Verified over the API and on the emulator: with the session reporting
`exited` and the composer showing a play button, typing a message and
pressing Send started the process, delivered the message and ran the
turn -- transcript order `idle`, `userMessage`, `running`, `idle` -- and
the button became a stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A session adopted at a backend start keeps the transcript's last status,
so one whose process had been reported gone and was then found again
read as `exited` while its CLI was running. `exited` is the word that
draws the phone's Start button and lets `start_session` build a driver,
so Start was accepted every time it was pressed -- and since starting
replaces the driver without retiring the old one, each press left
another reader on the same process. Every line the CLI wrote was then
translated once per reader: three presses put three interleaved copies
of one reply on screen, which is what it was reported as.
So `exited` is now checked against `session::process`, the one authority
on whether a process exists, in `launch` and again in `start_session`. A
record that is not known to be dead makes it false, and what replaces it
is `unknown` -- there is a process, and nothing here has heard from it,
which is the answer `status_of_unlaunched` already gave to the same
question. The correction goes out through the sink rather than into the
manager's view alone, or the list and the session screen would disagree
about it in the way this same button did a commit ago.
A driver that `start_session` replaces now gets `Driver::detach`, which
already existed for the backend going away and is the whole of what a
driver whose process has exited is owed.
On the phone the process button is disabled while its own request is in
flight, so a second press cannot be decided against a status the first
has not changed yet. That is a courtesy rather than the fix; the server
refuses it either way, because a phone that has lost the stream cannot
be relied on to know.
Verified against a stand-in CLI, with the state forced by hand: before,
three Starts returned 204 and left four readers on one process and the
status still `exited`; after, the session reports `unknown` on both
surfaces and all three are refused. Then driven on the emulator --
Stop, Start, Stop, Start alternated correctly with one process at a
time, and the list, the transcript and the record all agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which AVD this checkout means, creating it, booting it headless and refusing
to start one the machine has no room for is the same sequence in ai-app,
ai-app-2 and dev-updater. It now lives once, in ~/repos/emulator-tools, and
this script is what is actually specific to this project: a build, an install
and a launch.
Three copies of "boot an emulator" was three places for the memory check none
of them had -- starting one at 2.8 GB available invoked the OOM killer, and
what it took first was another session's emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer's second button now says what pressing it would do to the
process behind the session, in one place that is always there: an orange
pause while a turn is running (interrupt, the process stays), a red stop
when it is not (end the process), a green play when it has exited (start it
again on the same conversation). Send is disabled while there is nothing to
send, rather than pressable and silent.
Behind it, two routes. `stop` signals the recorded process and says nothing
else -- the driver's own reader already reports a death correctly, and
announcing it here would be a guess ahead of the measurement. `start`
replaces the driver and nothing else, so the transcript, the pump and every
open phone's stream stay where they were and there is still one writer of
the transcript; it is refused unless the session is known to have exited,
since starting on `Unknown` is the two-CLIs-on-one-conversation fault.
That last rule found a bug in the launch path: a relaunched session took its
status from the transcript, so one whose process had died before a backend
restart reported `exited` while the launch had just started a new process --
which refuses every command and offers a phone the chance to start a second
CLI on a live conversation. A launch that leaves a process running now says
idle.
The icon font moves to the Mono face, where every glyph is one em square, so
two icon buttons are the same width without either being told one; the
proportional advances ran 0.46 to 0.92 em and Send came out visibly wider
than Stop. GLYPH_SIZE comes down to match, since a glyph that fills its em
draws bigger at the same point size.
Verified against a stand-in CLI on the emulator: idle -> stop -> exited ->
start -> idle, a turn interrupted from the pause button, and both buttons
measured at 171x105 device pixels.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notification stream now has three places to land instead of two, decided
in one function. Nothing at all for the session on screen, as before. A
banner over the app while the app is up. Android's drawer otherwise. Never
two of them for one moment: a drawer filling up behind an app that showed
you each one is a drawer nobody reads.
The banners queue, one per session replacing that session's own -- the rule
the drawer already followed, and for the same reason. Each can be tapped,
which opens the session by the same path a tapped notification takes;
pushed off either side; or left alone, in which case the bar across its foot
retires it. The bar and the retiring are one value rather than a bar beside
a timer, so a banner cannot outlive the countdown drawn under it. They clear
when the app goes away, since a claim that a session wants somebody *now*
does not survive an absence -- and the drawer has the job back by then.
Which of the three applies needs no flag anybody keeps level. The session on
screen is registered by the one composable that draws one, and "the app is
up" is the queue being collected, which happens exactly while it is.
Also: tapping a model or permission button while its own menu is open now
closes it. A non-focusable popup does not swallow the press that dismisses
it, so the same finger was reopening what it had just closed -- measured at
3ms between the two, which is what the guard is sized against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`run-android.sh` derives its AVD from the checkout, so two clones of this
repo run two emulators, and with both attached a bare `adb` call stops
working. The failures do not say so: `adb shell` and `adb get-state` fail
with `more than one device/emulator`, and `adb shell pm list packages`
comes back empty -- which reads as the app having been uninstalled, and
sent dev-updater's session looking for a wipe that had not happened. Its
enrolment script mis-diagnosed the same ambiguity as "no emulator is
running", whose advice is to start a third.
The enrolment line in this file was itself a bare `adb shell`, so it was
the instruction that would have produced the confusion.
Six things Bryan asked for, which turned out to be one change: the app had
no icon set, so every one of them was blocked on having somewhere for icons
to come from.
That somewhere is dev-updater's arrangement, ported: a Nerd Fonts subset
committed as an asset, drawn as text. `Gear.kt`'s hand-drawn canvas gear
argued against icon fonts because a system font may not have the glyph and
whoever gets the empty box is never the person who wrote it. The objection
is right about *relying* on a system font and the answer is to ship the
glyph, so the file is gone and its reasoning is restated in `NerdIcons.kt`
rather than deleted -- otherwise the next reader re-derives it. `md-cog` and
`md-refresh` are dev-updater's own codepoints, because a cog means the same
thing in both apps.
The root screen's four words under the title are now four tabs, and the two
that act on the whole screen -- settings and refresh -- moved up onto the
title row as glyphs. That row's old comment recorded that a fifth word would
have had nowhere to go; tabs also say something the words did not, which is
that sessions, import, models and setups are four views of one backend
rather than four errands. Refresh feeds whichever tab is showing. Import,
models and setups lose their headings and their Back buttons, since the tab
row is now both.
Usage is a dialog. It is checked *against* what you were reading -- "can I
start this" is asked with the transcript still on screen -- and it had no
navigation of its own, so the only thing its Back could mean was "put this
away". The button that opens it is a chart glyph coloured by the worst of
the machine's windows, so the row says whether the limits are worth opening
before anybody opens them.
One `quotaColor` now colours every bar that measures a quota: blue, yellow
at 75%, red at 95%. The session bar escalates where it used to sit blue at
every level, and the dialog's thresholds moved out of it. A download keeps
plain blue at every value -- it has no limit to approach, and colouring it
like one would say the opposite of what is happening. States that are not
measurements take the ordinary control colour, since blue is the low end of
this scale and would read as "checked, and fine" about a machine nobody
could reach.
Send and stop are the filled paper plane and the filled square. Send keeps
the word "Queue" while a turn is in flight, because that is what pressing it
then does, and an icon that does two things while looking identical would
promise something immediate and do something that waits.
Looked at on the emulator: all six glyphs render, the tabs and the system
back gesture between them, the dialog over a live session, and the bar
bands at 82% and 97% forced through a scratch build, since this account is
at 72/31/5 and would only ever have shown blue.
The bar read "31% of 5h", which is the one thing about the window a
reader already knows. What decides whether to start something now is how
long what is left has to last: 80% with twenty minutes to go and 80%
with four hours to go are opposite answers, and the second number was a
screen away on the usage screen.
It now reads "31% - 2h 36m left", and the countdown is driven by a clock
the refresh loop advances rather than computed at draw time. A
percentage that comes back unchanged is an equal value, so Compose skips
the recomposition -- a "left" recomputed only when the quota happens to
move would have sat at a stale figure for hours while looking live.
A window can arrive with no reset time, so that keeps its own wording:
"reset time unknown" rather than "refresh soon", which would be a
recommendation nothing measured. Under a minute, including past the end,
is "refresh soon" -- "0m left" reads as a measurement.
The span arithmetic was already on the usage screen, so it moves into
`ResetCountdown.kt` and both callers supply their own sentence. That
screen still reads "resets in 2h 37m" and "resets in 5d 21h", checked on
the emulator alongside the bar it was not part of changing.
The fill is blue rather than the scheme's primary: the bar sits under
every session header, on a screen somebody opened to do something else,
and it reports a quantity rather than a verdict. The usage screen is
still where the same number turns yellow and then red, for a reader who
went there to be told where the limits are.
Also declares this project's resources for Dev Updater, whose
declaration schema changed in d27b5a3: `resources.ron` says ai-app keeps
its state as `ai-app`, so the Uninstall dialog offers the real
directories instead of saying it cannot tell where they are. Only the
name, because both XDG places are the conventional ones. What that
dialog's config toggle would delete includes the CA under `certs`, which
strands every phone running an APK pinned to it -- noted where somebody
would be standing when it matters.
Measured: an app-spawned session starts at ~33,200 tokens of context, of
which ~20,000 is written fresh on every spawn -- the always-loaded rule
files and this file -- and only ~13,200 comes from a shared cache. That
20,000 is billed at 1.25x on every single session start.
This file drops to 19,882 bytes from 21,293. What went is narrative that
PLAN.md already carries in more detail (the phase history, the submodule
drift story) and the parts of "Where things run" that MACHINE.md states
once for every project. What stayed is every operational fact: the
commands, the llama.cpp and ssh test recipes, the import rules, and
everything under "Things that have bitten".
The global chain was trimmed in the same pass, 43,039 -> 34,069 bytes,
mostly by moving the Gentoo host build profile out of the @import chain
into ~/.claude/HOST_BUILD.md, which MACHINE.md now points at. Nothing was
deleted there either; it is referenced rather than loaded, the same
arrangement this file has with PLAN.md.
Worth being honest about the size of the win: ~10,400 bytes is roughly
2,200 tokens off each session start. It is real and permanent, but it is
not what makes a long session expensive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
READ BEFORE PULLING. A Managed component's service unit is named
<config key>-<component name>, so this rename moves the unit from
ai-app-backend to ai-app-server and nothing points at the old one
afterwards. Uninstall the backend component from its card *first*, while
it is still called "backend"; then pull, accept the new declaration --
.dev-updater.ron is a request, so the card shows it as pending -- and
build. The unit installs under the new name.
Two things reset rather than break, both keyed by component name: the
per-component built_from sha, and the build and runtime logs. One build
makes the sha current again.
Also drops the claim that the components list is walked in order. They
have built in parallel since 2026-08-28, so the reasoning the comment
gave -- backend first, so a failing APK leaves the phone what it had --
no longer describes what happens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
Written down as "an ssh session's child dies with its connection, so it
takes the ordinary --resume path". The code never had that branch: `start`
records a pid whatever the transport, and for a remote session the process
the backend owns is the ssh client. Adopting it is right -- the fifo feeds
it, its logs capture the far end, and ssh lives exactly as long as the
remote command, so its liveness is the session's.
The docs claimed less than the code does, which is the safe direction to be
wrong in but still wrong, and it was about to mislead someone: a remote
`claude` has an sshd pipe on stdin under every version of this server,
because the fifo is on the backend's side of the connection. Reading a
remote session's stdin therefore says nothing about which backend started
it, and we were an inch from concluding otherwise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
The server now reports limits per machine, so the screen has to as well:
one card per machine that offers a paid service, named by the machine
first, because these are one account's numbers and which account is decided
by which box ran the session.
It also has to say which of four things happened, and the reason for
splitting them shows up here rather than in the data. A machine nobody has
logged in on is working exactly as somebody set it up, so it reads as a
plain statement in ordinary text -- marking it would be the interface
nagging about a decision already made, and would dilute the marks that do
mean something. Only "couldn't reach it" and "the endpoint refused" are
coloured as faults, and they say different things because they need
different things done. The old screen drew all three in the error colour.
No machine offering a paid service is not an error either: it says so
instead of drawing nothing.
The app also stopped parsing: `available` no longer exists and
`getBoolean` on a missing key throws, so this had to land with the server
change rather than after it. An older backend sending no `state` is read as
"failed" rather than "ok", since an empty card drawn as healthy is the
worse failure.
Looked at running, against five machines: local reporting notLoggedIn with
the backend's HOME emptied, loopback-over-ssh returning real windows beside
it, an unreachable host showing ssh's own message in red, and a machine
with no Claude provider correctly absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
An imported session showed no screenshots. `text_of` kept only `text`
blocks, so every image in the replayed tail was silently discarded -- while
the *live* translator has always saved them into the session's `files/` and
referenced them. Two readings of the same records, and the one used for
history was the lesser.
`save_image` moves out of `Translator` to a free function both paths call,
since the naming scheme for that directory should exist once. `events_from`
now takes the session directory to write into, which means the conversion
has to happen where that directory exists -- so `Seed` carries the raw
JSONL and `launch` turns it into events, rather than `routes` doing it
before the session is created.
Costs nothing in tokens, which is the point worth recording: this writes
into ai-app's own session directory and the phone fetches a reference only
when it draws one. Nothing here is ever written to the CLI's stdin -- it
reads its own session file, and the only things this app sends it are typed
messages, control requests and `/compact`.
Verified against the 133 MB session behind the 2026-08-29 incident: 45
images in the replayed tail, written as real PNGs and served over the files
route, with the transcript itself staying at 756 KB of references.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
The import list reported a line count, which is the wrong axis: these
transcripts embed screenshots as base64, so one line can be a megabyte.
On this machine a 69 MB session has 3,427 lines while a 44 MB one has
6,792 — the number on the row said nothing about what continuing the
session would cost, and size is the only thing there that predicts it.
The session behind the 2026-08-29 incident was 65 MB across 13,000 lines,
a line count that looks unremarkable.
Shown beside the line count rather than instead of it, since a short file
of long lines is exactly the expensive case. Not warned about and not
marked: importing a large session is a choice somebody is entitled to
make, and flagging it would be the interface nagging about a decision
already taken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
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
The reasoning belongs where somebody would look before changing the poll:
status is the obvious discriminator and is wrong, and the failure it
produces reads as the model repeating itself rather than as a bug.
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.
Re-applies the change reverted in d5a0f67. It was right then and only
mistimed: something was reading this working tree live, so deleting the
script took the backend card to "couldn't check" immediately rather than
on a pull. Iris has now uninstalled the service while the script was still
the declared one, which is the step that stops the old unit being orphaned
under a name nothing points at any more.
`Managed` runs the command with dev-updater's built-in service script,
resolved against the component's cwd. ai-app's own script was the generic
case exactly -- ExecStart=$BINARY with no arguments and no environment --
so it was 233 lines of init-system detection kept in step with an
identical copy next door.
Deleting it loses nothing, and that was checked rather than assumed: all
three findings the test guest produced today are in the built-in --
reading OpenRC's status exit code instead of grepping text it prints to
stderr, treating an uninitialised user softlevel as "couldn't find out"
rather than as stopped, and assigning through `|| code=$?` so `set -e`
cannot kill the script before it reads the code.
The one thing the script said that the built-in cannot is already in
AGENTS.md: Stop on this card takes down the server a phone reaches through
the tunnel, while Dev Updater itself is unaffected because it uses its own
port.
Install from the backend card after taking this.
Reverts the switch to `service: Managed(...)`. The switch is still right
and the reasoning in that commit still holds; what was wrong was doing it
now, unilaterally, to a checkout something is reading live.
A dev-updater is running against this working tree, so deleting
`server/service` did not wait for a pull to take effect -- the backend
card went to "couldn't check -- failed to run the service script: No such
file or directory" immediately, and the pushed declaration still names the
script, so the tree and the declaration disagreed in the one direction
that breaks things. My own commit message had said this change was not
safe to pull blind; it turned out not to need a pull at all.
The switch needs three steps in order, and only the middle one is mine:
Uninstall from the backend card while the script is still declared, then
take the change, then Install. Re-apply when Iris is ready to do that,
which is also when dev-updater's conversion path can be deleted.
dev-updater now carries a built-in service implementation, generated from
a template and driven through the identical interface a project-supplied
script uses, so a project whose service is unremarkable no longer writes
one. ai-app's was unremarkable: `ExecStart=$BINARY` and `command="$BINARY"`
with no arguments and no environment. 233 lines of it, and the half that
matters most -- the OpenRC branch, which neither project can exercise from
a systemd machine -- existed twice, so a fix found by testing would have
had two places to land and no way to notice the second.
The field keeps its name; `Managed` takes the command, resolved against
the component's `cwd`.
The one thing the script said that the built-in cannot is kept, in
AGENTS.md rather than lost: Stop on this card takes down the server a
phone reaches through the tunnel, while Dev Updater itself is unaffected
because it uses its own port -- which is exactly what makes that button
easy to press and easy to regret.
NOT SAFE TO PULL BLIND. A managed service is named after the component, so
this one becomes `app-backend` while the installed one still has the name
the script gave it. Uninstall from the backend card *before* taking this
change, then Install after; pulling first orphans a service that stays
enabled and starts at boot with nothing pointing at it.
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.
The status section still said "server side" and "no app screen yet", both
of which stopped being true today. Also records the discovery trade that
would otherwise be rediscovered: command -v follows a non-interactive ssh
PATH, so llama.cpp unpacked into ~/.local/opt is invisible until it is
symlinked onto PATH.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
Phase 4 is no longer deferred and phase 5 is exercised, so the status
section says so. The two decisions worth not undoing by accident get
named: the conversation lives in the transcript rather than the driver,
and a llama session is refused on an ssh host rather than half-working.
Also the local testing recipe, including the trap that cost me twenty
minutes -- a 2-bit quant produces fluent nonsense that reads exactly like
a broken driver, and llama-cli on the same file is how to tell the two
apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The status section had carried an open question since phase 3: SSH could
not be exercised in this VM because there is no second machine and no key
in ~/.ssh. There is a second machine, though -- this one. Ssh it to itself
with a throwaway key and a host of bob@127.0.0.1, point the provider's
command at /bin/echo rather than claude, and the whole path runs:
connection, remote exec, and the process's death arriving as
`status: exited` in the transcript. It costs no tokens and touches nothing
real, and the key comes back out afterwards.
Done that way just now against the new transport, so the technique is
written down as something that worked rather than something that should.
Also recorded: the login shell in this VM is fish. `cd '…' && exec '…'`
is valid there and the POSIX single-quote escaping happens to mean the
same thing, but both are luck, and a non-POSIX remote shell is the first
thing to suspect if an argument is ever mangled on the way over.
Phase 4 is no longer deferred -- Bryan asked for llama.cpp today -- so the
status paragraph stops saying it is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
AGENTS.md's "Checking your work" listed a typecheck for the app and tests
plus clippy for the server. Neither half mentioned a formatter, and nothing
mentioned Android Lint at all -- which is how a linter that was in the
build the whole time went unrun long enough to accumulate a crash.
Both lines now say the whole thing, and the app's reads as the counterpart
of the server's rather than a shorter version of it. The note about lint is
there because it is the one step a build does not do for you: nothing fails
if you skip it, which is exactly why it needs writing down.
The command in the app bullet is the one I ran to verify this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
A doc comment is not compiled, so nothing catches one that has outlived
what it described. `cargo doc` does catch a subset, and it was failing:
three unresolved intra-doc links in config.rs, all mine from today -- two
naming `parse`/`render` from outside the module that defines them, and one
`format` that is ambiguous with the macro. Rustdoc now runs clean with
broken_intra_doc_links denied.
auth.rs's module doc pointed at a test called `token_is_never_logged`,
which no longer exists: it was folded into the combined gating+logging test
because tracing caches callsite interest process-wide. The doc now
describes the test that is there and says why it is one test.
AGENTS.md contradicted itself twice, both checked against a running server
rather than by reading. Its curl example passed `--cacert certs/ca.pem`, a
path relative to the checkout, while the bullet above it correctly says the
CA is generated under $XDG_CONFIG_HOME -- so the documented command fails
before it connects; with the real path it returns `[]`. And it said wg0
"doesn't exist on this machine yet" as the reason for --bind 127.0.0.1,
which the section below it already contradicts: wg0 is up at 10.66.0.1, and
the actual reason is that the emulator dials 10.0.2.2 and cannot reach it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
CMP 1.11 deprecating the `compose.*` accessors and AGP 9 refusing a
`Provider` in the source-set API are facts about those versions, not about
this app -- they would bite any Android project on this machine, and the
one that hits them next is unlikely to be reading ai-app's notes. They now
live in ~/.claude/TOOLCHAIN.md, each stated against the version it was
learned on so it can be deleted when nothing here is on that version.
What is left under "Things that have bitten" is genuinely this project's,
and the section now says so, with the two global homes named -- so the next
lesson lands in the right one instead of here by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
AGENTS.md had grown a description of the machine these sessions run on --
the two boxes, the qemu networking, the shared ~/repos mount, gitea and the
push permission, the Android SDK path, the shared emulator, how to detach a
server. None of that is about ai-app, and all of it is equally true in the
sibling repos, so each copy was a place for the truth to drift: the push
note in this one had been wrong since the bot key was authorized, and the
checkout path it named had moved.
It now lives once in ~/.claude/MACHINE.md, which every session already
reads. What stays here is only what the machine means for this project: that
ai-server belongs on the host because WireGuard terminates there, that the
tunnel can never terminate in the VM, that the claude CLI being VM-only
makes it a remote to the backend, and where this project's secrets live
instead of the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The note saying otherwise predates the iris-ai bot account being authorized
for this repository, and a stale "you cannot do this" is worse than no note
at all -- it sends the next reader looking for a workaround that isn't
needed. The checkout's path had drifted in the same sentence: it is
~/repos/ai-app, not ~/host/repos/ai-app.
What the correction does not change is the posture. The host builds and
runs from its own clone outside the shared mount, so a deliberate pull
there is still what stands between this VM and the host -- it just isn't a
push permission doing that job any more.
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
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
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
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