Restart the history observer after every successful page so a collapsed tool page cannot consume the only layout invalidation that would request the next one.\n\nVerified with a cold 365-event tool-heavy sandbox transcript: without scrolling or expanding a group, cache coverage advanced continuously to sequence 1. Android format, compile, lint, and JVM tests pass. Transcript bench: 26 rows/26 units loaded, transcript draw 0.72 ms per frame (debug emulator).
A session whose transcript will not parse is skipped with only a log line, so
from the phone it is indistinguishable from an idle unresponsive one. That is
why the outage needed a report from Bryan rather than showing itself. The
cause is fixed; the class is not, and it is the "design the unknown state
first" rule rather than a bug in one code path.
Also rustfmt on the parse path, which the fix landed unformatted.
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>
The subcards were oldest first, which buried whatever is working now. They
are ordered on the phone -- still running first, then most recently active --
over the server's stable oldest-first answer, since presentation order is a
display decision and a subagent that is thinking reports nothing meanwhile.
Holding a subcard selects it and several at a time, the import list's gesture
and its confirmation, so selecting is learned once. The selection bar sits
inside the session's card rather than at the bottom of the screen: it belongs
to one card, and one Delete is one request against one parent, so picking a
row in another card moves the selection rather than adding to it. Delete is
disabled, with the reason in words, while anything selected is still running
-- its transcript is still being written to and its process is the session's
to stop, so the server refuses that batch outright.
`POST /sessions/{id}/subagents/delete` takes the batch and checks every id
before removing any, so a set naming a running one is left exactly as it was
rather than half-deleted. It is `Subagents::start`'s path out. What counts as
running is shared with the list route through `has_a_process`, so the two
cannot disagree. On success the phone takes those rows out of that one card
and off the session's count, purges its cached copies, and drops the
expansion when nothing is left -- nothing else is refetched.
Driven on the emulator against the sandbox with ui-trace's new hold-by-name:
selecting two, the dialog, the rows going, a running one holding Delete
disabled, and the expander leaving with the last subagent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Agent tool runs subagents in the background, so the parent's result
arrives at launch while the subagent works on for minutes; finishing on it
read a running agent as finished with a transcript cut off at launch. A
subagent now ends on its own message_delta end_turn, and a later line for a
finished one reopens it, since a background agent can be messaged again.
The card's expander row was only the chevron's height, so a tap for it
landed on the first subcard; it is the platform's 48dp minimum now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.
On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.
Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A 401 from the usage endpoint means the stored access token has expired.
Refreshing it here is not an option: Anthropic's OAuth rotates the refresh
token, so a second refresher invalidates the CLI's copy and forces a
re-login on a machine that usually has a live session on it. So run the CLI
there instead and re-read what it wrote.
`doctor` rather than `auth status`: probed against 2.1.258 with an invalid
token, `auth status` answers loggedIn:true from the file alone and never
reaches the network. The same probe showed a failed refresh blanks both
tokens, which is why this stays on the 401 path.
Also gives ProviderConfig one program() so the CLI's default path is not
written down twice.
A 401 is the endpoint answering and refusing the stored OAuth token, which
Claude Code refreshes as it runs -- so a machine whose CLI has been idle
hands us a stale one. Reporting it as "usage endpoint unreachable" pointed
at the network instead of at the one thing that fixes it.
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>
`gates_every_route_and_never_logs_the_token` failed about one full-suite
run in ten, on the assertion that a rejection *was* logged. Its sibling
ends with an unauthenticated request of its own, made with no subscriber
on that thread -- and tracing caches a callsite's interest process-wide
the first time it is reached, so whichever test got there first decided
whether the warning would ever be recorded.
That is the rule already written at the top of "Things that have bitten",
applied to one member of a set: the combined gating+logging test exists
because of it, and the enrollment test added later did not get it.
Twenty runs clean since.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rate-limit bar answered a question about an account, and picked the
answer by machine. One machine runs echo, the Claude CLI and a local
model side by side, so every echo session on it drew the CLI's five-hour
window: a quota that session cannot spend and could never run down. A
session now names its meter (`usageProvider`, from
`DriverKind::usage_provider`, which `usage::providers_for` reads too so
the two lists cannot disagree), and the phone matches on machine *and*
provider. Nothing meters echo or llama, and nothing at all is drawn --
including while the first fetch is out, since "checking" under a session
that turns out to meter nothing is a row the screen then withdraws.
Echo gets a meter it can be *told* about instead: `/usage 42`,
`/usage 95 20`, `/usage 42 never`, `/usage notloggedin`,
`/usage unreachable`, `/usage failed`, `/usage off`. Those states cost
real quota to arrange, which is why none of them had been looked at.
And llama.cpp runs wherever a setup says, which was the last of phase 5.
`Transport::reserve_port` is the second half of what a transport is --
"run this" plus "reach this port" -- returning the port the server binds
there and the port that reaches it here, and `Launch::reaching` puts the
`-L` tunnel on the connection that already carries the command. Three
things that came out of building it:
- A forwarded launch gets a pty and every other one keeps `-T`. Killing
the ssh client ends a CLI by closing the stdin it reads; llama-server
never reads its stdin, so the same kill left it running on the far
machine with the model loaded -- one orphan per stopped session.
- The model is looked for on the machine that will serve it, at that
machine's own models directory, so `GET /setups/{id}/models` is what
the spawn screen offers rather than the backend's own downloads.
- The readiness poll watches the process, not only the port: a model
that will not load exits in a second and would otherwise have been
reported as "gave up after 300s". The failure carries the log's tail.
Exercised end to end against this VM over ssh to itself: spawn, load,
answer, outlive a backend restart, be adopted, answer again, and stop --
with both the ssh client and the far llama-server gone afterwards. The
local path, the Claude bar and the spawn screen checked on the emulator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md and PLAN.md were both carrying their own copy of the HTTP
surface, and the previous commit replaced those with a pointer to this
module doc comment -- which turned out to be missing ten routes that
exist: the four `/setups/{id}/importable*`, `/sessions/{id}/permission-mode`
and all five under `/models`. Naming it the source of truth is only worth
doing if it is one.
The two "later phases add" lines at the foot are gone. Setups replaced
`/hosts` in August and `/models` is the block just added above them, so
both were promising work already done.
cargo test (127), clippy --all-targets and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The same pass the server had, on the Kotlin side: comments restating what
the code says are gone, and the ones recording a measurement, a constraint
or an incident are kept but cut to a few lines each. 6540 comment lines to
5674, and 920 lines off the app.
Two doc comments had drifted onto the item above the one they describe --
`contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s
equivalent on the server was fixed in the previous commit. Each is back on
its own item, which is the only non-comment line this diff moves.
The comments are reflowed to the column limit at their own indentation:
several were written wide, and ktfmt re-wrapped them into lines holding a
single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the
result, which is the check.
Left alone deliberately: this codebase's remaining comment density is high
because the comments carry things the code cannot say -- what a null means,
what a number was measured against, which bug a guard exists for. Of the
238 one-line doc comments in the app, five were pure restatement of the
name and were removed; the rest each say something the signature does not.
ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass;
cargo test (127), clippy --all-targets and fmt still clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markdown had accumulated a lot that was stale rather than wrong.
PLAN.md still described pi as the llama.cpp harness, a refcounted
LlamaServerManager, and a providers-by-hosts cross-product, all of which
were superseded or never built; it also carried a second copy of the HTTP
table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held
implementation checklists for work that has since landed. AGENTS.md
restated most of PLAN.md's design instead of being the working-notes
layer it says it is. 3225 lines of markdown to 2180, with the stale
sections gone rather than reworded.
On the server, comments explaining what the code already says are out and
the ones recording a constraint, a measurement or an incident are kept but
cut to a few lines each: 5504 comment lines to 4586.
Four doc comments in session/mod.rs, and one each in process.rs and
usage.rs, had drifted onto the item above the one they describe --
functions were reordered without them, so `stop_session`'s doc sat on
`set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on
`type Cached`. Each is back on its own item.
routes.rs's module table also claimed later phases would add `/hosts`,
which setups replaced.
cargo test (127 passed), clippy --all-targets and fmt are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reopening a session downloaded the conversation again, every time, over
the tunnel. It now draws from a copy of what the server has already sent
and asks for one event to check that copy is still current.
Per session, under cacheDir, the server's own event lines in chunks named
for the range they cover -- so a coalesced page, whose lines do not say
what they cover, still records it. Only the contiguous run ending at the
newest chunk is served; a gap is closed by paging through it, bounded by
`after` on /transcript so the page stops where the phone's copy starts
and can therefore be kept. Nothing is derived and stored: rows are a
rendering, and a cache of them would need throwing away on every change
to the fold.
Nothing here is load-bearing. Missing, evicted, damaged or unwritable all
degrade to the cold open this screen did before, and the check before the
stream resumes -- one request, one event -- is what stops a replaced or
truncated file being spliced onto a copy of a different conversation.
What that check cannot see, a line changed mid-file with the tail intact,
is what Reload in session settings is for.
Measured on the emulator against ui-sandbox, on a 505-event session:
reopening it costs one request for one event, including scrolling the
whole conversation back; a cold open is two requests and 100 events. A
reset after falling 300 behind fetched the gap as four coalesced rows
rather than re-fetching 104 events and discarding them. Every chunk was
checked line by line against what the server says for the range its name
claims, across the reset and the gap-fill.
transcript-bench.sh, same viewport content and gestures, before and
after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the
transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms
either way. Within the emulator's noise, which is what a cache must be:
it changes what is fetched, not what is drawn.
Building it also found that the server handed out the same transcript
line two different ways. serde_json's default float parser is not
correctly rounded, so a ts written as ...0757 came back from /transcript
as ...0755 while the SSE stream sent the original -- invisible on screen,
since a ts is drawn as a relative time, and visible here only because the
cache compares a line it holds against the server's answer. Fixed with
float_roundtrip, with a test that fails the moment it is dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against a session streaming 20 events a second. Reopening one with
an anchor 1,800 events back connects 87-119 events behind, under
CATCH_UP_LIMIT's 200 -- the restore is two requests now, the opening page
and one span covering the whole distance to the anchor, where the report
was written against a restore that took thirteen. Three provocations, no
repeat connection between them: that reopen, twenty seconds backgrounded
while 415 events were produced, and a reset forced by dropping the limit to
5, where the app cleared, refilled and carried on without reconnecting.
So the TODO entry goes. What stays is the instrument that answered it:
every SSE subscriber now logs at debug the cursor it arrived with and
whether it was continued or reset, since nothing else could say. The app
sees a window arrive and cannot tell how far it had fallen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file was a week's record of the transcript work, and everything in its
architecture, techniques and rejected sections has landed -- so it was a log
of how the code got here, which the code's own comments and AGENTS.md
already carry. Kept only what existed nowhere else: stream-bench.sh,
trace-draw.sh and the two emulator-loop traps go to AGENTS.md beside
transcript-bench.sh, along with the standing rule to run the benches either
side of a transcript change; GrapheneOS's broken System Tracing goes to
~/.claude/MACHINE.md, since it is about the phone rather than this project.
Its one unfinished item -- the reconnect loop after a restart onto a
streaming session, which may already have been fixed by the restore's
one-event-per-request fix -- moves to TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A table is recognised by its delimiter row, the only line of one that cannot
be anything else, and its header is the line above -- the single place the
scanner looks ahead. Colouring every `|` instead would have marked the pipes
of a shell command written in a paragraph.
Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign
inside it and no whitespace, which leaves `<div>` alone; a bare `scheme://`
needs no closer, so where it ends is the decision -- the sentence's trailing
punctuation is given back, and so is a closing bracket unless one opened
inside the URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The token scanner asks what a character is; markdown's meaning is where it
sits, so a `#` opens a heading at the start of a line and is an ordinary
character three words in. `MarkdownSyntax.kt` reads structure a line at a
time and then each line's prose left to right, and `spansOf` is the one
entry point that hides which of the two scanners a language got.
Conservative wherever a guess would be invisible: emphasis needs a closer on
the same line with no space beside either marker, so the `*p = *q` of a C
fragment opens nothing; an underscore may not start or end inside a word;
and an indented code block is left plain, since four spaces after a blank
line and four after a bullet are the same line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>