Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."
Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:
client-core -> app-rust/src/client
iris/transcript-ui -> app-rust/src/ui
iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/
iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs
iris/android-app -> app-rust/src/android + android-project/
android-shell -> app-rust/src/shell
iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.
Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.
DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.
Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Iris's call once the upload route was working: put it in Dev Updater
properly. So the app now exposes its own ring through a ContentProvider
at `<applicationId>.devlog` -- Dev Updater's contract, written down in
that project's README, not something invented here -- and Dev Updater's
phone app reads it on the same device and forwards it to its own build
machine. No tunnel, no token, no second enrolment, and any app that
server delivers can implement the same and get the same Runtime tab.
`DevLogProvider.java` plus `devlog.rs` are the platform glue only: a flat
`String[]` across JNI, a `MatrixCursor` on the Java side, and
`nativeReady` telling Rust the authority the provider actually
registered, so the Diagnostics pane can name somewhere a reader can
query rather than composing a guess. `LogRing::newest_seq()` is the one
addition in `client-core`: an in-memory ring starts again at zero, so it
is what lets a reader notice the process restarted instead of silently
skipping everything since.
Deleted with it, so there is one mechanism: `client_core::log_upload`,
`POST /client-log` on ai-server, the `AI_APP_LOG_*` baking (which left
`build.rs` with nothing to do), and the uploader on both Android
clients. Kept: the ring, `RingLogger`, `install_process_logger`, and the
Diagnostics line -- whose second half is now `devlog provider:
content://<authority>`.
Verified end to end on this checkout's emulator: iris's own
`iris::android::view` startup lines read out of the provider by the
shell, forwarded by Dev Updater's Runtime tab, and served back from
`GET /apps/android-app/components/app/logs?kind=runtime`. A component
whose package has no provider says so in as many words.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`app_log` is the platform half: `android_logger` as the logger the ring
forwards to, and an optional destination baked in by `build.rs` from
`AI_APP_LOG_HOST`/`_PORT`/`_TOKEN` plus the pinned CA -- the same
build-time trust boundary the transcript config and the Compose APK's CA
already use, so no token is committed and an APK is good for the server
that built it. All three or none: two of the three would be a build with
nowhere to send its log and no way to say so.
`Copy report` now appends the ring to what goes on the clipboard (not to
the pane, which is on screen and would be buried) and flushes the
uploader first, so the lines are on the server by the time the message
describing them arrives. The Diagnostics pane gains two lines: how many
lines are held and when the last arrived, and what the uploader last did
-- "not tried yet", "failing -- <why>", and "no server configured" are
each their own wording, because "nothing is arriving" has three causes
that look identical otherwise.
Also: the re-emitted lines carry the target `ai_server::client_log`, not
a bare `client_log`. `RUST_LOG=ai_server=debug` -- the filter AGENTS.md
tells people to run with -- drops a bare target, so every line a phone
sent vanished with nothing saying so. Found by running it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Iris tests iris builds on a phone with no adb, and Android forbids one
app reading another's logcat, so a `log::info!` in the app can only reach
her if the app carries its own copy and sends it somewhere.
`client_core::log_ring` is that copy: a bounded ring (2000 lines / 256
KiB, whichever bites first) behind a `log::Log` backend that forwards to
whichever real logger the platform installed, so `logcat` and the desktop
terminal see exactly what they saw before. Reading does not consume --
the report and the uploader are two readers of one ring.
`client_core::log_upload` drains it into ai-server's new `POST
/client-log`, which re-emits each line into the server's own tracing
output. Dev Updater already shows that as ai-server's runtime log, so
nothing new is built there. A failed batch is retried from the same
cursor, and nothing in the upload path calls `log!` -- it would land in
the ring it is draining.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.
On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.
Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Off by default and per session: it spends quota the moment quota exists,
with nobody watching, which is not a thing a default may decide. Switched
on from the session settings dialog, with the message it sends editable
("continue" unless something else is typed).
Running out of quota becomes a state rather than an error. The Claude
driver recognises its dialect's sentence -- `Claude AI usage limit
reached|1788546972` -- and reports `LimitReached` with the reset time it
gave; nothing above a driver matches on a string. The transcript draws it
as a divider, like a clear or a compaction.
The schedule is a plan to *ask*, never a plan to send. Both reset times
available are untrustworthy in the direction that matters -- the dialect's
is written when the turn fails, the endpoint's moves when the window does
-- so the wait ends in a question to the usage meter, and only `ok` with
no window at 100% sends anything. A window still spent reschedules to its
own reset time, which is what makes a limit that lifts late wait longer
and one that lifts early resume sooner. A meter that cannot be asked is a
longer wait too, never a send. A day after the limit was hit the wait
gives up and says so in the transcript, so a machine that can never be
asked is not retried for ever.
The schedule is persisted on the session: a five-hour window outlasts a
backend restart, and a wait forgotten across one never comes back.
Driven end to end with echo, never a real account: `/limit [minutes]`
reports the same event a real driver does and `/usage` sets what the meter
answers, deliberately separate so the two can disagree. The wait moved
from the dialect's two minutes to the meter's seven when the meter changed
its mind, and the message went out on the first check after the meter came
back under the limit.
Also makes the settings dialog scrollable, which these two controls made
necessary: at a 1.5x system font it clipped the last of them with nothing
on screen to say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Config::default_effort` is what a session starts at when nothing chose one,
applied in `spawn_session` rather than filled in by the spawn screen so it
holds for an import and a bare API call too. It is set by the spawn screen's
own picker, whose label says so: one control, where new sessions are made,
rather than a settings page for a single value. Not on a provider, because
providers are discovered and the next rediscovery would erase it; not on the
phone, because a second device would then spawn at a level nobody there
chose. `GET`/`POST /defaults` carry it as a struct, so the permission mode --
still hardcoded to `auto` on the spawn screen -- can move there later without
a second route.
Only drivers that read a level are given one: an echo session was storing a
`--effort` it never passes to anything, which is a config file answering a
question about itself wrongly.
Separately, `AGENTS.md` is 35 KB sent with every request in this repo, and 12
KB of it was rigs and reference measurements that only matter once you are
running one. Those are the `ai-app-rigs` skill now -- the same text, still the
only copy, read when the work touches it. 35,198 -> 20,813 chars.
Verified on the emulator against the sandbox: the spawn screen pre-fills from
the server, picking `low` spawned a session at `low` and left `/defaults` set
to it, and an echo session spawned afterwards took no level at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Output is about an eighth of what a session costs and thinking is nearly
all of it -- prose is ~1.5% of output tokens, measured over 27,015 requests
of this account's own transcripts -- so the level is the largest saving
available short of shortening the conversation itself.
Shaped like the working directory rather than like the model: the CLI's
only two setting control requests are `set_model` and `set_permission_mode`
(checked against the 2.1.258 binary), so `--effort` is read when the process
launches and cannot be asked of a running one. `set_session_effort` records
the level and stops the process; the next message or Start launches one that
has it. That is also why the picker is in the session settings dialog beside
Move, and not on the bar beside the model and the mode, which take effect
mid-turn.
`None` is a level in its own right -- the CLI's own default -- so the picker
can return to it, and a blank is normalized to it at the boundary rather
than stored as a level the CLI would reject.
Offered only where it means something: `DriverKind::takes_effort` reports
the capability and the phone leaves the row out entirely, rather than the
session-type branch this app does not have anywhere else. A llama session
would otherwise get a control whose only effect is stopping its process.
Verified on the emulator against the sandbox's fake CLI: the picker sets it,
the server reports it, and an echo session's dialog is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md and PLAN.md were both carrying their own copy of the HTTP
surface, and the previous commit replaced those with a pointer to this
module doc comment -- which turned out to be missing ten routes that
exist: the four `/setups/{id}/importable*`, `/sessions/{id}/permission-mode`
and all five under `/models`. Naming it the source of truth is only worth
doing if it is one.
The two "later phases add" lines at the foot are gone. Setups replaced
`/hosts` in August and `/models` is the block just added above them, so
both were promising work already done.
cargo test (127), clippy --all-targets and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markdown had accumulated a lot that was stale rather than wrong.
PLAN.md still described pi as the llama.cpp harness, a refcounted
LlamaServerManager, and a providers-by-hosts cross-product, all of which
were superseded or never built; it also carried a second copy of the HTTP
table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held
implementation checklists for work that has since landed. AGENTS.md
restated most of PLAN.md's design instead of being the working-notes
layer it says it is. 3225 lines of markdown to 2180, with the stale
sections gone rather than reworded.
On the server, comments explaining what the code already says are out and
the ones recording a constraint, a measurement or an incident are kept but
cut to a few lines each: 5504 comment lines to 4586.
Four doc comments in session/mod.rs, and one each in process.rs and
usage.rs, had drifted onto the item above the one they describe --
functions were reordered without them, so `stop_session`'s doc sat on
`set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on
`type Cached`. Each is back on its own item.
routes.rs's module table also claimed later phases would add `/hosts`,
which setups replaced.
cargo test (127 passed), clippy --all-targets and fmt are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.
Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.
Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.
Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.
transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.
Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against a session streaming 20 events a second. Reopening one with
an anchor 1,800 events back connects 87-119 events behind, under
CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page
and one span covering the whole distance to the anchor, where the report
was written against a restore that took thirteen. Three provocations, no
repeat connection between them: that reopen, twenty seconds backgrounded
while 415 events were produced, and a reset forced by dropping the limit to
5, where the app cleared, refilled and carried on without reconnecting.
So the TODO entry goes. What stays is the instrument that answered it:
every SSE subscriber now logs at debug the cursor it arrived with and
whether it was continued or reset, since nothing else could say. The app
sees a window arrive and cannot tell how far it had fallen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first half of EXPLORER.md: server/src/files.rs, which lists a
directory, reads a file, writes one, and creates a file or a directory on
whichever machine a setup names.
Each operation is one small POSIX script run through `Transport`, the way
the import listing and the usage fetch already ask a machine a question,
so the local and the ssh case are one implementation rather than two that
drift. The path crosses as a positional argument and never as script
text; `PATH_PRELUDE` is the one line that gives a leading `~` its
meaning, because a shell expands a tilde in text and not in an argument,
and it is the far machine's home that has to answer.
A read has four answers -- text, binary, tooBig, or the machine's own
error -- because a binary file drawn as text and a big one cut off
silently are both wrong in ways the reader cannot see. A write carries
the sha256 the read reported and is refused with a 409 when the file has
moved on, which is what happens whenever an agent is editing the file
somebody is reading.
`Transport::capture_with_input` is the one description of "run this
there, with this on stdin", and `ship_attachment` moves onto it rather
than assembling a second ssh invocation of its own. It is also the only
capture that hands back the exit status, which is how the write says
"this is not the file you read" without that answer looking like a
failure.
Exercised on both transports against the sandbox -- ssh to this VM with a
throwaway key, since the quoting and the stdin path are what that proves
-- including a filename with an apostrophe, one with a tab, an unreadable
file, a binary one, one over the limit, and the 409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer's settings row is outlined bubbles opening round menus, and
the message box is a TextFieldValue so anything put into it without being
typed -- a draft, a share, a slash command -- leaves the cursor at the end.
A tap that puts a text selection away no longer also collapses the card the
text was drawn in: every open and close on the session screen goes through
one guard that spends such a press on the selection.
The usage bar and the usage dialog were two polls of one measurement and
disagreed for up to a minute at a time; they are one feed now, and the
countdown rounds up to the minute in the one place both read.
A working directory typed as ~/repos/ai-app was four literal characters on
the local transport and as an argument on both, so the existence check
refused every home-relative path. It is checked by entering the directory
now, expanded for a local spawn the way the remote shell expands it, and
stored short so the phone draws what somebody would write.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.
A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.
TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.
The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.
ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The scroll-up freeze-then-skip was the history pager fighting the transcript's
own storage. A streamed reply is stored one token per event -- hundreds of
AssistantText events for one message -- but a page was counted in events, so
on a delta-heavy conversation a page was a fraction of one row: the opening
80-event load was less than a screen, "scroll up a bit" hit the unloaded
boundary at once, and each page the client did fetch cost a 400-event fold
(hundreds of thousands of list copies) that landed as one jarring insertion.
The server now joins each run of consecutive AssistantText deltas into the one
event the client's fold makes of it, and counts a page's limit in these
coalesced rows -- so a page is a page of the screen whatever the delta density.
Measured against a 3,500-event / 100-row echo session on the emulator: a raw
limit-20 page returns 20 tokens of one reply; the coalesced limit-20 returns
five whole replies. Scrolling the whole thing showed waited p99 51.7ms -> 0.6ms
and the worst whole-transcript measure 59ms -> ~0, with the client folding ~100
row-events instead of 3,500 token-events. No duplicate keys; the first reply
still reconstructs whole from token zero, so healSplitMessage welds the raw
newest window to the coalesced older pages exactly as before.
Coalescing is opt-in per request (`?coalesce=true`) and applied only to older
pages (`before` set): the newest window keeps real seqs because the live stream
resumes from the newest seq the phone applied, and a coalesced newest event
would hide the deltas after its first seq and replay them. The anchor-restore
path also stays raw -- it counts events to reach a known seq, which a page
measured in rows cannot do -- so HISTORY_PAGE is now rows while the restore
span and its cushion stay in events.
Also: ui-sandbox.sh gains a `keep` verb that restarts the server without wiping
sessions, so a fixture that costs minutes to build (a long delta-heavy
transcript) survives a server rebuild.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the connection throttle from the previous commit, which was a
workaround: it made a batch slower without changing that N sessions
meant N ssh connections, and the cap it picked was a guess at somebody
else's sshd config.
The batch now goes out as a single invocation. import::delete takes the
whole list, the remote script loops over the ids and prints one
`<id>\t<state>` line each, and the route settles every row from its own
line. So a batch of any size is one connection and cannot exceed
MaxStartups however many rows are selected -- and it is faster, since
it stopped paying a handshake per session.
Each id still reports on its own: deleted, missing, or failed, kept
apart because only "failed" is worth retrying. Ids the machine never
mentioned -- a connection that dropped part-way -- are reported as
unknown rather than defaulting to either answer, and a malformed id
fails only itself.
Verified end-to-end against a fake ssh that counts connections: a
9-session batch used one, every row settled, both copies of a session
recorded under two project directories went, and the id that was not
there failed with a message saying so rather than a connection error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A
working directory is settled when the process is spawned -- the CLI is
launched with it as its cwd and there is no control request that changes one
-- so this records the new one and ends the process that is in the old one.
It does not start a replacement, and the field says so in a line beside it:
a session with no process starts on the next message or on Start, which is
this app's rule for that everywhere else, and "usually restarts" is a worse
control than "always stops".
The path is checked against the session's own machine and refused if it is
not there. The spawn path corrects instead of refusing, because it is
resuming a directory the *machine* recorded and that can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here would surface much later as a session that would not
start, with nothing pointing at the typo. The refusal names the machine and
the path, and is drawn under the field it is about.
Nothing of Claude Code's own is moved, and that is measured rather than
assumed: on CLI 2.1.237, `claude --resume <id>` finds a session from any
working directory -- an id that does not exist answers "No conversation
found with session ID", and a real one resumed from an unrelated directory
did not. So the conversation continues in the new place with nothing
relocated. Doing otherwise would mean reproducing a rule this app cannot
see the whole of; PLAN.md records what that rule is, for whoever tries.
Found while checking it: `SessionInfo.cwd` came from the snapshot a session
launched with, so a moved session went on reporting its *old* directory for
as long as its process lived -- a dialog showing a directory the next launch
would not use, with nothing saying so. It is read from the config where the
row is built now, the same way `setup_name` already was, and for the reason
already written above `setup_name`: only the manager holds the config, and
both of these change under a running session.
Checked end to end on the emulator against a session whose process really
does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the
dialog showed the new path immediately rather than after a restart, and a
directory that is not there and a relative path were both refused with the
session left exactly as it was.
Two things a reader could not do to what is on screen.
**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.
**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.
The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.
Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
The renderer draws every table cell at one line with an ellipsis, so most
of a table was unreadable on a phone -- and an elided cell looks exactly
like a short one, so nothing said anything had been cut. Cells now take as
many lines as they need and align to the top of the row. Width is the other
half: a column narrows to 136dp and no further, and past that the table
scrolls sideways rather than squeezing. 136 is the widest floor that still
fits three columns across a phone, measured rather than picked; four and up
scroll, which is the right answer for genuinely too many columns.
The import screen used to send one request per selected row, so a handover
was only as atomic as the network: some rows started and the rest were
never asked for, and a row nobody asked for looks exactly like a row nobody
picked. `POST /setups/{id}/importable/delete` and `.../import` now take the
whole list, and every id is registered as in flight before the 202 goes
back. Only the registering is atomic -- the work settles per row, since six
deletes that all roll back together is not something a filesystem offers.
The echo driver grows `/table N`, with cells long enough to have been
truncated: a fixture of tidy one-word values renders fine whether or not
the bug is there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
A Rust backend that owns the sessions and an Android app that reads them.
The server spawns and adopts CLI processes, normalises everything they emit
into one event model, keeps the transcript, and serves it over pinned TLS on
a WireGuard interface; the phone streams that, replies, sends images, and
imports conversations the machine already has.
`AGENTS.md` is the working guide -- what runs where, what has been measured,
and the faults that were expensive to find. `PLAN.md` is the design record.
History before this point was squashed away. It was a personal project's
running commentary and carried a name and a couple of machine paths that
have no business in a public repository; the tree is what mattered and the
tree is here.