Commit Graph
102 Commits
Author SHA1 Message Date
iris-ai 1b38579b97 Land a closed card centred on the tap that closed it
Two things were wrong with the hold, and each hid the other.

It held a *share* of the row's height: the point the finger was on stayed, in
proportion, which is the same miss in miniature as holding an edge. Tap away
from the middle of a long card and the heading landed most of a card's height
from the finger, and off it. A shut card is a heading, and the only place it
belongs is centred under the hand that shut it, wherever down the card the tap
was.

And the correction was worked out from the change in height, which needs the
list to behave the way the arithmetic assumed. It does not: which item it holds
still across a resize depends on what it has composed -- a row taller than the
screen is anchored on itself -- and a scroll it cannot honour in full is
honoured in part with nothing said. Measured rather than predicted now: each
measure pass asks for the error it can see, and the pass that answers is where
the rest becomes askable. Three passes is the worst seen, including the one
where a card that reached past the bottom of the screen has shut, left the
viewport entirely, and has to be asked back to the bottom edge before there is
anything to measure at all.

The pass has to be the *measure* one. A scroll asked for during placement is
never picked up by another measure and does nothing whatever -- which is what
the first version of this did, and why a close moved nothing -- so the list
took an `afterMeasure` hook and the correction lives there.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox, against a 2,785px card in a
conversation with room on both sides: closed at 1450 it lands 1387..1513, at
1700 it lands 1637..1763, at 1950 it lands 1887..2013 -- centred on the tap to
the pixel each time. Opening by the heading still holds the heading still, and
a group closed from its footer bar lands on the bar. Where the conversation
runs out -- a card at the very start with nothing above it to scroll -- it
lands as close as the list can put it, which is what it could always do.
2026-09-16 02:38:48 -04:00
iris-ai b86a5dc37a Hold an open call out of its run without taking one out of a group
Being open did two things to grouping, and only one of them was wanted. It held
a call standing on its own out of the run it belongs to, so a command finishing
behind the card being read no longer shuts it and folds it away mid-sentence.
It also took a call *out* of the group it was already inside, and that is what
made collapsing jump: grouping is what gives a row its identity, so one tap
rebuilt the rows around the finger -- opening a call inside a group split the
group into two pieces with mismatched keys, and closing one replaced three rows
with one, which no anchor survives. Measured at 450px of jump, with the card
that was closed going with it.

So the held-out set is now the screen's, not the transcript's: a call that has
never been drawn inside a group and is open stands out of its run, and a call
that has been in one stays in it whatever the reader does to it. Being inside a
group once is a fact about what the reader has been shown, which is why the
screen is what remembers it.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: opening a call inside an open group of
six leaves it one group of six and closing it returns every row to the pixel it
came from; a call opened while standing alone survives a reply landing behind
it, and folds back into "Called 3 tools" when it is closed without moving the
rows below it.
2026-09-16 01:57:50 -04:00
iris-ai 463acb28fa Close a card on the point that was touched
Collapsing held one of the row's edges -- whichever the tap was nearer -- which
is right for opening and wrong for closing: the row that shuts leaves a heading
where a screenful of card was, and both its old edges can be a screen's length
from the finger that shut it. It now keeps the touched point itself, which for a
closed card is the same thing as landing under the hand that closed it. Opening
is unchanged and deliberately so: those rows are small, every point in them is
within a heading's height of both edges, and the edge pressed is what the reader
wants held rather than a fraction of an unbounded expansion.

One number carries both readings -- the share of the row's height above the
touch, spent as it is on a close and rounded to the nearer edge on an open.

The scroll offset the correction asks for goes negative on a close, and has to:
that is the list being asked for the rows below what it has composed, which is
where the newer content comes from when a card gives a screenful back. It was
clamped at zero, which was invisible while every correction was a row growing
and is what left closes uncorrected.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: a 1441px card closed at a quarter of
its height put the collapsed card's top at 743px against 743 predicted, and
opening a card by its heading still holds the heading still.
2026-09-16 01:50:33 -04:00
iris-aiandClaude Opus 5 cbae7ee8c0 Order the session list by when each agent was turned on
A running session no longer moves: the ones with a process come first,
oldest start first, so starting one appends it to the bottom of that
group and nothing it goes on to do -- beginning a turn, finishing one,
asking a question -- can shift it. Sorting by activity with the
awaiting-answer ones floated to the top is what this replaces; the
status word and its colour already say which session wants something
without the row having to move to say it. Stopped sessions are a group
below, most recently active first.

The order is the server's: `SessionConfig::started` is written each time
a process is started for a session and reported as `started`, so it is
the same on every device and survives a backend restart -- which adopts
processes rather than starting them, and so could not work the times out
for itself. Applied on the phone, because presentation order is a
display decision.

`LiveSession::info` takes the session's config entry rather than a
parameter per field read from it, which is what `AutoResumeView` existed
to bundle; that goes.

Verified on the emulator against the sandbox: three echo sessions kept
their order while the newest-active one was messaged; a stopped and
restarted session moved below one started after it; a stopped session
dropped below every running one; and after a backend restart the
recorded times came back unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:54:36 -04:00
iris-ai 947ea8ecf2 Keep tool group keys unique across transcript 2026-09-15 23:20:47 -04:00
iris-ai f00a178cf0 Ignore replayed tool starts 2026-09-15 23:14:11 -04:00
iris-ai 9bcf0f1a48 Keep open tool cards out of groups 2026-09-15 22:43:45 -04:00
iris-ai 33b130b6bb Let failed messages be discarded 2026-09-15 19:03:44 -04:00
iris-ai 8262ceb786 Show live background task counts 2026-09-15 13:44:32 -04:00
iris-ai f0661919bb Offer Claude sign-in from failed sessions 2026-09-15 12:24:25 -04:00
iris-ai 0be15adbee Post the drawer's row behind the app's own banner
A notification arriving while the app was open was shown as a banner and
nowhere else, so a moment that happened while the phone was face-up on a desk
left nothing behind at all -- the banner is seconds long and reaches only
somebody already looking.

The two are not two versions of one thing: a banner interrupts and a row
records. Both go up now, and the banner having done the interrupting is what
makes the row a silent one (`setSilent`), so one moment is worth a noise once.
What keeps the drawer from filling up is the other end rather than suppression,
and already was: opening a session clears whatever is posted about it, whichever
way the reader got there.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox, reading the posted record out of
dumpsys: app on the session list gives a banner and flags=AUTO_CANCEL|SILENT;
app backgrounded gives flags=AUTO_CANCEL; opening the session leaves nothing
posted about it in either case.
2026-09-15 02:06:12 -04:00
iris-ai 1e52b2910c Keep the last tool call outside its group once it finishes
A call left its group only while it was running, so the moment a command ended
it vanished behind "Called 3 tools" -- and a session that has run its last
command and is composing its answer, or has finished the turn entirely, spends
most of its time in exactly that state. What folds a call back into its run is
therefore not finishing but being overtaken: anything arriving behind it, a
reply included, makes it history.

Standing outside the run is the call's place in the list as it is now rather
than something recorded on the call, so it is asked of the list while grouping
it, where the rest of that decision already lives.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox: "/tools 3 1" settles as "Called 2
tools" with the third Bash card beneath it, and folds to "Called 3 tools" the
moment the next reply lands.
2026-09-15 01:52:51 -04:00
iris-ai 036eb375aa Keep the running tool call outside its group
A run of adjacent calls is drawn as one collapsed card, which hid the one
thing worth seeing without opening anything: the command the session is
running right now. It is a row of its own while it runs and folds back into
the run when it ends.

Grouping stays a display decision, so the pieces a running call cuts a run
into are keyed there. The first piece keeps the run's name -- that name is
what survives a page of history landing in front of it -- and later pieces
take their own first call's id behind it, since the call a run was named
after can itself be the one running.

The echo rig's /tools gap now runs between a call's start and its end rather
than between one call and the next, which is where a real session's time goes
and what makes the running state observable at all.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest (new
ToolRowsTest) and lintDebug, cargo fmt/clippy/test, and on the emulator
against the sandbox: "Called 2 tools" with the live Bash card beneath it.
2026-09-15 01:13:03 -04:00
iris 579689cbb8 Keep a thinking level the settings dialog set
The dialog held the level for as long as it was open and read it back
from the frozen row the session screen was opened with, so reopening it
showed the old level until a return to the list refetched the row. The
level is the session screen's own datum now, like the title.
2026-09-13 12:34:21 -04:00
iris 898e6b92d0 Clarify subagent coordination cards 2026-09-13 02:48:21 -04:00
iris 7d9df5d572 Rename setups and add provider reauthentication 2026-09-12 22:56:43 -04:00
iris e9a0f1b9da Do not enlarge images on open 2026-09-12 21:52:47 -04:00
iris 6d765ff6e4 Make image viewer truly full screen 2026-09-12 21:33:29 -04:00
iris 76895bc644 Remove image viewer touch ripple 2026-09-12 20:28:28 -04:00
iris 0b4da64062 Fix image zoom focal point 2026-09-12 20:16:25 -04:00
iris 62cb6c91d5 Navigate explorer back toward project 2026-09-12 19:38:18 -04:00
iris 2ff0b13950 Return from files to explorer 2026-09-11 12:40:27 -04:00
iris 57e1cec09c Render Codex web searches as common tools 2026-09-11 02:31:21 -04:00
iris 6226a1cb43 Keep compact transcript history loading 2026-09-11 01:18:45 -04:00
iris 22f263ccce Restore directory navigation on Android back 2026-09-11 00:28:55 -04:00
iris 59965d314f Keep compact Codex history loading
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).
2026-09-10 18:51:29 -04:00
iris e3cca97cdd Open transcript file links in explorer 2026-09-10 18:08:05 -04:00
iris 3c6e6778fd Keep sent messages visible until received 2026-09-10 02:17:11 -04:00
iris 3c19b5a9bb Fix Codex transcript convergence 2026-09-10 01:24:16 -04:00
iris f9c8f640ce Fix quoted Bash tool titles 2026-09-10 00:46:09 -04:00
iris 4c15150338 Return from files to explorer 2026-09-09 22:48:52 -04:00
iris b507656abd Normalize shell and patch tool cards 2026-09-09 20:30:52 -04:00
iris 14dd520719 Fix explorer back and session usage selection 2026-09-09 13:01:27 -04:00
iris 8c88a7e991 Use native Codex steering and transcript deletion 2026-09-09 12:19:11 -04:00
iris 00538cc19b Show separate Codex usage pools 2026-09-08 00:41:06 -04:00
iris 7ee88dfd9c Make model and permission choices provider-specific 2026-09-08 00:08:09 -04:00
iris 6a0202b1b5 Add Codex JSON sessions and usage limits 2026-09-07 23:29:15 -04:00
irisandClaude Opus 5 fd71d876e1 Never let one unreadable line take a transcript down
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>
2026-09-06 22:23:45 -04:00
irisandClaude Opus 5 1bbb642973 Take subagent reports out of the main transcript, and separate turns with a rule
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>
2026-09-06 21:39:19 -04:00
irisandClaude Opus 5 ef1aad8776 Keep a subagent's words in its own transcript, and count the ones already running
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>
2026-09-06 19:54:36 -04:00
irisandClaude Opus 5 5711c2568a Never run two turns into one, and say when a session waits on its own work
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>
2026-09-06 18:57:30 -04:00
irisandClaude Opus 5 74c07d687a End subagents on the CLI's own task lifecycle, and detect a limit two ways
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>
2026-09-06 13:29:23 -04:00
irisandClaude Opus 5 13d2d11c2d Order a session's subagents by activity, and delete finished ones by holding
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>
2026-09-06 13:10:21 -04:00
irisandClaude Fable 5.1 cf10b17c5b End a subagent on its own end_turn, not the parent's tool_result, and give the expander a touch-sized row
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>
2026-09-05 14:44:00 -04:00
irisandClaude Fable 5.1 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
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>
2026-09-05 13:41:15 -04:00
irisandClaude Opus 5 6bdec6e785 Let a session resume itself when its usage limit lifts
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>
2026-09-05 05:21:43 -04:00
irisandClaude Opus 5 4821a02bd3 Default thinking level for new sessions, and move the rigs out of AGENTS.md
`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>
2026-09-04 21:42:05 -04:00
irisandClaude Opus 5 1ff662c7c3 Let a session choose how hard it thinks
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>
2026-09-04 21:23:58 -04:00
iris 3c0214ece8 Merge branch 'main' of git.arirex.me:iris/ai-app
# Conflicts:
#	AGENTS.md
#	PLAN.md
#	app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt
#	app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt
#	server/src/config.rs
#	server/src/main.rs
#	server/src/routes.rs
#	server/src/session/echo.rs
#	server/src/session/llama.rs
#	server/src/session/transport.rs
#	server/src/ssh.rs
#	server/src/usage.rs
2026-09-04 17:56:50 -04:00
irisandClaude Opus 5 127b25e60a Meter a session by its provider, and let llama.cpp run over ssh
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>
2026-09-04 17:45:32 -04:00