A correction made from the layout is a frame late whatever phase it is made in:
from placement it is never picked up by another measure and does nothing at all,
and from measure it lands on the next frame with the uncorrected one drawn
first. That is the flick when a card is opened, and it is why a close could
finish somewhere other than where it was aimed -- the two are the same fault.
Asked for at the tap instead, the request is consumed by the same measure pass
that first lays the row out at its new size, so the resize is drawn once, in its
right place. What makes that possible is that none of the three positions needs
to know the new height. A top edge holds by placing the item *above* the row
where it already is -- that item's bottom edge is the row's top edge whatever
becomes of the row, and it does not have to be composed for the list to place
it. A close places the row itself against the height it had at the moment it was
opened, which is the height it is going back to.
So the measure-phase hold, its modifier and the list's `afterMeasure` hook are
all gone, and this is 75 lines shorter than the version that could not do it.
Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against a real imported conversation as well as the sandbox:
a group closed by its heading lands centred on the tap (1467..1593 for a tap at
1530), a card closed at 1500 and at 1800 lands centred on each, opening by a
heading holds the heading to the pixel, and opening or closing a real call
inside a group of two leaves that group's heading exactly where it was.
Correcting by the error each pass could see, and asking again on the pass that
answered, was a frame per pass with the ones in between drawn: opening a card
visibly stepped. It also still missed, because two of the passes were spent
finding out what the list would do rather than telling it.
`requestScrollToItem` against the row itself says it outright, in one pass: the
row is placed wherever it has ended up and whether or not it is still on screen,
and a negative offset -- which is the list being asked for the rows below a card
that has just given the screen its whole height back -- is exactly what a close
needs and works. Everything else follows from where that puts it.
A call opened or shut *inside* a group is a third case, and it was being treated
as the second: the group is not the thing opening, it is the container, and
centring it on the tap threw a group of six the length of the screen. What keeps
the call under the finger is holding the group's top edge, so that everything
above the change -- that call's own heading included -- stays where it is.
Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox with sampling as fast as the device will
report it, so an intermediate frame would show: a 2,785px card closed at 600,
1200 and 1800 lands centred on 600, 1200 and 1800 to the pixel, each in one
step; opening by the heading holds the heading still; and opening or closing a
call inside a group of five leaves the group's heading exactly where it was.
Two things were wrong with the hold, and each hid the other.
It held a *share* of the row's height: the point the finger was on stayed, in
proportion, which is the same miss in miniature as holding an edge. Tap away
from the middle of a long card and the heading landed most of a card's height
from the finger, and off it. A shut card is a heading, and the only place it
belongs is centred under the hand that shut it, wherever down the card the tap
was.
And the correction was worked out from the change in height, which needs the
list to behave the way the arithmetic assumed. It does not: which item it holds
still across a resize depends on what it has composed -- a row taller than the
screen is anchored on itself -- and a scroll it cannot honour in full is
honoured in part with nothing said. Measured rather than predicted now: each
measure pass asks for the error it can see, and the pass that answers is where
the rest becomes askable. Three passes is the worst seen, including the one
where a card that reached past the bottom of the screen has shut, left the
viewport entirely, and has to be asked back to the bottom edge before there is
anything to measure at all.
The pass has to be the *measure* one. A scroll asked for during placement is
never picked up by another measure and does nothing whatever -- which is what
the first version of this did, and why a close moved nothing -- so the list
took an `afterMeasure` hook and the correction lives there.
Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox, against a 2,785px card in a
conversation with room on both sides: closed at 1450 it lands 1387..1513, at
1700 it lands 1637..1763, at 1950 it lands 1887..2013 -- centred on the tap to
the pixel each time. Opening by the heading still holds the heading still, and
a group closed from its footer bar lands on the bar. Where the conversation
runs out -- a card at the very start with nothing above it to scroll -- it
lands as close as the list can put it, which is what it could always do.
Being open did two things to grouping, and only one of them was wanted. It held
a call standing on its own out of the run it belongs to, so a command finishing
behind the card being read no longer shuts it and folds it away mid-sentence.
It also took a call *out* of the group it was already inside, and that is what
made collapsing jump: grouping is what gives a row its identity, so one tap
rebuilt the rows around the finger -- opening a call inside a group split the
group into two pieces with mismatched keys, and closing one replaced three rows
with one, which no anchor survives. Measured at 450px of jump, with the card
that was closed going with it.
So the held-out set is now the screen's, not the transcript's: a call that has
never been drawn inside a group and is open stands out of its run, and a call
that has been in one stays in it whatever the reader does to it. Being inside a
group once is a fact about what the reader has been shown, which is why the
screen is what remembers it.
Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: opening a call inside an open group of
six leaves it one group of six and closing it returns every row to the pixel it
came from; a call opened while standing alone survives a reply landing behind
it, and folds back into "Called 3 tools" when it is closed without moving the
rows below it.
Collapsing held one of the row's edges -- whichever the tap was nearer -- which
is right for opening and wrong for closing: the row that shuts leaves a heading
where a screenful of card was, and both its old edges can be a screen's length
from the finger that shut it. It now keeps the touched point itself, which for a
closed card is the same thing as landing under the hand that closed it. Opening
is unchanged and deliberately so: those rows are small, every point in them is
within a heading's height of both edges, and the edge pressed is what the reader
wants held rather than a fraction of an unbounded expansion.
One number carries both readings -- the share of the row's height above the
touch, spent as it is on a close and rounded to the nearer edge on an open.
The scroll offset the correction asks for goes negative on a close, and has to:
that is the list being asked for the rows below what it has composed, which is
where the newer content comes from when a card gives a screenful back. It was
clamped at zero, which was invisible while every correction was a row growing
and is what left closes uncorrected.
Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: a 1441px card closed at a quarter of
its height put the collapsed card's top at 743px against 743 predicted, and
opening a card by its heading still holds the heading still.
A running session no longer moves: the ones with a process come first,
oldest start first, so starting one appends it to the bottom of that
group and nothing it goes on to do -- beginning a turn, finishing one,
asking a question -- can shift it. Sorting by activity with the
awaiting-answer ones floated to the top is what this replaces; the
status word and its colour already say which session wants something
without the row having to move to say it. Stopped sessions are a group
below, most recently active first.
The order is the server's: `SessionConfig::started` is written each time
a process is started for a session and reported as `started`, so it is
the same on every device and survives a backend restart -- which adopts
processes rather than starting them, and so could not work the times out
for itself. Applied on the phone, because presentation order is a
display decision.
`LiveSession::info` takes the session's config entry rather than a
parameter per field read from it, which is what `AutoResumeView` existed
to bundle; that goes.
Verified on the emulator against the sandbox: three echo sessions kept
their order while the newest-active one was messaged; a stopped and
restarted session moved below one started after it; a stopped session
dropped below every running one; and after a backend restart the
recorded times came back unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A notification arriving while the app was open was shown as a banner and
nowhere else, so a moment that happened while the phone was face-up on a desk
left nothing behind at all -- the banner is seconds long and reaches only
somebody already looking.
The two are not two versions of one thing: a banner interrupts and a row
records. Both go up now, and the banner having done the interrupting is what
makes the row a silent one (`setSilent`), so one moment is worth a noise once.
What keeps the drawer from filling up is the other end rather than suppression,
and already was: opening a session clears whatever is posted about it, whichever
way the reader got there.
Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox, reading the posted record out of
dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT;
app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing
posted about it in either case.
A call left its group only while it was running, so the moment a command ended
it vanished behind "Called 3 tools" -- and a session that has run its last
command and is composing its answer, or has finished the turn entirely, spends
most of its time in exactly that state. What folds a call back into its run is
therefore not finishing but being overtaken: anything arriving behind it, a
reply included, makes it history.
Standing outside the run is the call's place in the list as it is now rather
than something recorded on the call, so it is asked of the list while grouping
it, where the rest of that decision already lives.
Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox: "/tools 3 1" settles as "Called 2
tools" with the third Bash card beneath it, and folds to "Called 3 tools" the
moment the next reply lands.
A run of adjacent calls is drawn as one collapsed card, which hid the one
thing worth seeing without opening anything: the command the session is
running right now. It is a row of its own while it runs and folds back into
the run when it ends.
Grouping stays a display decision, so the pieces a running call cuts a run
into are keyed there. The first piece keeps the run's name -- that name is
what survives a page of history landing in front of it -- and later pieces
take their own first call's id behind it, since the call a run was named
after can itself be the one running.
The echo rig's /tools gap now runs between a call's start and its end rather
than between one call and the next, which is where a real session's time goes
and what makes the running state observable at all.
Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new
ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator
against the sandbox: "Called 2 tools" with the live Bash card beneath it.
A message typed during a turn reaches the model at the next model call if
the turn has one left, and otherwise as the opening line of the next turn --
Claude's read out of the fifo after the turn ended, Codex's requeued when
turn/steer is refused. Read there it is indistinguishable from a reply, so
the model treats the answer it just gave as seen.
Both drivers now compose the text the CLI receives through
driver::message_body, which prefixes a note saying the message was written
without having seen the rest of that turn. The transcript still holds the
words that were typed; only the CLI's copy carries the note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing `Event::TaskNote` hours after adding it made every transcript that
had recorded one unreadable. `Transcript::open` parses every line, so `launch`
failed for those sessions and `SessionManager::new` logged
"couldn't relaunch session <id>" and skipped them -- and a skipped session has
no pump and no driver. On the phone that is no status, no history and nothing
sendable, for every live session that had run a background task. One
unfamiliar word took down every conversation it appeared in.
A transcript is append-only and permanent, so the set of kinds one can hold
only ever grows: what this build writes is not what it may have to read. A
line can come from a newer server, or from an older one that wrote a kind
since dropped, and neither may be able to end the file.
`Indexed::parse_at` degrades a line it cannot make sense of to
`Event::Unreadable { kind }` instead of failing the whole read. It keeps the
line's seq -- the cursors, the page bisection and the next-seq counter are all
addressed by it, and dropping the line would hand out a seq the file already
contains -- and carries the word the line called itself, so the phone can say
what is missing rather than that something is. A line with no readable seq is
still an error: that one cannot be placed at all.
`Event::TaskNote` comes back retired rather than deleted: deserializable,
never constructed, dated, with the reason on it. The phone folds it to no row,
which is the point -- an unreadable line correctly draws a placeholder, and
one per background task is the wall the row was removed for in the first
place.
Found while diagnosing a report that live sessions had lost their status and
could not be sent to. 173 server tests pass, including the new one, which
fails on the old code within a second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A backgrounded command has no subagent, so there is no second transcript for
its report to live in and its own tool card is the only record of it anywhere
-- and until the task notification arrives that card is showing the launch
result, which says the command is running. It was left saying that for ever.
The report now updates the call's own row (`Event::ToolUpdate` against its
tool_use id), so the card ends up holding what became of the command instead
of a claim nothing was ever going to correct. That includes the endings that
carry no summary: those are exactly the ones that went wrong, and a stale
"running in background" reads worst on them, so they say the status word
rather than nothing. A task with a subagent behind it is untouched and its
report stays where it was, in that subagent's own transcript.
Echo grew `/background [seconds]` for the shape end to end: the Bash call, the
launch result, a turn that ends `waiting`, and the completion arriving later
to correct the card and start a second turn.
Verified on the emulator: the card reads `Background command "sleep 5 && echo
done" completed (exit code 0)` where it had said "Command running in background
with ID: ...". 172 server tests, ktfmt, clippy, rustfmt, Android lint and the
JVM unit tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row per finished background task is a screenful of dividers about work the
reader was not asking after, and one of them turned out to be a whole shell
command drawn as centred prose, because its words came from somewhere with no
reason to keep them short. `Event::TaskNote` is gone entirely, along with the
row that drew it. A subagent's closing report is recorded as that subagent's
own transcript's closing text and is read in the subcard, which is where it
was already going; what the parent gets a row for is a message a subagent
genuinely sends it, which arrives by the peer path and has had one all along.
What remains is the actual defect and the smallest thing that fixes it. The
fold still refuses to grow a settled reply, so a turn boundary is always a
message boundary, and where two replies then abut it puts a `TurnBreak`
between them: a hairline, no words, no colour. Made by the fold rather than
sent by the server, because it is not something that happened -- it is the
boundary between two things that did. `joinPages` puts one in at a page seam,
which the fold never gets to see.
The task notification is still what closes a task in `Status::Waiting`'s
bookkeeping, and the registry lookup that recognises one this translator never
saw start is what makes that work for a session adopted across a restart.
Verified on the emulator: three replies, three rules, and nothing about the
helpers anywhere in the parent. 170 server tests, 85 JVM tests, ktfmt, clippy,
rustfmt and Android lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections to the previous commit.
A subagent's closing report belongs in the subagent's transcript, which is
where it already is; drawing it as a card in the parent's put the same
paragraph in two places for a reader who did not ask for it. The row is a
divider now -- a boundary, which is what the transcript actually needed there
-- closed, saying only what reported and how it went. Opening it shows the
report anyway, since leaving the conversation to read one line has its own
cost, and a backgrounded command has no transcript of its own so this is the
only place its report exists at all: that one names itself from its summary
and has nothing left to open. `TranscriptDivider` grew a `trailing` slot for
the chevron rather than the row growing its own copy of the rules.
And the status was wrong for a session that was already running before the
update, which is every session when the backend is replaced under it.
Adoption picks a session's stdout back up from a recorded offset, so the
`task_started` lines for subagents launched earlier are behind it and the
translator never saw them -- it started with an empty set and reported `idle`
with a subagent plainly still working. `Subagents::any_open` reads the
directory instead, which is a measurement rather than bookkeeping and is right
for a session this process did not start. Both sources are kept and neither
subsumes the other: the translator's own set is the only thing that knows
about a backgrounded *command*, which has no subagent to be found. The same
pair decides whether an ending has already been reported, so a task that began
before the restart still gets its divider.
Echo's helpers now record their report as their own subagent's closing text,
the way the real driver does, so the fixture has the shape being tested.
Verified on the emulator: three dividers closed, one opened to its report, and
each reply drawn as its own message. 170 server tests, ktfmt, clippy, rustfmt,
Android lint and the JVM unit tests all clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A turn started by something with no row of its own -- a subagent reporting
back, a peer message the CLI only owns up to at the end -- met the previous
reply with nothing between it, and the fold grew that reply rather than
starting a new one. Two answers were drawn as one paragraph, running together
mid-sentence with not even a space between them. The fold now refuses to grow
a settled reply, and `joinPages` carries the same rule across a page boundary.
The other half is the row. `Event::TaskNote` records a background task
reporting back -- a subagent that finished, or a backgrounded command -- with
its title, how it ended and what it said; `TaskNoteRow` draws it as a card,
since somebody said this, and its own row rather than an update to the Task
call's, which is above everything the session has said since. Reported once
however many of the CLI's two lifecycle shapes arrive.
`SessionStatus::Waiting` is a session whose own turn is over while work it
started is not. `Idle` means "waiting for a person" and this means the
opposite, so reporting it as idle sent a "finished" notification at the one
moment that was untrue. Drawn as "waiting" in `waitingColor`; the queue and
the held-command boundary release on either end-of-turn status, so a message
sent while a subagent runs is not held until it finishes.
And a usage limit the account hits inside a subagent now reaches the session
as well as the subagent's transcript. `resume.rs` can only schedule against a
session, and a background Task outliving its parent's turn is the ordinary
case, so auto-resume was doing nothing at all for it.
The status word and its colour were two `when`s on two screens, and the second
missed `waiting` silently; they are `sessionStatusWord`/`sessionStatusColour`
now. Echo's `/subagent n` reproduces the whole shape, staggered a second
apart. Verified on the emulator against the sandbox: 169 server tests, ktfmt,
clippy, rustfmt, Android lint and the JVM unit tests all clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Subagents were showing "running" long after they had finished. Measured
against 2.1.237 by running a session that launched one Task agent and
reading its stdout: a subagent's lines carry no `stream_event` at all --
they are whole `user`/`assistant` lines with a null `stop_reason` -- and no
`result` line is sent for one. So `ends_a_turn`, which watches for a raw
`message_delta` saying `end_turn`, could never fire for a subagent, and
nothing finished one until its session's process exited.
What the CLI does send is a task lifecycle, as top-level `system` lines:
`task_started` (with the tool_use id), `task_progress`, `task_updated`
(status, naming the task only) and `task_notification` (tool id, status, and
the agent's own summary). `translate_task` keeps the task -> tool mapping,
records the summary as the subagent's closing text -- the run showed its
child lines stop at its last tool_result, so without this a finished
subagent reads as stopping mid-tool -- and ends it. A `completed` update is
deliberately not the end, since its notification carries the summary; any
other terminal status is, because the failure to avoid is a subagent nothing
ever finishes. `ends_a_turn` stays as a second detector and must never be
the only one again. Verified by replaying the captured stream through the
server as a fake CLI: running, prompt, Bash call, output, report, exited.
`finish_all` now reads the directory rather than the live map, which is what
clears the ones already stuck: a subagent left running by an earlier run of
the server is exactly the one this process never touched, so it read
"running" again every time its session was started.
Auto-resume gets the same treatment on its own single point of failure. The
only thing that scheduled a resume was the CLI's error sentence at the end
of a failed turn; the CLI also sends `rate_limit_event` lines saying where
the account stands, and this server ignored them entirely. Both are read
now. Anything that is not an `allowed...` status counts as refused and is
logged if unfamiliar -- being wrong that way costs one question to the usage
meter, which is still what decides whether anything is sent, and being wrong
the other way is the feature silently not existing.
Co-Authored-By: Claude Opus 5 <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>
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>
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>
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>