Commit Graph
99 Commits
Author SHA1 Message Date
Kaede Akino e3ee3fc62e Prevent duplicate live reasoning 2026-08-27 11:53:57 +08:00
Kaede Akino ffca27dace Add Simplified Chinese localization 2026-08-27 11:53:57 +08:00
Kaede Akino cd67911cd8 feat: sync vault with CloudKit 2026-08-27 11:53:52 +08:00
Kaede Akino 69bc5e0bae fix: cap sidebar width responsively 2026-08-27 05:54:58 +08:00
Kaede Akino 0964535ddb fix: reserve width for vault detail panels 2026-08-27 05:51:15 +08:00
Kaede Akino 0250a384d1 fix: unify sidebar interactions and window limits 2026-08-27 05:36:37 +08:00
Kaede Akino d10816165f feat: show runtime versions in settings 2026-08-27 05:08:34 +08:00
Kaede Akino 99511480d4 Refine MCP authentication window styling 2026-08-27 05:04:01 +08:00
Kaede Akino ade1f5888b Fix transcript scrolling during layout changes 2026-08-27 04:47:14 +08:00
Kaede Akino 2ebbf8e877 fix: require response envelope for completed events 2026-08-27 04:27:33 +08:00
Kaede Akino cbc34748c3 Improve built-in tool result presentation 2026-08-27 04:27:33 +08:00
Kaede Akino fa38f89f85 Rename iCloud keychain storage case 2026-08-27 04:27:33 +08:00
Kaede Akino b294f4346f Show reasoning in active tool batches 2026-08-27 04:27:32 +08:00
Kaede Akino 4690e856e9 Add user input timeouts 2026-08-27 04:27:27 +08:00
Kaede Akino e1e7646dc8 Refine reasoning and tool work presentation 2026-08-27 02:07:24 +08:00
Kaede Akino bbc587d518 Fix stopped conversation streaming state 2026-08-27 01:55:01 +08:00
Kaede Akino e9d5149d9d feat: surface reasoning and group tool activity 2026-08-27 01:46:08 +08:00
Kaede Akino f7f9ab3451 Refine work process disclosure 2026-08-27 00:56:39 +08:00
Kaede Akino 46893c5579 Add Assistant context and collapsible work history 2026-08-27 00:50:33 +08:00
Kaede Akino 8485227e1a Improve request user input flow 2026-08-27 00:48:37 +08:00
Kaede Akino c900989d56 Select current slash command option 2026-08-27 00:46:01 +08:00
Kaede Akino 0f9c944d54 Add Assistant model visibility setting 2026-08-26 22:58:13 +08:00
Kaede Akino 1eb5d59193 Add file and image context to Assistant 2026-08-26 22:38:06 +08:00
Kaede Akino 76e993c447 Expand slash command menu 2026-08-26 21:35:06 +08:00
Kaede Akino c838cf38ae Refine assistant chat surfaces and interactions 2026-08-26 21:24:11 +08:00
Kaede AkinoandClaude Opus 5 ea88179e7d Plan before acting, track the work, and survive a long conversation
Five things the assistant could not do: research with writes structurally
unavailable, track a multi-step job, say how full its context is, summarize a
conversation that outgrew the window, or take a command instead of prose.

Token accounting goes first because the rest reads it. `usage` is a new stream
event parsed from all four providers. Anthropic needs its cache counts folded
into the input total — its `input_tokens` is only the uncached remainder, so
taken at face value a 100k conversation reads as a few hundred. Chat Completions
has to be asked with `stream_options`. A local estimator covers the gap before
the first response and produces the per-part split, scaled to the provider's
total so the breakdown and the gauge above it cannot disagree.

Plan mode is a loop hook rather than a fourth permission mode: hooks run before
authorization, so it can only tighten. It refuses anything that changes
something outside Termind, but judges `ssh_exec` on whether the command is
destructive rather than provably safe — the classifier fails closed, and gating
on it refused most of the ways of reading a server's state. Its contract is
injected at the model boundary so the system prompt stays byte-identical and the
prefix cache survives a toggle. `present_plan` takes a scratch path rather than a
document, so revising costs a diff, and it ends the turn rather than suspending a
run across a person reading. Widen the read-only command list to match: the
reporting `systemctl` subcommands and the usual inspection binaries, leaving off
names like `swapon` and `rpm` whose verdict depends on their arguments.

Compaction follows Pi. Cut at a turn boundary and never between a tool call and
its result, keep a recent budget verbatim, summarize the rest into a fixed
skeleton. It rewrites what is sent and not what is kept, so the transcript stays
whole on screen behind a marker. The marker is stored, because history is rebuilt
from every run's messages and a relaunch would otherwise undo a compaction at the
point it was most needed — which needs schema V8, with V6 and V7 frozen against a
copy of the old model, two versions pointing at the live type being "Duplicate
version checksums detected".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:54:08 +08:00
Kaede Akino 1c10cf97d9 Add MCP tool namespaces 2026-08-26 18:30:17 +08:00
Kaede Akino 70c66d8cd0 Refine assistant transcript behavior 2026-08-26 18:10:51 +08:00
Kaede Akino c59e2cb9e5 Refine MCP authentication window sizing 2026-08-26 17:35:06 +08:00
Kaede Akino 325968b8ed Improve Assistant working indicator 2026-08-26 17:26:55 +08:00
Kaede Akino 17855d69ae Refine Assistant chat UI 2026-08-26 17:18:04 +08:00
Kaede Akino 794471d75c fix: start the MCP loopback listener 2026-08-26 17:11:06 +08:00
Kaede Akino e7f9e3478d refactor: rename tool server to MCP server 2026-08-26 16:56:41 +08:00
Kaede Akino 75c7188b94 refactor: replace core with layered APIs 2026-08-26 16:56:25 +08:00
Kaede Akino cc637ee45d Refine chat tool activity UI 2026-08-26 16:56:25 +08:00
Kaede Akino c5d08fea29 feat: expose SSH and SFTP MCP tool server 2026-08-26 16:56:20 +08:00
Kaede Akino 416ae83214 Integrate Agent SSH authentication with SFTP 2026-08-26 13:57:19 +08:00
Kaede AkinoandClaude Opus 5 22543c2a56 Let a long wait end when the user has something to say
`ssh_exec` reports at fifteen seconds and hands back a handle, which left
the model two bad options for a three-minute build: block a long
`ssh_wait` — the turn occupancy this whole line of work exists to remove —
or poll in short ones at a full round trip each. Neither is good, and the
first quietly undoes the fix.

So a wait can now be ended by the user typing. `ssh_wait` races three
things instead of two: the command finishing, its own deadline, and the
user interjecting. An interjection is reported exactly as a deadline is —
not finished, ask again — because to the caller it is the same answer.

This is offered to tools rather than done to them, and the distinction is
the whole reason it is safe. Ending a *wait* costs nothing: the command
keeps running, its session stays, its handle stays valid, and the only
thing lost is the waiting. Ending `ssh_exec` would strand a half-finished
command on a host, which is why an interrupt was wrong there and is right
here. A tool asks `whenUserInterjects` when it is about to wait on
purpose; nothing is cancelled behind any tool's back.

The signal is stateful rather than a plain broadcast. A wait that began a
moment *after* the user typed would otherwise sit out its whole deadline
with a message already queued behind it, which is the exact failure it is
meant to prevent. It is armed when steering is queued and disarmed when
the queue drains into the turn about to start, so "is anyone waiting to be
heard" is asked of the queue rather than of a past event.

What this buys, end to end: the model can ask for a long `timeout_seconds`
without that being a decision about how long the user has to wait. A
message typed two seconds into a five-minute wait is answered at the
boundary that follows it rather than five minutes later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:26:28 +08:00
Kaede AkinoandClaude Opus 5 fed5f39caf Revert "Tell the model when a background command nobody watched has finished"
This reverts commit f671913. The problem it addressed is real — a model
that takes a handle and never comes back never learns the outcome — but
the fix worked in a narrower band than the argument for it suggested, and
cost more than the band was worth.

It fired only when the command finished *and* nobody was in `ssh_wait`
*and* the run was still alive. That third condition is the one that
undoes it: a model which starts a deploy, tells the user it is running,
and ends its turn has no run left to be told, so `notify` was dropped —
in exactly the case where forgetting is most likely. What remained was
the narrow case of a model still working on something else in the same
run, which is also the case where it is most likely to remember.

Against that: up to 256 KB of output pushed into the context at a
boundary nothing asked for; a `user`-role message the user never wrote,
replayed in history forever, because the transcript has only three roles
and a report has no seat of its own; a waiter count, a per-command
watcher, an event and a queue. And a defect — `announceCompletion` marked
the entry announced without removing it, so a later `ssh_wait` returned
the same outcome a second time.

The two-bad-options problem it was really solving is that a model told
"still running" can either block a long `ssh_wait` — which is the turn
occupancy this whole line of work exists to remove — or poll in short
ones, at a full round trip each. The answer to that is to make a long
wait interruptible rather than to push at the model, which is what
replaces this. Cutting a *wait* short costs nothing at all: the command
keeps running, its session stays, its handle stays valid. That is the
same reasoning that made an interrupt wrong for `ssh_exec`, where it
would have cost a half-finished remote command, and right here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:17:38 +08:00
Kaede AkinoandClaude Opus 5 f671913c98 Tell the model when a background command nobody watched has finished
`ssh_exec` hands a long command a handle and returns. If the model comes
back with `ssh_wait` it gets the outcome; if it does not, the command runs
to completion, its output sits in a buffer, and ten minutes later the
sweep throws all of it away having been read by no one. A deploy whose
result vanishes because the agent got distracted is the quietest failure
this design could have, and the only thing standing against it was a
sentence in a tool result asking the model to please remember.

The registry announces instead. A watcher per adopted command — again
unstructured, again because a structured one would deadlock on a task that
ignores cancellation — reports the outcome when the command ends. It
reports only when nobody is looking: `wait` counts itself in and out
around its own wait, so a completion landing mid-wait is reported by that
call rather than announced behind its back. Hearing one outcome twice is
worse than hearing it once.

The notice reaches the model on the steering queue's delivery point but
through its own event, because the two are not the same thing. Steering is
a person talking; this is a machine reporting. `noticeDelivered` lands in
the conversation's model-facing `transcript` and nowhere else — the
visible message list never sees it. That matters because the transcript
has exactly three roles, so anything the run says to the model has to be
sent as `user`, and rendering it would attribute to the reader words the
reader never wrote. The user finds out that a command finished from its
card, which is the honest surface for it.

A notice does not keep a run alive, unlike steering. Reports first then
instructions at each boundary, but the loop's decision to stop consults
only the steering queue: a report is not a request, and an agent that has
already said its piece should not start talking again because a command it
forgot about came back. The consequence is stated rather than hidden — a
command that finishes after its conversation has gone quiet has nobody to
report to, `notify` returns false, and the model finds out by asking or
not at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:13:07 +08:00
Kaede AkinoandClaude Opus 5 81c841c239 Actually run the idle sweep
`AgentBackgroundCommands` shipped with a `sweepIdle` and a ten-minute
window, and nothing that ever called it. A handle the model never asked
about again held its detached session — an authenticated connection on
someone's server — until the app quit. The window was a promise with no
clock behind it.

The connection pool's shape, for the same reasons it has that shape: the
timer starts when the first handle is adopted and stops as soon as the
registry empties, because a timer ticking against nothing is a wakeup a
laptop pays for and nobody reads. `sweepInterval: nil` disables it for
tests that drive `sweepIdle` themselves with a synthetic instant, which is
also what keeps those tests from racing a live timer.

The test uses real time — a 20ms interval against a zero-length window —
which is worth the flake risk here because the thing under test *is* the
clock. It fails, rather than hangs, when the timer is not armed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:07:45 +08:00
Kaede AkinoandClaude Opus 5 5100c6fc5d Let a long command outlive the turn that started it
`ssh_exec` blocked its whole turn on whatever it was running — up to
fifteen minutes by the clamp, and by default sixty seconds before it gave
up. Blocking a turn blocks everything behind it: the model cannot be told
anything, and a message the user types while watching cannot be delivered,
because a provider will not accept a turn whose tool calls have no results
yet. A four-minute deploy was four minutes of nobody being able to say
anything.

Pi's loop never required a tool to run to completion — `execute` returns a
promise and when it resolves is the tool's own business. So this is a
change of policy in one tool, not of the loop. `timeout_seconds` stops
being a failure deadline and becomes a reporting one, and its default
drops to fifteen seconds.

That default is only safe because expiry stopped being destructive. It
used to throw, mark the session dead, and lose every byte the command had
printed — while the command itself carried on running on the host, which
is the part that made it a bad trade. Now the deadline hands back what was
captured plus a handle, and `ssh_wait` picks the command up again on the
same terms: finished result, or output so far and still running. Short
deadlines become pure gain — turns end in seconds, nothing is lost, and
anything typed while waiting arrives at the boundary that follows.

Connecting is inside the deadline, not outside it. The first draft raced
only the command, which meant a slow jump host or a stalled handshake hung
the turn exactly as before — the failure this is meant to fix, sitting in
the one path not covered. But a connection that has not finished is
nothing to hand over: no session, no output, and a handle pointing at none
of it is worse than saying plainly that nothing ran. So the two outcomes
are told apart, and `AgentCommandHandover` tells them apart exactly once,
because "the call gave up" and "the command started" can happen at the
same instant. If giving up wins, the command is not started at all when
the connection finally lands — otherwise it would run, print, and finish
with nobody holding a handle to it. The connection is kept either way, so
the retry this invites is the fast one.

The session goes with the command. Its channel is still being pumped, so
it can be neither shared nor swept, and `detachExec` takes it out of the
pool by identity — the pool may have opened a replacement for that host in
the meantime, and evicting *that* would give away a session somebody else
is about to use. The next command for the host opens a fresh one, which is
the same reconnect a timeout already forced, minus the lost output.

There is deliberately no `ssh_kill`. Closing a channel does not stop the
process on the far side, so a tool by that name would promise what it
cannot do. What can honestly happen is releasing the connection, and that
is `sweepIdle`'s job on the pool's own 180-second principle: a handle
nobody asks about again is let go after ten minutes. Abandoning a runaway
`tail -f` is the model simply not waiting on it a second time.

One bug found by a test that hung rather than failed, worth recording
because the shape is easy to write again. `settled` raced two children in
a task group: one awaiting the command, one sleeping the deadline. A task
group awaits *every* child before it returns, and `Task.result` ignores
cancellation — so the child watching a command that outlived the deadline
pinned the group open until the command ended. The function never returned
in precisely the case it exists for, and `ssh_exec` would never once have
backgrounded anything. The watcher is unstructured now, and the wait goes
through `AgentCompletionSignal`, whose cancellation handler releases the
waiter without claiming the command arrived — giving up on a wait is not
the same event as the thing finishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:01:10 +08:00
Kaede AkinoandClaude Opus 5 70665e7375 Show a command's output while it is still running
An `ssh_exec` card was blank for as long as the command took. `apt upgrade`
gave you a spinner for four minutes and then four minutes of text at once,
which is the worst possible ordering for the one thing you were watching
for — the line where it went wrong.

Pi has had the answer since `AgentToolUpdateCallback`: a tool may report
what it has so far, `executePreparedToolCall` turns each report into a
`tool_execution_update`, and the TUI redraws the card from it. Ported
here, with one change of address. Pi passes the callback as a fourth
argument to `execute` because it has nowhere else to put it;
`AgentToolExecutionContext` already threads per-call state to every tool,
so `report` goes there and no tool signature changes.

Where the partial goes is the whole design. `toolProgress` is not durable,
on the same grounds as `messageDelta` — history is written from run
snapshots, so a card that fills in as output arrives must not also stream
database writes. And it never reaches the transcript: the model is owed
one result per call, not a running commentary, and a turn that replayed
three partial views of the same command would pay for all three. The card
gets everything; the model gets what the tool finally returned. The card's
state is also left alone, because a call that has something to show has
not therefore finished.

`SSHExecSession` grows an `onOutput` handler called at the four points the
pump already captures a chunk. It runs on the session's serial queue
between two `libssh2` reads, so the contract is that it returns promptly
and never awaits — anything it blocks on stalls every command on that
host. `AgentCommandOutputStream` is the intended shape on the other side:
a lock, an accumulator, and a callback fired outside the lock.

Two details it exists to get right. Reports carry everything captured so
far rather than the newest chunk, because the card replaces its result
wholesale and would otherwise flicker instead of growing. And they are
throttled to one per 150ms — a build printing a thousand lines a second
would otherwise be a thousand SwiftUI invalidations per second to render
text nobody can read at that speed. Throttling drops the report, never the
output: what was captured is still captured, and the finished result
follows moments later regardless. A chunk boundary can also fall inside a
multi-byte character; that decodes as a replacement character and corrects
itself on the next report, and only the live view ever sees it, because
the finished result is decoded once, whole, by the session.

The test for the throttle is worth a note. The first version collected
reports into an actor and awaited them, and raced its own assertions. The
callback's contract is that it may not await, so a collector that has to
be awaited is not testing the thing — it is locked now, like the real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:16:40 +08:00
Kaede AkinoandClaude Opus 5 aee297868e Ask about the first call first
A turn's approval cards were queued in whichever order the batch happened
to resolve. Tools run concurrently and each awaits `willExecute` and then
the approval gate before it reaches `requestApproval`, so which of two
calls arrives first is a race between two tasks on the global executor —
the same pair could be presented either way round on two runs of the same
prompt.

Nothing was unsafe about it: every card names its own tool and shows its
own arguments, so the answer is never given to the wrong question. It is
simply a needless surprise in the one place the app stops and asks the
user to authorize something, and "1 of 2" reads as an order it was not
keeping.

Planning is sequential — `AgentToolScheduler` preflights the whole batch
before any of it runs, because the batch's concurrency is not known until
every call has been classified — so `invocation.createdAt` already records
the order the model asked in. Inserting by it costs a linear scan of a
queue that is almost always empty and makes the presented order the
model's own.

`denyingTheHeadLeavesTheNextRequestAnswerable` has been passing on that
race all along, which is worth writing down: it asserts that the *second*
tool is the one that runs, so it only passed while the first call happened
to win. It went red the moment an unrelated change perturbed the timing by
one task hop, and that is what surfaced this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:16:07 +08:00
Kaede AkinoandClaude Opus 5 2617cec944 Tell the model what a stopped tool left behind
Pressing Stop mid-batch put this in the transcript the model reads next
time: `The operation couldn't be completed. (Swift.CancellationError error
1.)` — a sentence about Swift, handed to something that needed a sentence
about the host. `AgentToolExecutor` caught every error the same way and
used `localizedDescription`, which for `CancellationError` says nothing at
all.

What it says instead matters more than that it says something. It must not
claim the tool did not run. Cancelling an `ssh_exec` does not stop the
command: the channel is mid-stream, and closing it politely would stall
every later command on that host, so `SSHExecSession` abandons the whole
session and the command carries on running on the far side. A model told
"this never happened" will cheerfully run it again, which is a data-loss
bug the first time the call was `rm`. So a cancelled call now reports the
outcome as unknown and says to check before repeating — the same wording,
for the same reason, that `AgentUnansweredToolCallRepair` gives a call
whose process died before its result was written.

The test that comes with it guards something narrower and easy to lose:
that Stop reaches the tools at all. It does today because the batch is
awaited directly, so cancelling the run task propagates structurally. Any
future change that runs the batch in an unstructured task breaks that
silently — an unstructured `Task` does not inherit cancellation — and
leaves every running tool running with nobody waiting for it. The tool in
the test records whether it observed cancellation, so the failure is an
assertion rather than a five-second hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:15:37 +08:00
Kaede Akino 09f5d027bf Persist agent permission mode 2026-08-26 11:37:07 +08:00
Kaede Akino aa136347da Add interactive SSH authentication to agent tools 2026-08-26 11:31:06 +08:00
Kaede AkinoandClaude Opus 5 99be1d6885 Change a remote file without sending it through the model
Editing a config on a host cost two full copies of it in tokens. `sftp_read`
returns the file inline, and `sftp_write` takes the replacement back as a
`content` string the model has to re-emit in full — so the ordinary "pull it,
change three lines, push it back" job paid for the whole file twice, and
anything over the 128 KB inline cap, or anything that was not UTF-8, was out
of reach entirely. There was also nowhere to keep a draft between turns: a
script the agent was composing lived only in the transcript, which the context
pipeline trims head-and-tail as the run grows.

`AgentScratchWorkspace` is a per-conversation staging directory under
Application Support, inside the sandbox container, deleted with the
conversation that owns it. Application Support rather than Caches because the
system may evict a Caches directory mid-run, and a staging area that can vanish
between two tool calls is worse than none. Paths are relative; `..` is refused
rather than resolved, which is the inverse of `normalizeRemotePath` and the
right inversion — a relative path with `..` in it is never something the agent
needed. Symlinks are refused even when they point back inside, because an alias
within the workspace makes two paths the same file and quietly breaks both the
quota arithmetic and the atomicity `apply(_:to:)` claims. Every resolved path
is then proven to still be under the scope. Writes are bounded per file, per
conversation, and by entry count, and hidden files are counted — a quota you
can evade by naming a file `.big` is not a quota.

It is an actor, and that is load-bearing rather than incidental. Pi serializes
file mutations through an explicit per-path queue; here the actor already
serializes every operation against every other, so a read-modify-write is
atomic for free. That is what lets every local scratch tool stay `.parallel`:
a batch containing one does not have to drag its whole turn serial to be safe.

`scratch_edit` follows Pi's `edit` and is what makes the rest pay off. Every
`old_text` is matched against the file as it was before the call, not against
the result of the preceding edit, so the model never simulates its own changes
to write the second one. Matches must be unique and non-overlapping; anything
ambiguous fails the whole call rather than applying part of it, because a
half-applied edit is the one outcome nobody can reason about. Matching is exact
first, then one line-aligned retry that ignores smart quotes, unicode dashes
and trailing whitespace — without it an edit fails whenever the model retypes a
block with tidied punctuation, which is often. And it returns a unified diff
rather than the new file, which is the entire point: an 8 MB file can now be
changed for the price of the lines that changed.

`scratch_pull` and `scratch_push` move the bytes, and are separate tools rather
than options on `sftp_read` / `sftp_write`. A tool that means two different
things depending on which optional argument is present is harder for a model to
choose correctly and harder to audit, and it would have put two safety levels
inside one descriptor. The SFTP tools are untouched.

`Safety` gains `locallyContained`. The existing two cases state *why* a call may
skip the gate, and "read-only" is the wrong reason for a scratch write — it
changes something, it just cannot change anything outside storage the app owns.
It is auto-allowed in every mode including Ask for approval, because a staging
area you must approve into is not a staging area, and six dialogs for one edit
loop teach the reader to click through the seventh, which is the one that
reaches a host. `isAutoAllowed` is a property of the case rather than an
`== .requiresAuthorization` at the call site, so a fourth case has to make a
decision here instead of defaulting to allowed. Nothing switches exhaustively
over `Safety` and it is not persisted, so there is no migration.

`scratch_push` is the only gated tool here, and it has a problem the SFTP tools
do not: the approval card would show a filename where it used to show the bytes,
leaving the reviewer — human or model — agreeing to something neither had seen.
Its preflight therefore reads the staged file and puts the size, digest and a
redacted twenty-line head into `localReasons`, which both the card and Security
Review's `Local signals:` line already render. It also carries the digest in
`executionMetadata` and rechecks it at execute time: approval suspends the turn,
and what comes back from the workspace afterwards is only the same file if
nothing rewrote it in between.

The schema helpers move to an `AgentToolSchemaBuilding` protocol with defaults
rather than a namespace enum, so a conforming type still calls `descriptor(…)`
and `string(max:)` unqualified and a second tool file costs a conformance
instead of a copy of forty lines.

`UnifiedDiff` is a formatter over `CollectionDifference` — Swift's
`difference(from:)` is already a Myers diff, so no dependency. It trims the
common prefix and suffix before running, because Myers is fast on similar
inputs and quadratic on unrelated ones, and refuses anything past a line limit
in what is left.

Its output was checked against the real tool before any expectation was written
down: for eight cases it is byte-identical to GNU `diff -u`, and `patch -p1`
applies each one and reproduces the target file. That found three bugs reading
the code had not. `firstChangedLine` was nil for every replacement, because the
first changed row of one is the removal and a removal has no line number on the
new side. A trailing-newline-only difference produced an empty diff with
`identical: false` — true and useless. And `LineEnding.detected` reported `.lf`
for every CRLF file, because Swift treats "\r\n" as a single Character and
`firstIndex(of: "\n")` finds nothing in a Windows file; editing one line would
have rewritten every line ending in it. A fourth came from the BOM test:
`String(data:encoding:.utf8)` strips a UTF-8 BOM while decoding, so checking the
decoded String never fired and writing it back dropped those three bytes. It
now reads the raw Data, and the test asserts on bytes for the same reason —
decoding would have reported a pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:56:13 +08:00
Kaede AkinoandClaude Opus 5 61fc841627 Send what the user typed while the agent was working
Typing at a running agent put a bubble on screen and, more often than
not, did nothing else. The loop returned `complete` the moment an
assistant turn came back with no tool calls, and the steering queue was
only drained at the top of the *next* iteration — so anything typed while
the final reply was being written was dropped on the floor, sitting in the
transcript as a turn the model was never shown. Pi does not have this bug
because its inner loop runs `while (hasMoreToolCalls || pendingMessages.length > 0)`:
a queued message keeps the loop alive even when the model asked for
nothing. That is the rule adopted here, and it is the whole fix — the
guard now completes only when the queue is empty and otherwise continues,
which buys the message a turn of its own.

The second defect was quieter and worse. The bubble was appended to both
`messages` and `transcript` at the keystroke, so the conversation ordered
it *before* the assistant text that actually preceded it while the run's
snapshot ordered it after. `transcript` is what `pendingPriorMessages`
replays as the next run's history, so a steered conversation would later
show the model a conversation that never happened. Nothing is written at
the keystroke now. The queued message enters both lists on
`steeringDelivered`, which the runtime emits at the moment it injects it,
and its `createdAt` is restamped there — a message stamped while the
previous turn was still streaming sorts above the reply the user was
reading when they typed it, which is not where the model saw it.

A queue is a promise, so it can be broken and the words have to survive
that. `steer` returns a Bool rather than swallowing the message: `false`
means there is no run left to deliver it and the caller still owns the
text. The gate is `acceptsSteering` rather than `runTask != nil`, because
`complete` awaits its final persist before the run ends and an actor is
reentrant at every await — a message accepted in that window would land
in a queue nobody drains again. It is cleared as the first statement of
every terminating path, which makes the window empty rather than narrow,
and set in `start` rather than inside the run task, because the first
drain happens ahead of the first provider request and something typed
that early should still be delivered. Whatever is undelivered when a run
ends — stopped, failed, or refused in that race — goes back into the
composer with its attachments, joined above whatever has been typed
since. Returning the whole queue at once rather than one entry per
refused hop is also what keeps the words in the order they were written.

What is queued is drawn as a queue, not as a turn. A strip above the
composer lists each pending message with an ✕ that hands it back, and it
empties itself as each row enters the transcript proper. The caption
changes to "held until you answer above" while an approval or a question
card is holding the run, because in that state nothing is working towards
the next step and promising one would be a lie. Rows show what was typed,
not the composed payload, which carries the attachment blocks folded in
for the model. Its height is measured from its rows and bounded, because
a bare `ScrollView` is greedy and would stand at its 88pt ceiling with
one line in it — the same problem, and the same answer, as the draft
editor's height.

Stop and Send no longer stand side by side. Drawn together they read as a
pair of equals and the primary action moved under the pointer the moment
the draft gained a character; they share one slot now, Stop when there is
nothing to send and Send otherwise. That leaves Stop without a target
while typing, so `⌘.` reaches it from the draft field as well as from the
button. And each region above the composer now rules itself off from what
is above it instead of below it: ruling below meant any visible card was
followed by two adjacent hairlines, its own and the composer's.

Two things from Pi are deliberately left out. There is no follow-up queue
— the second queue, delivered only once the agent would otherwise stop —
because Enter while running means steering, as it does in Pi's own TUI.
And there is no `QueueMode`: Pi drains one message per boundary by
default, where draining the whole queue costs one round trip instead of
several for the common case of two hurried messages. The delivered bubble
is also left unmarked, since once injected it is an ordinary user turn,
and `ChatMessage` is rebuilt from stored transcript rows on relaunch, so
any flag would quietly disappear on restart.

The regression test was checked against the unfixed loop rather than
merely observed to pass: reverting the guard makes it fail with one
provider request instead of two, and with the queued message absent from
both the second request and the saved snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:24:32 +08:00
Kaede AkinoandClaude Opus 5 da3082aff8 Ask a group of decisions at once, not one at a time
`request_user_input` took one question. A turn that needed three raised them
one after another, each hidden until the one before it was answered — and a
second question you cannot see is a second question that cannot inform the
first. It now takes up to three, drawn as one form with one Continue.

The earlier message on `2ec0e12` gave the wrong reason for not doing this.
It said several questions per call "would need a queue and a
multi-question card", as though the runtime's single `pendingUserInput`
forbade it. It never did: a call carrying three questions is still one
blocking request and one continuation, which is exactly what a queue is
not. That was a preference dressed as a constraint. The real cost is the
two things below — per-question drafts, and a form that can outgrow its
strip — and neither was a reason to refuse.

The queue that *did* arrive in `22258e3` does not cover it either, for a
reason worth writing down. `request_user_input` is `.sequential`, and a
batch runs in parallel only when every call in it is, so two questions in
one turn execute one after the other and `pendingUserInputs` never holds
more than one. The queue and its `1 of N` are live for approvals and
purely defensive for questions. Sequential is right for this tool —
racing a question against other calls means output arriving under
something still being read — so batching inside one call is the only way
several questions reach a person together. The queue stays as it is.

A question may be left unanswered, and the tool reports that as a null
rather than an error. That needs saying in the tool's own description or
a model reads the null as a failure and asks the same thing again, which
puts the user in a loop they answered their way out of once already. So
the description now says plainly: a null is the user declining to decide
that one — choose, say which assumption you used, and do not ask again.
Continue needs one answer, not all of them; it needs at least one because
answering nothing is Cancel, and reporting that as success would hand the
model a result whose only content is that it was not refused.

Drafts become per question. Sharing one text field and one selection set
across a form meant typing into the third question cleared the first, and
the mutual exclusion between an option and a typed answer — which is
per-question by definition — reached across all of them. `AgentUserQuestion`
carries the id the model chose, so answers travel under the model's own
names rather than an index we assign, and every question is reported back
whether answered or not: an absent key would read as a bug where a null
reads as a decision.

Two things fell out of rendering it. A bare `ScrollView` capped at the
form's maximum height is greedy, so a one-question card was handed 320pt
of box for 120pt of question; `ViewThatFits` prefers the natural stack and
falls back to scrolling only when the questions genuinely overflow. And
`reported` is not private, because that shape is the contract with the
model and a test should be able to hold it still — including that a
secret, which `request_user_input` never asks for, is reported as skipped
rather than echoed if one ever arrives there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:52:21 +08:00