e65c961e1eea46d584a4e2509e1c58de300f5b45
110
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0dc248b9e4 |
Fetch a restore's history in one request, and let RUST_LOG through
Now that a scroll anchor is a seq, the restore knows exactly how far back
it has to reach, so it asks for that span in one request instead of
walking there a page at a time. ai-app-2 suggested it; the arithmetic is
theirs. `read_window` counts lines and a transcript numbers them one per
seq, so the distance to the anchor is the number of events to ask for --
and were seqs ever sparse, that difference is larger than the count,
which overshoots into older history rather than stopping short.
Capped at [RESTORE_PAGE_MAX], and the loop already there is what makes
the cap safe: a span past it comes back in several requests rather than
one, which is what every restore did until now. The bytes are the same
either way -- every row between the anchor and the newest end has to be
loaded for the list to be able to count to it -- so this only trades
round trips against response size.
Measured on a 16,133-event session, restoring to seq 2000 (14,133 events
back, the extreme case): **19 requests before, 5 after.** The realistic
case, a couple of thousand events back, is 4 before and 2 after. At
`--delay 150` the deep one puts the row on screen at 5.7s and the
moderate one at 2.5s, and in both the row does not move once it lands.
Also: `RUST_LOG` did nothing. `with_env_filter("info")` is a fixed
directive that never reads the environment, so the per-request page
diagnostics AGENTS.md tells you to turn on with `RUST_LOG=ai_server=debug`
printed nothing at all -- which reads as the code you are instrumenting
being wrong rather than as the switch being disconnected. It is a
fallback now, so the default is still `info`. Those diagnostics are what
the request counts above were measured with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
446d3b9c21 |
Find a transcript page by bisection instead of parsing the file
Every page-back re-read and re-parsed the whole transcript and then threw away all but the window. That is the cost that grows with the conversation rather than with the answer: measured on a 21 MB, 24,000-event transcript, one page took ~500ms of server time to return 620 KB, and it took the same 500ms whichever page was asked for. A phone scrolling up pays that per page, and every stream reconnect pays it again to discover there is nothing new. Sequence numbers only increase, so the boundary of a range is a bisection. `Indexed` locates the lines without reading them, finds the edge by parsing one line per halving, and parses only what is going to be returned. Same page, 232ms including the 120ms `--delay` -- so ~110ms, of which ~20ms is the file scan and the rest is serialising the 620 KB that was always going to be sent. `catch_up` gets it too, and there the case that looks least interesting is the one that mattered: a subscriber with no cursor asks for the whole conversation and is handed the last CATCH_UP_LIMIT events of it. The file is still read whole, which is a deliberate stop -- finding the tail without reading forwards means a chunked backwards reader, and locating a line is not what the half-second was going to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c782a1264b | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
61d2c78afe |
Keep the reader's place: in a group, in a compaction, and in the list
Five things Iris asked for, all about the transcript screen holding still around whoever is reading it. A tool call opened on its own stayed open when a second call in the same run turns it into a group. Watching a Bash call and having the session make another one used to shut the card being read and fold it behind "Called 2 tools" -- the reader lost their place because something else happened. The transition is noticed once, at the moment a run first becomes a group; after that the group's own toggle owns it, so shutting a group whose inner call is still expanded does not re-open it. The compaction clock is taken from the `compacting` status event's own timestamp rather than from this device noticing one, so it survives leaving the session and coming back -- it used to disappear, because the only thing that knew when the compaction started was a screen that had been disposed. The server timestamps every transcript line, so this is still a measurement; it is compared against the phone's wall clock, which is the same comparison a session's "last active" already makes. Session settings are a dialog over the session instead of a screen below it. Two controls did not warrant a page transition and a back stack, and the thing they change was hidden while they were on screen. Captions are gone -- each control is a labelled noun -- and "Notify me" is "Notifications" with a bell beside it (`md-bell`, added to the committed Nerd Fonts subset). Failures keep their words, since those are what a reader cannot work out by looking. Tool groups are rounded like every other card, their foot bar is the same height as their heading (both derived from the heading's own line height, so the pair cannot drift), and the calls inside are a connected stack: square where they face a neighbour, rounded on the outside, with a small gap so the join reads as a join. Scroll position is persistent on the device, per session, keyed by the row rather than by an index -- an index means nothing across a reopen, where the transcript is fetched newest-first. Reopening pages backwards until that row is loaded *and* has something older behind it, because the oldest loaded row is a half-row that grows when the page behind it arrives; anchoring into one landed a screen and a half out. The list draws nothing until the position lands, so there is no frame in which the transcript is somewhere other than where it was left. Two things found on the way. `snapshotFlow`'s first emission is the state before anybody has touched the list, and reading it as a scroll that had just ended at the newest end wiped every saved position on the way in. And backwards pages now ask for 800 events rather than 80: ai-app-2 measured a real transcript at 2,426 events for seven assistant messages, so a page of eighty is a fifth of one row and filling the lookahead took about thirty sequential round trips -- seconds of a list that will not move, over the tunnel. `/tools [n] [gap]` in the echo driver takes seconds between calls, which is what makes a run grow slowly enough for somebody to have opened one of its calls first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
06ab7343cf |
Sessions spawned while testing clean themselves up
--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> |
||
|
|
a18a57e73e | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
61af05c732 |
A backend start adopts, and starts nothing
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> |
||
|
|
d981d63bcc | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
fd616361eb |
Hold the transcript still under a scroll, and parse replies before drawing
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>
|
||
|
|
6119926a4d |
Start the process for a rename too -- the CLI keeps its own name
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> |
||
|
|
1a132b4de3 |
Start a stopped session's process for a command too
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>
|
||
|
|
4a122f7b25 |
Start a stopped session's process when a message is sent to it
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>
|
||
|
|
50f9b956d9 |
Check "exited" against the process record before believing it
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> |
||
|
|
1d843f20f2 |
Let the driver say it started, so both screens agree
The session list reads the manager's status; the session screen replays the transcript. Correcting a relaunched session's stale `exited` by writing the manager's view directly left those two saying different things about one session -- which showed up as a stop button that turned into a play button a moment after the screen opened, and a status row that disagreed with the row it was opened from. So the correction goes through the event sink instead, from the driver that started the process: `EchoDriver::new` and `LlamaDriver::attached` already announce the state they start in, and `ClaudeDriver` was the one starting a process silently. It says idle only when it started one -- adopting says nothing, since a process that was already running may be mid-turn. Coming from the driver also orders it against the exit `follow` reports for a process that dies immediately, which a status written from the manager could not be. Verified against a stand-in CLI: stop, restart the backend, and the list and the transcript's last status both say idle, with the relaunch recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6154cb1949 |
Stop and start a session's process from the composer
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> |
||
|
|
0d623b7073 |
Recover a session's context from the CLI's own file
A restarted server has been told nothing, so a Claude session that has not taken a turn since reported its context as unknown -- which was true, and useless, since the CLI had written the figure down at the time and it was sitting in the session's file the whole while. It now reads it from there at load, over the session's transport, in the background: the same three input fields the import list already reads, so it is a measurement rather than a guess. Only when nothing else has answered, and only for a provider that keeps such a file. A clear needs no special case even though it makes the last usage in a file stale, because clearing gives the CLI a new session id -- so the lookup lands on a file with no usage yet and answers unknown, which is what it is. |
||
|
|
bc0a48799c |
Put a question to the reader on a row of its own
An AskUserQuestion arrived in the middle of a run of tool calls and was folded into the collapsed card with them, so the one row where somebody was asked something -- and the answer they gave -- was hidden behind "Called 6 tools" like any other grep. It now starts a run of its own and ends the one before it, which needs no change to the grouping: a run of one is drawn as itself. The calls around it become a group before and a group after, so where the work stopped to ask is legible from the shape of the transcript without opening anything. Echo's `/ask` now runs three ordinary calls on each side of the question, because that is the shape this has to be looked at in and there was no way to produce it. |
||
|
|
09f7f8d203 | Merge branch 'main' of git.arirex.me:iris/ai-app | ||
|
|
5e11b9da80 |
Report the context a session holds, not what it has spent
The number on the status row was a running total of tokens spent, so it could only ever climb: a session compacted from 128k down to 10k, or cleared outright, went on reporting the larger figure, and disagreed with the divider directly above it saying what the compaction had recovered. It now reports what the model is holding -- prompt plus both cache figures -- folded through `driver::context_after`, which is the one rule the pump, the transcript and the phone all use: a turn sets it, a compaction replaces it with what the compaction measured, and a clear leaves it unmeasured. Unmeasured says so in words, because an empty context and one nobody has counted used to look identical. Taken from the turn's last assistant message rather than its `result`: measured against CLI 2.1.237, a two-message turn reported a cache read of 40,211, being 14,259 and 25,952 -- the same conversation counted twice, and no size the model ever held. |
||
|
|
68704fce7c |
Ask the driver whether a command can go, not the status it reported
A `/clear` that did nothing, traced to the end. There was no race to lose: the driver sees every line it writes and every line that comes back, so it always knew. What it knew was being asked of the wrong thing. Two views of "is a turn running" had grown apart. The driver's moves the instant it writes a line; `SessionStatus` moves when output is *recorded*. Messages ask the driver -- which is why they behave -- and commands asked the status, which for a command is stale for its whole round trip: a command's reply carries no assistant text, so nothing proved a turn had started and the recorded status stayed idle from the moment it went out until the moment it came back. A second command in that window went straight out too, landing inside the turn the first one had started, where the CLI reads it as text instead of running it. Nothing anywhere says so: a command read as a message looks like a message. So `Commands` asks `Driver::between_turns()` now, and asks again when it releases a held one -- the recorded idle that woke it is a moment in the past by then. `local_command` says `Running` when it writes, which is both true and what makes the next idle a change worth recording; without it the idle at the end of a command was equal to the idle before it, and nothing behind it was ever released. The other half was a turn nobody here started. The CLI picks the conversation back up on its own -- measured: a backgrounded `sleep` finished nine seconds after the turn's result and it began again unprompted -- and it announces that with a `system/init` about a second and a half before its first assistant text. We had been ignoring that line and learning about the turn from the text, so for that second and a half the session read as idle. It is a turn now, told apart from the `init` at startup by the translator already having a session id, and from our own `/clear` by `running` already being true. Measured against the real CLI, not argued: two `/clear`s sent back to back on one connection now record `commandSent`, `running`, `commandQueued`, `cleared`, `idle`, `commandSent`, `cleared` -- held, then run, in order, both of them. Before this the second was swallowed. The self-started turn shows as `running` eleven seconds after the previous turn's idle, which is the window a command used to disappear into. Also measured on the way, and worth writing down: a message written into a running turn is *folded into it* -- one `result`, `num_turns: 2`, both things answered -- so an idle after one is honest and there was nothing to fix there. A command written when the CLI is genuinely between turns is executed even ten milliseconds after the result, so the boundary itself was never the problem. |
||
|
|
f1a185a9cd |
Answer a command a session can never run, and put attachments under the text
Investigating a `/clear` that did nothing. What I could measure says the basic path is sound: the CLI honours `/clear` in stream-json mode -- it emits `conversation_reset`, opens a fresh session id, and the model then answers "NO CONTEXT" to a question about something it was told a moment before -- and a `/clear` sent into a running turn is queued here and applied at the boundary, with the model losing context, in two reproductions. What the investigation did find is a command that can wait forever. Held commands drain at the next idle, and a session whose process is gone has no next idle, so `/clear` sent to one sat in the queue with a waiting bubble on the phone that nothing could resolve and nothing anywhere saying why. The *message* path has always answered this case -- a message to the same session reports the exit at once -- which is what made the silence visible: one session answered one and swallowed the other. A command owes the same answer, since what makes it unanswerable is the same fact. `Unknown` still waits. It means nobody could find out whether the process is there and it resolves itself, so refusing on it would turn "we don't know" into "it's gone". `local_command` gets the `closed` check `send_user_message` has had all along, for the window between the status being read and the line being written -- a line into a fifo nothing is reading goes nowhere and looks exactly like one that arrived. Attachments now draw under the message text rather than above it: what somebody wrote is what the bubble is, and it keeps the first line of every bubble in the same place down the transcript whether or not there is an image in it. |
||
|
|
da65c1571f |
Merge remote-tracking branch 'origin/main'
# Conflicts: # app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt |
||
|
|
b0629f77ca |
Shrink a photo to what the provider takes, and put it in its own bubble
Sending an image was broken in the way that is hardest to see from the phone: a camera photo is twelve megapixels and several megabytes, the Claude API resizes anything past 1568px on its long edge before looking at it and refuses far larger outright, so the picture was uploaded whole over the tunnel to be thrown away or rejected at the other end. Shrunk on the phone, to a limit the server states. Which number it is comes from the provider's *kind* -- `DriverKind::max_image_edge`, reported on the session row -- because that is where a provider's requirements are known, and a phone carrying its own copy of them would be a second place to update when one changes. `None` where nothing cares, rather than a large number: "no limit" and "a limit that happens to be big" are different answers and only one of them stays true. Doing it before the upload rather than after is the point -- the expensive part on a phone is the tunnel, not the decode -- and an image already inside the limit is uploaded byte for byte rather than being round-tripped through JPEG for nothing. EXIF orientation is applied while scaling. The camera writes which way up the picture is into a tag rather than into the pixels, and re-encoding drops it, so a portrait photo would have arrived at the model on its side with nothing anywhere saying so. **What is attached is now visible before it is sent**, in a row directly above the box it will be sent from: the count on the "+" button said how many and never which, so the only way to find out what you had picked was to send it. It scrolls sideways rather than shrinking, and tapping one takes it back off -- an image picked by mistake could otherwise only be dealt with by sending it. The tile is outlined as well as filled, because most of what gets attached here is a screenshot of a dark app and a cropped one is near-black: without an edge the only thing on screen saying an image was attached was the cross drawn on top of nothing. **And the picture is inside the bubble that sent it.** Attachments used to be their own `Image` events emitted just before the message, which drew somebody's screenshot as a row floating above the bubble and left the phone deciding from adjacency alone which message an image belonged to -- a thing the sender knew and could simply say. `UserMessage`, `MessageQueued` and `MessageTaken` carry the refs now, so a waiting message keeps its picture for as long as the turn runs, and a replay puts it back in the same place. Verified on a real claude-cli session rather than an echo one, since the limit only exists for that kind: a 3000x4000 image arrived as 1176x1568 JPEG -- long edge exactly the limit, aspect ratio intact -- and haiku answered "AI Sessions displays idle Photo", which is what the picture was. No error, and the transcript records the message with `images` on it. |
||
|
|
de049bcec6 |
Hold the transcript still while somebody is reading further back
Two separate defects, both of which moved the list under the reader. The first: a row that grows drags the view toward the newest end. The list is laid out from the bottom, so it anchors on the first visible item's *bottom* edge -- and a reply streaming in extends that row upwards, pushing everything already on screen with it. Measured against a reply streamed in four hundred pieces: scrolling back one screen and waiting six seconds ended at the very bottom, forty lines further on than where it was left. So the transcript now only changes while the reader is at the newest end; anything arriving before then waits in order and lands when they return. Status, tokens and the model still update live, because none of those are drawn in the list and freezing them would trade a jumping transcript for a status row that lies. The second: every markdown row was measured at nothing before it was measured at its real height. The renderer's `content: String` overload parses in a coroutine and draws an empty loading slot until it finishes, so a row composes with no height and springs open a frame later. Seen with five replies on screen at once, all blank, the whole conversation shrunk to a single screen. Parsing in the composition costs a few milliseconds on the main thread and is worth it: no scroll anchoring can survive a row that lies about its height first. `/stream N` in the echo driver is what made the first one reproducible -- `/slow` emits a line a second, and the growth has to be continuous for the anchor row to drag. Verified on the emulator: scrolled back through a whole 400-piece stream, the transcript region is pixel-identical across ten seconds while the status row goes from working to idle; returning to the bottom brings the backlog in one go. Checked the tool-call rig too, which this change had no reason to touch -- paging back still works and every group still reads "Called 8 tools". |
||
|
|
47d6b84265 |
Report what a session last did and what it cost, not what this page holds
Three readings that were each a part presented as the whole.
**"just now", everywhere, after a restart.** A relaunched session took its
last-activity from the clock, so every session the backend brought back
claimed to have been active that instant. On the phone that is every row
reading "just now" and the list -- which sorts by it -- coming back in an
order that means nothing, with the conversation somebody was in the middle
of buried among sessions untouched for days. It comes from the transcript
now, in the pass `Transcript::open` already makes, which is the same
correction `last_status` got and for the same reason: a server that has just
started has been told nothing, and the file is the only thing it knows. The
test backdates a transcript by a day, so it cannot pass by the test being
fast; it fails on the old code with the clock's answer in the message.
**The token total was the newest page's.** The phone added up the
`UsageDelta`s it had received, and it opens a session on the newest page of
the transcript -- so a long conversation reported its last few turns as the
total, and a page with no turn in it reported nothing at all, since zero is
drawn as blank. That is the reading Bryan saw: no tokens, on sessions that
had certainly spent some.
The count belongs to the server, which is the only side that sees every
turn. `UsageDelta` now carries the running total beside the delta, filled in
by the pump rather than by each driver -- a driver knows what its own turn
cost and nothing else does, so a new one cannot get this wrong by leaving it
out -- and the session row reports it for a screen that has not opened the
stream yet. The phone takes the largest total it has seen instead of
accumulating, which also means paging older history cannot move it, and
leaves the seeded figure alone for transcripts recorded before the field
existed. Seeded by summing deltas at startup for exactly that reason.
**The header said the model twice and the machine backwards.** A session's
subtitle now reads `machine · provider`, in that order and with no "on"
joining them, matching the list and the usage dialog -- the "on" made it a
phrase, which works in one order and stops working the moment the same pair
is shown somewhere else. The model is gone from it: the footer's picker
already shows what the session is set to, and two places showing it meant
two things to keep in step, which disagreed for a moment on every switch
since one follows the request and the other the session's own answer.
Checked on the emulator against a twelve-turn session whose visible page
held the last six: the header reads "this machine · echo", the status row
reads "idle", and the total reads 42 tok, which is what `GET
/sessions/{id}` says rather than what the page adds up to.
|
||
|
|
694535badc | Merge remote-tracking branch 'origin/main' | ||
|
|
135950c8ed |
Say when a session wants you, and stop calling a stop an error
Three things Bryan asked for, and one the second of them exposed.
**Notifications.** A session that asks a question or finishes a turn now
says so on the phone, per session, switchable from its settings screen.
The switch is stored on the backend rather than the phone, because it is a
fact about the session: one that runs unattended overnight should be quiet
on every device, and answering that question again on each device is how two
of them come to disagree. It is on by default -- a notification nobody
wanted is turned off in one tap, where one that never arrived is not
diagnosable at all.
Which moments count is `notification_for`, and the asymmetry in it is the
point. *Waiting on a person* is worth saying however it was reached.
*Finished* is only worth saying when this server watched the work happen:
sessions settle into idle for several reasons that are not "your work
ended", including every one of them being adopted at startup, and announcing
those would put "finished" on the phone for the whole config on every
backend restart. That is the failure that makes somebody switch the feature
off, so it has a test naming every transition rather than the two that
work.
The stream is `GET /notifications`, live only and with no cursor -- the one
place this server does not offer to catch a client up. A notification is a
claim about now; replaying "your turn" from an hour ago sends somebody to a
session that may have been answered from another device since, and a
notification that is wrong costs the trip *and* the credibility of the next
one. What was missed is still on the session list, which says what is
waiting without claiming to be news.
On the phone it is a foreground service, because Android has had no
long-lived background service since 8.0 -- it is what Syncthing does, and
Discord is not a counter-example since it takes a push from Google, which
would mean this backend talking to Google about somebody's sessions. The
ongoing notification Android charges for it sits on an `IMPORTANCE_MIN`
channel: no sound, no status-bar icon, bottom of the shade. `specialUse`
rather than `dataSync`, which is what it looks like: Android 15 caps
dataSync at six hours a day, and a connection that stops listening after six
hours misses the overnight run it exists for.
**A stop is not an error.** The CLI reports an interrupted turn exactly as
it reports a broken one -- `is_error` on a `result` -- so pressing Stop
showed "the turn ended with an error" for doing what the button says. The
line cannot distinguish them; what does is that this side asked, so the
driver says so before the request goes out and the translator spends that on
the next result. The test's second half is the one that matters: the naive
fix passes the first half and silences every genuine failure after it.
**Every status says which one it is.** The session screen's status row named
only `exited` and left the rest blank, so idle and "nobody could read it"
looked identical -- and a just-stopped turn showed nothing, which reads as
the app having lost the session rather than as the stop having worked. The
words are the session list's own, so a state is not called two things
depending which screen you are on. Red on a quota bar now starts at 90%.
**`GET /sessions/{id}`**, which the notification switch found missing. A
screen opened from a list row carries the row the list last fetched: fine
for a title, wrong for a switch, which is *set to* something. Caught on the
emulator, where the switch read on against a backend that said off, with
nothing on screen to say which was true. The screen now reads the session
when it opens, and until that answers the switch is disabled and says so --
a two-position control cannot say "I do not know", so it does not pretend
to.
Verified on the emulator with the app backgrounded: the service holds the
stream (`isForeground=true types=0x40000000`), a finished turn posts
"Finished" and a question replaces it with "Waiting for you" on the same
tag, turning the switch off silences it with no restart, and turning it back
on from the phone reaches config.ron. The interrupt is a translator test
rather than a live turn, which is where that logic is anyway.
|
||
|
|
2dc61c5780 |
Stop drawing one tool call twice where a page of history begins
A page boundary lands wherever it lands, and about half the time that is between a tool call and its result. The newer page then holds a `ToolEnd` whose start it never saw, which the fold draws as a row of its own -- correctly, since a call rendering as nothing is indistinguishable from one that never happened. But when the older page arrived it brought the real `ToolStart`, and the two lists were concatenated, so the call was left on screen twice: once as a proper card and once as a nameless placeholder. `joinPages` merges the two halves by the call's own id instead, which is the one thing a page boundary cannot destroy. The older half wins on what a start knows -- the tool's name, its input -- and the newer on what an end knows, its output and whether it finished. The miscount was the visible part; the moving was the point. The extra row sits exactly at the join, which is where the reader is looking when the page loads, so everything below it stepped down by a row at the moment they scrolled into it. Demonstrated both ways round on a rig of twelve `/tools 8` runs, whose groups are eight calls each and whose page boundary falls inside the second one: without this the transcript reads "Called 9 tools" there and eight everywhere else, with it every group reads eight. That rig is `/mixed N` in the echo driver, added here: N beats of paragraphs at three lengths, single tool calls, runs of adjacent ones, images and peer messages -- every row shape the app draws, in one session, from a command that costs nothing and produces the same transcript every time. The paragraphs are deliberately ragged, because a wall of identical lines looks the same at every offset and makes a scroll of one line indistinguishable from a scroll of ten, by eye or by comparing frames. |
||
|
|
e37e90a579 |
Let the server say what is waiting, instead of the phone remembering
A message sent into a running turn was drawn as a pending bubble from
screen state, so leaving the session or restarting the app showed nothing
waiting while the queue was full. Nothing waiting is what "there is
nothing" looks like -- the reader had no way to tell it from a queue that
had already drained, and Bryan hit exactly that: a message he sent
arrived, and his phone stopped showing it after a restart.
The server now records the waiting. `MessageQueued { id, text }` goes into
the transcript when a driver takes a message it cannot deliver yet, and
is resolved by the `UserMessage` carrying the same id -- the same shape
`CommandQueued` and `CommandSent` already had, so this is one more
instance of a mechanism rather than a second one beside it.
The message itself still lands where the session read it, which is what
the last change was about; only the *waiting* is recorded early. The two
are different facts and now have different events.
Paired by id rather than by text. The old code removed the bubble whose
text matched, so sending the same thing twice cleared the wrong one and
left a message on screen that had already been read.
Both drivers that can queue do it: the echo driver too, because the phone
now draws pending bubbles from the stream and a rig that skipped the
event would exercise a state the real app never sees.
Checked on the emulator: two messages sent into a `/slow` turn, then the
app force-stopped and relaunched -- both still drawn as waiting, in the
pending style, and both resolved into ordinary bubbles when the turn
ended and the session read them.
Still outstanding, and worth knowing: an entry outlives a *server*
restart in the transcript but not in the driver's memory, so a backend
restarted mid-queue would leave the bubble drawn with nothing coming to
resolve it. Before this change that message vanished from the transcript
entirely, so the failure is now visible rather than silent -- but it is
not yet right.
|
||
|
|
76ba24993c |
Make the permission mode the picker offers actually selectable
The session screen's mode picker listed `bypassPermissions`, and on any
session not born in it, choosing it failed:
Cannot set permission mode to bypassPermissions because the session
was not launched with --dangerously-skip-permissions
The CLI is asymmetric about that mode and it is not obvious. It will
launch straight into it on `--permission-mode` alone -- so spawning into
it from the phone has always worked -- but it refuses to switch into it
afterwards unless the process was started with the flag. So the picker
offered a state the session could not reach, and the failure arrived
after the fact as an error line in the transcript.
Sessions now launch with `--allow-dangerously-skip-permissions`, which
makes that mode reachable without selecting it: the session still starts
in whatever mode it was asked for and only moves when somebody moves it.
Deliberately the `--allow-` form; `--dangerously-skip-permissions` is the
one that turns bypassing on for everything, which would take the choice
away from whoever is holding the phone. Since the mode was already
reachable at spawn, this withholds nothing new -- it makes the two routes
to it agree.
Measured both ways round against 2.1.237, driving the control request
directly: without the flag the response is `subtype: error` with the
message above, with it `subtype: success, mode: bypassPermissions`. Then
through the app's own route on a session spawned `manual`, whose argv
reads `--permission-mode manual … --allow-dangerously-skip-permissions`
and which reported `permissionMode: bypassPermissions` when asked to
change.
|
||
|
|
549e49bc10 |
Record a steer where the model read it, not where it was typed
A message sent while an answer was streaming was recorded in the middle of that answer and above the tool call it ended with. The model had committed to that call in the same message it was already writing, so it had read none of it -- and on screen the tool result underneath read as something the steer had asked for. The answer also split into two bubbles around a message that was not part of it. The driver announced a steer at "the next assistant text or tool call", on the reasoning that anything the CLI says next is proof it has been round the model again. With --include-partial-messages that is not true: the deltas and the tool_use block of a message already in flight keep arriving afterwards, and none of them saw the steer. `message_start` is what actually proves it. The CLI sends the previous call's tool results back before it opens the next assistant message, so that line is the first moment anything written since can have been read -- and it carries no events of its own, which is what makes it a place to put one. Verified against 2.1.237: message_start, the blocks, the tool_result, then the next message_start. The end of the turn stays as the other half, and is the case that must not be lost: a message typed after the final model call has no later message_start, and one that is only recorded when announced would otherwise vanish while a phone drew it as still waiting. Checked live on haiku, before and after. Before: the steer landed at seq 37 among the essay's deltas, with the tool call at 45 and its result at 46. After: essay whole, tool call 42, result 43, steer 44. Also checked the case this had no reason to touch -- a steer sent during a 30-second Bash call, which was already correct -- and it still records after the result. The two tests fail on the old rule; the failure prints the old order, which is the bug. |
||
|
|
894180de77 |
Take the CLI's word for a clear instead of inferring it
Bryan reported no divider when clearing. It was not this code -- the backend serving him started at 17:06, three hours before `Event::Cleared` existed, so it has no such event to send and `/clear` reaches it as an unrecognised passthrough. Verified against a current build: the event is recorded. Probing the CLI to establish that turned up something better than what was here. `/clear` in stream-json mode emits a dedicated `conversation_reset` line and *then* a fresh `init` with the new session id -- so watching the id be replaced, which is what this did, was reading the event through one of its side effects. The announcement says it directly, and it arrives first, so the divider now lands above the new conversation rather than after its opening line. That also removes the reasoning the previous commit needed about which id changes count. There is one signal now instead of an inference with two exceptions, and the test that used to pin those exceptions became `an_init_alone_is_never_a_clear`, which covers all three ways an init arrives: a session's first, the one a compaction re-announces with the same id, and the one following a resume. The resume token still follows the id, unchanged -- one CLI event with two observable effects, and each half now reads the half it needs. Verified end to end against a real claude-cli session: message, /clear, message, and the transcript reads userMessage / assistantText / cleared / userMessage, in that order. 74 tests, clippy and rustfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
71067275e2 |
Stop claiming a terminal, and stop calling a recoverable delete final
Two bugs Bryan hit, with one shape between them: a claim stronger than the thing that was measured. **The delete warning branched on `imported`.** It told him deleting `ai-app` could be undone and deleting `manager` could not, when both are claude-cli sessions whose conversations survive equally. `imported` records how a session got into the app; what decides recoverability is whether the *driver* keeps its own record -- the Claude Code CLI does, under ~/.claude/projects, however the session started; echo and llama.cpp do not, and for those the app's transcript is the only copy. So the fact now sits on DriverKind and rides on SessionInfo, decided by the server from the provider's kind rather than by the phone from its name, which a person can change. The comment above the branch asserted "a session started here has no copy anywhere". That sentence was the bug written down and reasoned from, and it is gone. Neither branch promises a restore, which it should not: nothing here checks the file is still on disk, and re-importing was never a restore anyway -- this app's transcript holds images, peer messages and command events the CLI's record never had. So the recoverable text says what is known and names what goes either way. "Can't be undone" is now said only where it is true, which is the point of saying it at all. **"open in a terminal -- close it there first" named a place that need not exist.** The detection is right and worth keeping: something live holds that session, and importing it would reproduce the double-resume incident. But which something was never measured. The live descriptors here include two of this backend's own adopted sessions and a peer agent's; none is a terminal, so the instruction sent the reader looking for a window that was not there. **And this app did not recognise its own spawned sessions.** The import list filters out what the app is already driving, but it matched only the import cursor -- which exists solely for imported sessions. Every session the app spawned therefore stayed in the list, marked in use, telling the reader to go and close it somewhere: here. Matching the resume token too, which both kinds have, is the fix; `session_importing` is now `session_driving`, because that is what it was always being asked. Verified on the emulator against a scratch backend: a spawned claude-cli session reports keepsOwnTranscript true with imported false -- Bryan's `manager` case exactly -- and draws the recoverable warning; the echo session draws "can't be undone"; and once the CLI named itself, the spawned session's id was absent from the import list, where the old match would have listed it. 75 tests, clippy and rustfmt clean; ktfmt, compileDebugKotlin and lintDebug clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
5f6fd34aba |
Record the clear that happened, not the one that was asked for
`ClaudeDriver::clear` emitted `Event::Cleared` beside the `/clear` it sent, so the divider recorded a request. A reader scrolling back takes that mark as a fact about the conversation -- the session no longer has what is above this -- and a request is a different claim from a result. Compaction already gets this right by taking its mark from the CLI's own `compact_boundary` rather than from somebody pressing Compact; this is the same rule, and it was the one place left applying it to the request. Caught in review by the session this was measured against, which also established that the CLI's `/clear` is declared `supportsNonInteractive` and returns empty text with no result line -- so a fresh `init` bearing a different `session_id` is the only trace it leaves. The reader already watches for exactly that in order to persist the resume token, so the mark now goes out there. It has to be a *replacement* rather than any change, and the tests pin both ways of getting that wrong. The first `init` sets the id from nothing, which would otherwise open every session with a divider announcing a clear that never happened. And a compaction re-announces `init` carrying the *same* id, which would otherwise draw a clear on top of the compaction's own mark -- that one was found by writing the test rather than by reasoning about the change. 73 tests, clippy clean, rustfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
6680fbb987 |
Clear from the phone, and put the numbers where they are read
Four changes to the session screen, three of them Bryan's and one that
fell out of them.
**/clear is offered like any other session command.** It joins
SESSION_COMMANDS, so it suggests itself while being typed and goes out
through the command endpoint that /compact already uses -- no new path,
and the boundary pump holds it mid-turn exactly as it holds a compaction.
**A clear draws a divider, not a deletion.** `Event::Cleared` becomes a
ClearedNote row saying that everything above stays here and is no longer
sent. That sentence is the row's whole job: the reader can see the
conversation is still on screen, so without it the divider reads as
something having been thrown away, which is the one thing it is not. It
carries no counts, because nothing was measured -- a compaction's
numbers are real and there is no equivalent here to report.
Compaction and clear now share `TranscriptDivider`. They are the same
kind of mark to somebody scrolling back -- "the session no longer has
what is above this" -- and the difference belongs in the words rather
than in how they are drawn, so the styling is written once and cannot
drift.
**The five-hour usage bar sits under the session header.** It reports
the paid service's own metering for the machine this session runs on,
fetched from that machine, refreshed every minute off the backend's
cache. It is never derived from the transcript's token counts: those are
a different quantity measured differently, and a quota-shaped bar built
out of them would be a guess wearing a measurement's clothes. Not
knowing has its own appearance and its own words -- "unknown" and why --
because a bar resting at zero because a machine is unreachable reads as
plenty of headroom, which is the opposite of the truth. The window is
selected by the API's own `kind` ("session"), added to UsageWindow in
this change, rather than by matching the label a person reads.
**The token total moved from the header to the bottom right of the
transcript**, pinned above the input rather than scrolling with it. In
the header it was one item in a run of dot-separated facts about the
session and read as another of them, rather than as the running total it
is.
SessionSummary now carries the setup id, which it deliberately did not.
The stated reason was that nothing here addressed a setup and holding
both id and name invited showing the wrong one; the usage bar addresses
one, so the reason lapsed rather than being overruled, and the comment
now carries the rule that replaces it: never display it. The server has
always sent the field, so nothing changed on the wire.
ktfmt, compileDebugKotlin and lintDebug all clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
|
||
|
|
1e3eec8cf0 |
Let a session drop its context without ending
Adds `Event::Cleared`, `SessionCommand::Clear`, and `Driver::clear`, so
`POST /sessions/{id}/command {"text": "/clear"}` does for a session what
the CLI's own `/clear` does for a terminal.
The marker is a divider, not a truncation: everything above it stays in
the transcript, because that is the only copy of the conversation the
phone has and a person scrolling back is a different question from what
the model is given. It also makes clearing mean one thing across
drivers -- `claude` sends `/clear` and the CLI answers with a fresh
`init` whose new session_id the reader already persists as the resume
token, so the next launch resumes the cleared conversation with nothing
to keep in step; `llama` needs no state at all, since `conversation()`
already folds the transcript and now folds from the last marker; `echo`
emits the marker alone, so the phone's divider and scroll behaviour can
be exercised without spending a real session's context.
That fold is why `Cleared` is documented as load-bearing rather than
decorative. For any driver that rebuilds its conversation from the
transcript, this marker decides what the model sees, and treating it as
something only the phone draws would silently put the cleared
conversation back in front of the model at full price.
Clear rides the existing boundary pump like any other SessionCommand, so
one arriving mid-turn waits exactly as a compaction does, and nothing
grows a second way to wait.
Removes `--autocompact` in the same change, because clearing is the
cheaper answer to the problem it was added for and Bryan would rather
manage context that way. Keeping the measurement here, since it was the
reason for the constant and is worth more than the constant was:
context returned to 70-85k within ten calls of a compaction; a
compaction took 104,346 to 147,671 ms; compaction cost that session
2,655,508 tokens across six boundaries, of which the single automatic
one at the 1M ceiling was 1,696,870. Clearing costs nothing, because
nothing is sent.
70 tests, clippy clean, rustfmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
|
||
|
|
797513bb86 |
Raise the compaction window to 200k
100k is the cheapest window on tokens and the wrong one to sit in front of. Measured on the session this was written against: context returns to 70-85k within ten calls of a compaction, so a 100k window compacts about every thirteen calls, and a compaction takes roughly two minutes (durationMs 104,346 to 147,671 across the six recorded). A 130-call request would have spent some twenty minutes compacting -- optimising the number that was asked about while making the thing somebody actually waits for on a phone considerably worse. 200k keeps most of the saving against the 1M ceiling and halves the stalls. The comment now also says what the window does not do, because measuring this turned up the opposite of what the byte counts suggested. Images are 93% of the bytes that tool calls put into that transcript but only 8% of the context growth -- the adb wrapper's downscaling holds a screenshot to a median of 476 tokens, while text-only calls add a median of 740 and a mean of 1,139. So the file is large because of screenshots and the context is large because of ordinary tool output, and only the second one is what this constant governs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
4f586d6551 |
Attribute the drift to the CLI's ceiling, not to a manual compaction
The reasoning on AUTOCOMPACT_WINDOW cited 491,562 tokens as where "the CLI compacted it". That was a manual /compact somebody ran, not the CLI's own trigger, so the comment credited a person's intervention to the automatic behaviour it was arguing about. Caught in review by the session whose transcript it was measured from. Corrected from that transcript's compaction boundaries: the window left to `auto` was 1M, and the one automatic compaction fired at preTokens 1,000,184 with the API context peaking at 999,668. So the drift ceiling is twice what the comment said, and near it a single tool call bills about 100k tokens rather than 49k. The correction strengthens the case, but it also changes what the example is evidence *of*, which is why it was worth fixing rather than just raising the number: what held that session together was the person in it running /compact by hand four times, and the 4.2-million-token request happened at the merely-large contexts left between those. The constant is for the sessions where nobody is doing that. No behaviour change. 68 tests, clippy clean, rustfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
36984a9e1b |
Put the compaction window's reasoning on the compaction window
|
||
|
|
4a4057886d |
Compact at 100k rather than letting a phone session drift
Left at `auto` the CLI picks a very large window, which suits a terminal session somebody closes at the end of the day and does not suit this app at all: these run for hours, nobody closes them, and the transcript carries screenshots. One session here reached 491,562 tokens of context before the CLI compacted it. That matters because every API call re-reads the whole context, and one request is not one call. At half a million tokens a single tool call bills about 49k before it does anything, so "can you make it so you can rename a session?" cost 4.2 million tokens across the 130 calls it took. Measured over that session's life: 2,498 calls, 1.08 billion cache-read tokens. 100k is the smallest window the CLI accepts and roughly the cheapest. Per-call cost falls with the cap, while the compaction it forces costs about the same in total either way -- a smaller window compacts more often, but each pass is proportionally smaller. What it trades is how much detail survives a compaction, which is a real cost to the work and the reason this is one named constant with the reasoning written down rather than a computed value. Passed before the resume/name branch, so it applies to adopted and imported sessions too -- which are the large ones, and the ones this is for. Verified: the exact argument list the app now spawns starts, accepts an empty stream-json stdin and exits 0, so the flag combination is good without spending a token. 68 tests, clippy clean, rustfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
749b2db287 |
Make a command a thing the app knows, and hold it until it can run
Typing "/" now suggests what this app understands -- `/compact` and `/rename <name>` -- with a line each about what they do, and anything else beginning with a slash is passed to whatever runs the session, because a dialect's own vocabulary grows without this list. None of them are messages, and that is the substance of the change. A line written into a running turn is read by the *model*, so a command sent mid-turn either does nothing or arrives as text somebody has to puzzle over. They now wait for the turn to end. The waiting is done once for every provider, in the pump that already watches every event for the boundary, rather than in each driver where a new provider could get it wrong by leaving it out. Waiting is a state, so it is on screen: the command sits at the reader's end of the conversation in blue, with a spinner and "waiting for this turn to end", and becomes an ordinary blue row when it goes. Blue because these are about the session rather than about the task -- the same blue a compaction already used, which is now one colour with one name rather than two. Renaming from the settings screen sends exactly this, so it waits and draws the same way. The name itself is not held: it is this server's own datum, so the list and the header change at once and only telling the session waits. Echo grew the same split, which is where the bug in it showed: its commands are its messages, so running one announced a `MessageTaken` as well, and the same line drew twice -- once blue, once purple. A command owes no announcement; the manager has already recorded that it was sent. Watched rather than reasoned about: `/compact` during a 25 second turn held with its bubble up, went out when the turn ended, and the compaction that followed reported what it recovered. |
||
|
|
bebaae7a94 |
Carry a question in the event model, not in one provider's JSON
A question is now fully described by the event that reports it: the tag it was asked under, each option's label, what it means, and the sample of what picking it would produce, plus whether several may be picked at once. The app renders from that alone. It had been reading Claude Code's tool input to find the parts the event dropped -- that dialect's schema, written out a second time in Kotlin, where no other provider could reach it and where it would drift the first time the schema moved. Echo could not describe an option at all, and llama never will. Answers travel as a list for the same reason. A question that takes one answer sends a list of one rather than being a different shape, and the one place that flattens it is where the CLI is spoken to: its answers map holds a string, so several choices are joined there. That join was in the phone. Also here because it is the same rule: the permission ask reuses the question body rather than owning a second one, so Allow/Deny renders and resolves through exactly the code an AskUserQuestion does. Verified against both, since a refactor that only satisfies the case it was written for has been tried on the half that cannot fail: a two question `/ask` answered from the phone, one option and then two, and a real sonnet session's `rm -f` permission asked, allowed, and run. |
||
|
|
fea8e7e92b |
Show every option a question offers, on the call that asked
Reported by Iris through the dev-updater session: a two-question AskUserQuestion arrived with only one option visible per question, so the answer she sent was the only one she had been offered. The cause was a `Row`. It hands out intrinsic widths in order and clips whatever runs past the edge, so the first option or two drew and the rest went off the side of the screen -- which does not read as a bug, it reads as those having been the only choices. The same Row was in the permission ask beside it; both wrap now. That pairing is the reason to look: a rule stated on one member of a set is usually missing from the others. The rest of what she asked for, and what each was: - It drew twice, as the tool call and again as loose question cards, because the backend marked these questions as belonging to no call. They belong to the call that asked, and now say so. - So it renders like any other tool: one card, its own heading, opened because a decision cannot be made from a closed row. - Each option shows its description and its `preview` block, which is the part a reader is deciding on and none of which was reaching them. - "Other" is a field on every question. The harness always offers it, so leaving it out narrowed a question that was never that narrow. - A multi-select sends the labels it collected as one string, which is the tool's own schema rather than a guess -- its answers map is string-valued. - No spinner while it waits. A spinner says the machine is working; here the machine is idle and the turn is stopped on the person, so the card says "your turn" in the colour this app already uses for that. Verified against a real session as well as the echo fixture: haiku asked two questions with three described options each, both were answered from the phone, and the model carried on with the answers. Echo grew `/ask` so the shape can be looked at without paying a model to produce one, and its option cards are outlined rather than tinted -- as one surface step up they were three paragraphs where three things to press should be. |
||
|
|
d3fff3d229 |
Let a session be renamed, under the same name everywhere
A gear at the end of the session's own bar opens what can be changed about that session; the name is the first thing there. Compact is gone from that bar -- `/compact` typed into the message box is the CLI's own way to ask and it already worked, so the button was a second way to say one thing. Echo takes the typed word too now, since it is the rig the compaction display is checked against and losing the button would have taken that with it. The name is this server's, not a driver's: it is what the list shows, it exists before any process does, and every provider has one. So it is settled in the config and the driver is *told* -- which is the opposite of the model and the permission mode, and the difference is written down at `Driver::set_title`. A driver whose process has no notion of a name does nothing and says nothing, because there is no failure to report. Claude Code has one, so the name reaches it: `--name` for a session we create, and `/rename` afterwards, which is a local command rather than a control request -- `set_session_name` is not a subtype it knows, which I established by asking it. A resumed session is deliberately not renamed at launch: an import already has a name, quite possibly one the person typing in it chose, and taking that would be helping itself to something the app was only shown. Verified end to end rather than argued: renaming from the phone put "Session renamed to: paging and scroll" in the CLI's own session file, and the session now lists under that name to other agents. The gear is drawn rather than set in a font, for the reason Chevron gives. It was a sun on the first attempt -- thin teeth standing clear of a thin hub -- which no amount of reading the diff would have shown. |
||
|
|
1629e0911e |
Say what the line splitter would do with a bare carriage return
`complete_lines` splits on `\n` only, which is right -- this stream is JSONL, and a record terminated by a bare `\r` would not be a record -- but the doc comment said why the remainder is held without saying what decides where a line ends. Worth the sentence because of what the failure would look like if the CLI ever wrote such a line: the session goes quiet, the process is healthy, nothing errors, and the cause is a line splitter. The dev-updater session hit exactly this shape today reading cargo's progress line, which is `\r`-terminated for redrawing in place, and lost a whole build's worth of output to it. |
||
|
|
3eccf7e443 |
Show the model and mode the session has, not the ones it was asked for
Picking either from the phone wrote the choice straight into the session's state and then sent the request. Asking and having are different things, and the difference is not rare: `auto` is a permission mode the CLI accepts on the command line, silently resolves to `default`, and refuses outright over the control channel -- "auto mode unavailable for this model" -- so a session spawned in auto was in default and one switched to auto stayed where it was, with the phone reporting auto in both cases. So the drivers report what they are set to and the manager follows that. Measured, because the confirmations are not uniform: a model change answers success with no value, so what was asked is remembered until the answer arrives; a mode change echoes the mode it became, and that answer wins over the request; and `init` names both -- resolving `haiku` to claude-haiku-4-5-20251001 -- which also covers a session adopted from a terminal that set them outside this app. A driver that cannot change either already says so with an error, and now that error is the whole story rather than a note beside a display that changed anyway. The config keeps the requested value, deliberately: that answers a different question, which is what to launch this session with next time. Two things fall out. Control request ids are random rather than the clock, because two in the same second shared an id and something now looks them up. And the phone shortens a resolved name for the button -- `haiku-4-5` -- since the full one is what the CLI reports and roughly twice the room that row has once Stop is in it. |
||
|
|
404066fa7d |
Say when a session is working, and what it was told
Three things a phone could not see, all of them the same shape: the session was doing something and nothing on screen said so. A turn nobody here started never reported itself. `Running` was sent where a message was *sent*, so a session picked up mid-turn, one compacting on its own, or one another agent wrote to sat there reading as idle until it finished. The driver now says it from what it observes -- output that could only come from a turn in flight -- which is the same set of events that already announced a steer, with the ends swapped. An imported session had it worse: nothing but replayed lines ever reaches it, and a status was not among them, so it was permanently whatever it was when it was adopted. Its file does not record a turn ending, but it does record why each assistant message stopped, and `tool_use` versus anything else answers it. A record that says nothing leaves the status alone rather than voting for idle. Messages from other agents were dropped outright: the CLI marks them meta, and this replayed everything except meta. They are now a row of their own, closed by default like a tool call, named for the session that sent it -- not the reader's own bubble, because they did not say it, and a session working on something this phone never asked for is exactly what one of these explains. Measured against a real session file rather than guessed: the peer record carries the sender's name and the message body in `origin`, beside a copy wrapped for the model to read. |
||
|
|
fc71cb4403 |
Say "unknown" for a session we are not driving but cannot bury
A session in the config with no live entry reported `Exited`, whatever the reason. That covers three different situations -- one that genuinely ended, one that failed to relaunch, and one whose process could not be checked -- and the wrong one is the expensive one. `Exited` reads as "this conversation is over", and what a reader does about it is start a fresh session. If the process is in fact still running, that is a second CLI against a conversation that already has one: the exact fault `session::process` exists to prevent, arriving through the status field instead of through a spawn. So it is said only when the process is known to be gone. A record that cannot be checked reports `Unknown`, and so does one that is still alive -- this server is not driving it, so it genuinely does not know what that process is doing, and the honest word is the one meaning "wait" rather than the one meaning "act". A session with no record at all is still `Exited`: an echo session, or one already stopped and cleaned up, and known to be. The distinction was available all along -- `process::recorded` returns the liveness -- which makes this the same mistake as the other five today: reporting what was convenient to compute rather than what was measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8 |
||
|
|
5396da76c7 |
Show a compaction happening, and what it recovered
The Compacting status had been declared, rendered in four places, and never once emitted: no driver produced it, and the app had no control to ask for a compaction in the first place. Pressing nothing for two minutes and then quietly having less context was the whole experience. The CLI turns out to announce all of it, which was worth measuring rather than guessing at. Driven through /compact against 2.1.237 it emits a `status: "compacting"` line at the start, a `status: null` carrying `compact_result` at the end -- `"failed"` with a sentence saying why, when it does -- and then a `compact_boundary` with the token counts. The same records appear in the CLI's own transcript file with camelCase keys, which is the obvious place to read the shape off and gets every field name wrong. So none of it is inferred here. The driver writes the line and says nothing; the translator reports what the CLI reports. A failed compaction surfaces the CLI's own sentence, which is specific enough to act on. The counts are the part worth keeping afterwards, so they land in the transcript rather than only in a status that vanishes: a session that went from 128,402 tokens to 9,617 has just been given its context back. They are optional throughout, because a compaction whose size nobody reported has to be able to say so -- a zero would read as "recovered nothing". Also here, all found on the way: - `rename_all` renames variants; fields need `rename_all_fields`. Every field in Event was a single word until `pre_tokens`, which went out as snake_case, was not found by the app, and rendered as the "no counts reported" case -- a state it is allowed to be in, so nothing looked wrong. There is now a test on the wire names. - The unparseable-line warning sliced bytes, not chars, on output that is full of em dashes. A panic there kills the task reading the session's stdout, and the session goes deaf with nothing on screen. The other three truncations in the tree already did this correctly. - Echo compacts too, with invented numbers and a real shape, so this screen can be looked at without spending two minutes of somebody's account to reach the state. |
||
|
|
42131c75d6 |
Send a steer into the running turn, and put an image under its call
**The queue was holding messages the CLI would have taken.** Two claims in this file contradicted each other: the module header said a mid-turn message is injected at the next tool boundary -- "the behavior this app exists for" -- and `Queue`'s own doc said a line written mid-turn simply becomes the next turn. The code followed the second, parking every message until `Status::Idle`, which is the end of the whole turn. Measured rather than argued, twice. Writing a line straight into a live session's stdin fifo mid-turn produced one `result` for the whole thing, so it was consumed inside that turn, not as a new one. The header was right and the queue was built on the wrong claim. The cost was exactly what Bryan reported: he steered after the second tool call and it sat unread until every remaining call had finished. Measured before and after on the same three-step turn -- steer sent at +13s, recorded at +24.7s before this change and at +14.1s after, which is the next tool boundary. So the line goes out immediately. What stays behind is the *announcement*: the CLI says nothing on stdout about having read a message, so `MessageTaken` now waits for the next assistant text or tool call, which is proof another model call happened and the steer was in it. That keeps a held message drawn below the working indicator until the session has actually taken it -- the thing that mattered when this was last changed -- without delaying the message to get it. Idle counts too, and is the case that must not be missed: a message written after a turn's last model call has no later output to prove anything. `closed` is untouched, and `Queue::close` still reports held messages by name rather than dropping them. **An image now names the call that produced it.** `Event::Image` gains `about`, the `tool_use_id` from the tool result it came out of, so a screenshot is drawn inside that call's card instead of floating beside it -- pairing them by position is what a page boundary breaks. `None` for a person's own attachment, which belongs to no call. The import path threads it through as well, so replayed history reads the same as live. Images show whether the card is open or closed: a call whose result *is* a picture says less closed than the one line it replaced. Verified on the emulator against a real haiku turn: the checkerboard sits inside `Read /tmp/tiny.png`, and the steer sits between that call and the next, where it was taken. 53 tests, clippy, rustfmt, lint and ktfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |