Commit Graph
18 Commits
Author SHA1 Message Date
iris 25370731d0 Prune commentary and stale Rust port notes 2026-09-10 00:44:13 -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
irisandClaude Opus 5 79682f03a7 Condense the documentation and thin the server's comments
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>
2026-09-04 15:45:43 -04:00
irisandClaude Opus 5 a383c19dd5 Colour what a shell printed, swipe back, and let a stopped session take a setting
Bash output arrived with its escape sequences in it, so a coloured diff or
test run was line noise around the thing being read. The sequences that
decide how text looks are spans now and every other one is dropped, with a
carriage return honoured the way a terminal honours it so a progress bar
shows its final state rather than every state it passed through.

A rightward drag anywhere on a session, spawn or settings screen steps back,
following the finger so it can be abandoned. It loses every argument: a
child that consumes horizontal drags -- a wide fence, a table, a selection
-- has already taken the gesture before this sees it.

Changing the model or the permission mode of a session with nothing running
was refused, in words about the driver, while the config had already taken
the value that its next start will use. Both now announce the stored setting
instead, through one function, since which of the pair it is does not change
the rule.

The model-switch warning no longer fires after a clear: the server reports
the context as unmeasured rather than zero afterwards, and the fallback
reading counted the whole conversation still on screen.

An image loading shows a spinner in the space it is about to fill, in the
transcript and in the composer's attachments alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:17:12 -04:00
irisandClaude Fable 5.1 801618ba0e Stream attachments end to end, ship files to a remote session's machine, and write up the transcript work
Uploads no longer sit whole in memory anywhere: the phone writes the
multipart body chunked as it reads the picked file, and the server writes
each chunk to a `.part` file under the session and renames it when whole.
The per-request cap is 4 GB and bounds disk, not memory.

A file attached to a session on another machine is copied there in the
same request: one ssh invocation takes the bytes on stdin into the
setup's `attachmentsDir` (new, optional, on the machine form and in the
config), else the session's cwd, else the login home, and answers with
`pwd -P`, which is recorded beside the file as `<name>.remote` and is the
path the driver tells the CLI. A failed copy fails the upload and says
why, so no message ever names a file that is not there. The host keeps
its copy so transcripts can reference and fetch it. Measured against the
Gentoo test guest: a 40 MB file shared from the phone arrived there byte
for byte. The tilde in that setting is the remote home, so it is not
expanded on the server the way other setup paths are.

TRANSCRIPT_RENDERING.md records the week of transcript work -- the
measurements behind each decision, the harness, what was rejected, and
what to do next -- so a new session can start from it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:24:36 -04:00
irisandClaude Fable 5.1 6180663f14 Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 08:15:10 -04:00
irisandClaude Fable 5.1 1656b058bf ai-server --enroll-link: mint one more device's link while the server runs
Prints the enrollment URI, one line on stdout, and exits; the running
server adopts the token the first time that device presents it, via the
spool wg-app-link's enroll module now provides (submodule bumped to
d35c880). This is the server half of enrolling through Dev Updater: its
coming per-component Enroll button runs this command and opens whatever
it prints on the phone, which is what a reinstall -- a signing change, a
new phone -- needs when nobody is at the terminal the QR is printed on.

Verified against the sandbox server: minted while it ran, first request
with the token served and the token moved into config.ron, spool empty,
second request served as an ordinary token. 108 tests, clippy clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:44:17 -04:00
irisandClaude Opus 5 465645cefb Draw a peer message above the turn it started, not below it
The live Claude Code path only learns a turn was another agent's when
the turn ends -- the whole of the message arrives as an `origin` object
on the `result` -- so the note was appended after everything it caused,
and the transcript showed the answer above the question.

It cannot be recorded in place: by the time anyone knows, the reply is
already written, and the transcript is append-only. So the event carries
where it belongs instead. `PeerMessage` gains `turnStart`, the seq of
the status that opened its turn, stamped by the pump -- the only thing
that knows a seq and the only thing that sees every driver's turns. The
phone gives the note that seq, so it sorts into place rather than being
drawn out of order at the end. A status draws no row, so there is
nothing for it to collide with and the list stays sorted, which the
scroll anchor and paging both depend on.

Absent where there is nothing to correct: a message replayed out of a
session file by `import` is already in the right place, and one that
opened no turn has no turn to sit above. Both stay where they arrive.

The echo driver gets `/peer-turn` for the live shape, beside `/peer` for
the in-place one. Verified on the emulator both ways, live and on
replay, plus an ordinary `/tools` turn to confirm the run grouping the
insertion cuts through is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:20:38 -04:00
iris deb908034c Move a session to another working directory
`POST /sessions/{id}/cwd`, behind a field in the session settings dialog. A
working directory is settled when the process is spawned -- the CLI is
launched with it as its cwd and there is no control request that changes one
-- so this records the new one and ends the process that is in the old one.
It does not start a replacement, and the field says so in a line beside it:
a session with no process starts on the next message or on Start, which is
this app's rule for that everywhere else, and "usually restarts" is a worse
control than "always stops".

The path is checked against the session's own machine and refused if it is
not there. The spawn path corrects instead of refusing, because it is
resuming a directory the *machine* recorded and that can be gone through
nobody's fault; a path somebody has just typed is different, and a mistyped
one accepted here would surface much later as a session that would not
start, with nothing pointing at the typo. The refusal names the machine and
the path, and is drawn under the field it is about.

Nothing of Claude Code's own is moved, and that is measured rather than
assumed: on CLI 2.1.237, `claude --resume <id>` finds a session from any
working directory -- an id that does not exist answers "No conversation
found with session ID", and a real one resumed from an unrelated directory
did not. So the conversation continues in the new place with nothing
relocated. Doing otherwise would mean reproducing a rule this app cannot
see the whole of; PLAN.md records what that rule is, for whoever tries.

Found while checking it: `SessionInfo.cwd` came from the snapshot a session
launched with, so a moved session went on reporting its *old* directory for
as long as its process lived -- a dialog showing a directory the next launch
would not use, with nothing saying so. It is read from the config where the
row is built now, the same way `setup_name` already was, and for the reason
already written above `setup_name`: only the manager holds the config, and
both of these change under a running session.

Checked end to end on the emulator against a session whose process really
does take a cwd: /proc said /tmp/cwd-a before and /tmp/cwd-b after, the
dialog showed the new path immediately rather than after a restart, and a
directory that is not there and a relative path were both refused with the
session left exactly as it was.
2026-08-31 23:16:34 -04:00
iris a1eedd7a78 Don't call a turn finished with a message still waiting behind it
A message written into the tail of a turn is read the moment that turn's
`result` lands: the session reports idle and is running again in the same
breath. The phone that sent it got "finished" in between -- seconds before
anything it asked for had been done, which is the notification arriving to
say the opposite of what is happening.

`notification_for` now takes how many messages the session has been given
and not started reading, and a turn ending with any of them waiting is not
an ending. The count is kept in `pump`, from the recorded events, because
that is the one place that sees all of them in transcript order: a
`messageQueued` up, and the `userMessage` that resolves it or a
`messageDropped` down. Asking the driver instead would answer about the
moment the question was asked rather than the moment the status was
written, which is the same class of mistake as reading a session's status
to decide what a queue contains.

It deliberately does not suppress *awaiting input*. A question is worth
interrupting somebody for whatever is queued behind it -- the queue is
precisely what will not move until it is answered.

Tested both halves: the decision on the number, and the number itself,
where an echo turn that reads its queued message before going idle still
announces its finish. That last is the case a suppression written slightly
wrong silences, and it is the common one.
2026-08-31 22:43:24 -04:00
iris 82401cd887 Select any of the transcript, and take a queued message back
Two things a reader could not do to what is on screen.

**Selection.** Nothing in the transcript was selectable at all, so a
command, a path or an error message could be read and not copied. One
`SelectionContainer` around the whole list rather than one per row: a
transcript is one body of text to a reader, and a selection has to be able
to run from a reply into the tool output under it. Per row it also could
not, and whatever was drawn without a container would have been silently
unselectable -- a state nothing on screen reports. Rows keep their tap
handlers; checked on the emulator that expanding a tool call, scrolling and
flinging are all unaffected, since a selection is a long press.

**Taking a message back.** A message sent into a running turn sits as a
bubble waiting to be read, and there was no way to change your mind: it is
tappable now, and the server answers `POST /sessions/{id}/unqueue`.

The answer has three states, and the middle one is the point. Claude's
driver writes a steer into the CLI's stdin the instant it arrives -- that
is what makes it reach the model at the next tool boundary rather than at
the end of the turn, and it was measured -- so the line is already gone and
`AlreadySent` is the only honest answer it can give. Holding the write
until a boundary would make the drop real and cost a steer one model call,
which is the latency the immediate write exists to remove; rejected on that
trade, with the reasoning in PLAN.md. The refusal is drawn on the bubble
that was pressed rather than in the error row under the header, a screen
away from it.

Where a driver really does hold its queue -- echo today -- the message goes
for good, and it goes as an `Event::MessageDropped` rather than as a return
value: every device watching the session loses the bubble, and a phone that
reconnects and replays the `messageQueued` does not put back one that was
cancelled with nothing left to resolve it.
2026-08-31 22:20:47 -04:00
iris 3c159fa1e1 Run imports and deletes on the server, and say so on an event
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.

So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.

Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.

The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.

Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.

The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
2026-08-31 21:05:33 -04:00
iris b172c464ea ai-app: a phone interface to Claude Code and llama.cpp sessions
A Rust backend that owns the sessions and an Android app that reads them.
The server spawns and adopts CLI processes, normalises everything they emit
into one event model, keeps the transcript, and serves it over pinned TLS on
a WireGuard interface; the phone streams that, replies, sends images, and
imports conversations the machine already has.

`AGENTS.md` is the working guide -- what runs where, what has been measured,
and the faults that were expensive to find. `PLAN.md` is the design record.

History before this point was squashed away. It was a personal project's
running commentary and carried a name and a couple of machine paths that
have no business in a public repository; the tree is what mattered and the
tree is here.
2026-08-31 20:29:07 -04:00