Commit Graph
95 Commits
Author SHA1 Message Date
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
Kaede AkinoandClaude Opus 5 bfbebc7b80 Answer tool calls that a killed process left hanging
Force quitting during a tool batch produced a conversation that could not
be continued at all, only deleted: `failed to stream request: error: No
tool output found for function call call_…`. The assistant turn is
persisted as soon as it arrives, its results only when the run next saves
a snapshot, so a process that dies in between restores a turn whose calls
nothing answers — and every provider rejects that outright rather than
degrading. It is the exact mirror of the orphaned result this codebase
already repaired, and the same class of fatal.

Recorded results come back first. `toolFinished` is durable and lands as
each tool finishes, so in the common case the output is sitting in the
database while the transcript claims to know nothing about it;
`loadConversations` now materializes any result whose transcript row was
never written. In a batch of three killed during the third, the first two
come back with the output they actually produced.

`AgentUnansweredToolCallRepair` answers whatever storage cannot, at the
model boundary and at the snapshot seed — the same two places the orphan
repair runs, and for the same reason: repairing at the seed writes the
fix forward instead of re-deriving it on every future turn. What it says
matters as much as that it says something. It does not claim the tool did
not run. The process died after the command was dispatched, so whether
the remote side executed it is genuinely unknown, and a model told "this
never ran" will cheerfully run it again — which is a data-loss bug the
first time the call was `rm`. It says the outcome is unknown and to check
before repeating.

A card caught mid-flight also came back spinning. Every unfinished run is
marked interrupted at launch, so `running` is not a state a restored card
can legitimately be in, and nothing was ever going to stop that spinner.

Worth noting against the previous commit: batching the snapshot save to
once per turn rather than once per tool widened this window, and the
recovery above is what closes it — results are still recorded one at a
time, so nothing a tool produced is lost, only deferred.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:36:53 +08:00
Kaede AkinoandClaude Opus 5 22258e3f2f Run a turn's tools at once, not one after another
`AgentRuntime` was one actor doing four jobs: holding the run's state,
driving the loop, executing tools, and publishing events. Adding an
audit, a rewrite, or a new tool source meant editing it. The loop now
keeps only the state and the control flow, and each phase is a value it
drives — `AgentContextPipeline` at the model boundary, `AgentTurnDriver`
for one assistant turn, `AgentToolScheduler` for the batch,
`AgentToolExecutor` for a single call, `AgentToolRegistry` for where
tools come from. Each is testable without a run around it. Four seams are
protocols with real conformers: `AgentToolProviding`, `AgentLoopHook`,
`AgentContextTransforming`, `AgentModelStreaming`. Authorization is
deliberately not among them — `AgentApprovalHandling` stays a structural
stage of the executor, because a permission gate you can remove by
leaving an element out of an array is not a gate.

A turn's calls run concurrently only when every one of them is marked
parallel; a single sequential call takes the whole turn serial rather
than being fenced into its own segment. Segmenting would buy back only
the cross-host case and cost semantics nobody can hold in their head
while reading an incident transcript. Concurrency is declared per tool
and may be overridden per call by preflight, which is how `ssh_exec`
decides: parallel exactly when `CommandRiskClassifier` proves the command
safe, the same locally-proven evidence that already decides its safety.
MCP tools are parallel by default, and a server's destructive hint or Ask
First makes them sequential — the hints can only tighten concurrency,
never grant it, so remote annotations remain risk hints rather than proof
of safety. The ceiling is worth stating plainly: `SSHExecSession` and
`SFTPClient` are actors, so calls aimed at one host still queue inside
their session. The win is several hosts at once, and host work
overlapping `fetch`, `web_search`, and MCP.

Whatever order they finish in, results reach the transcript in the order
the model asked. `toolFinished` still follows completion, because that is
when a card should stop spinning, so the two orders now disagree by
design — and `AgentToolResultOrdering` repairs it at the model boundary,
where both the run's snapshot and the conversation's own copy pass
through one implementation.

Three defects that serial execution was hiding, in rising order of harm.
A denied call was relabelled `Failed` the instant it was refused, because
the denial is an error to the model and the card followed the result;
`Denied` therefore never reached the screen. A run that gave up halfway —
cancelled, out of tool budget, or handed a response truncated
mid-arguments — left an assistant turn whose tool calls nothing answered,
and every provider rejects that outright on the *next* request, so an
interrupted run became a permanently unusable conversation. Every call
now gets a card and a result on every path, refusals included. And
`pendingApproval` was a single slot: two calls needing approval in one
turn overwrote each other, the first continuation was never resumed, and
the run stopped with no error and nothing on screen to answer. Approvals
and agent questions are queues now, presented one at a time with a
`1 of N` count, and run state is derived from what is outstanding rather
than asserted by whichever call moved last.

Events travel one ordered path. The runtime and the approval broker used
to publish independently, each wrapping its durable write in its own
unstructured `Task`, so a review's events had no defined order against
the tool events they belong between. `AgentEventChannel` fans out to the
UI and to a single serial recorder. `messageStarted` now carries its
message rather than just an id, so the visible turn is stamped with the
run's clock instead of whenever the UI got around to the event — a card
proposed later could otherwise carry an earlier timestamp than the turn
that asked for it, and sort above it.

`AgentContextPipeline` is also where trimming finally has somewhere to
live. `ssh_exec` returns up to 256 KB and `sftp_read` up to 128 KB, and
each is replayed on every later turn; two log reads were enough to crowd
out the conversation they were meant to explain. At most one turn's tool
output is now replayed in full, and older results keep their head and
their tail, because a truncated command output missing its ending has
lost the error message. It shapes only the request: snapshots and stored
history keep everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:28:07 +08:00
Kaede AkinoandClaude Opus 5 2ec0e123a3 Let the user answer something the model did not offer
`request_user_input` took a prompt and an array of bare strings and drew a
`Picker`. When none of the choices fit — which is exactly when asking was
worth doing — Cancel was the only move available, and a cancelled run
tells the agent nothing about why. So a free-form field now sits in the
list alongside the options rather than under them as an afterthought, on
by default, and `allow_custom` has to be set false to take it away.

Options become objects. A bare string left the model nowhere to put the
tradeoff, so it either crammed the reasoning into the label or dropped
it, and the reader chose between four words with no way to tell them
apart. Each option now carries an id, a label, a sentence on what it
costs, and a `recommended` flag — a flag rather than a `(Recommended)`
suffix, so the convention lives in the schema instead of in prose the
model has to remember, and the label keeps its whole budget. The ceiling
drops from twenty choices to four; twenty was never a decision anyone was
being asked to make.

Nothing is preselected, the recommendation included. A recommendation is
a label, not a default, and a preselected answer is one the user can
submit without reading — so Continue stays disabled until they actually
answer. Where one answer is wanted the two routes are exclusive and each
clears the other, because a half-typed "something else" left sitting
under a chosen radio is a second answer contradicting the first. Where
several are wanted they are additive, since "these two, plus this other
thing" is a real reply.

Selected ids come back in the order the request listed them rather than
the order they were clicked: the model wrote the list and reads the
answer against it. Alongside them the tool returns `custom_text` and a
canonical `answer`, so a model that reads only one field still reads the
right one. Malformed and duplicated options are rejected rather than
dropped — a menu quietly missing the option the user wanted is a worse
failure than an error the model can see and retry.

`request_user_secret` shares the interaction handler, so it now refuses
`.choice` explicitly. A secret has to come back through `SecureField` and
the broker; anything else means it travelled the ordinary-answer path,
where it would have been echoed on screen and left in plain view state.

None of this is persisted, so there is nothing to migrate. Two things
were deliberately left out. Several questions per call does not fit a
tool that blocks mid-run on a single `pendingUserInput` — it would need a
queue and a multi-question card, and one question per call is right for
an agent that is already holding the user up. Per-option previews need a
two-pane layout, and this card is a narrow strip above the composer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 08:37:09 +08:00
Kaede AkinoandClaude Opus 5 cdea8d5448 Draw the blocks Markdown was already parsed into
Replies rendered their newlines away and their tables into mush. The
parser was never the problem. `AttributedString(markdown:)` is cmark-gfm
underneath and records everything — probed against a three-column table,
it returns the four characters `ab12` with every cell carrying
`[tableCell 0, tableHeaderRow, table [.left, .center, .right]]`. Column
count, alignments, header row and cell indices are all there. SwiftUI's
`Text` reads inline attributes only and ignores `presentationIntent`
entirely, so the structure was parsed and then discarded at draw time.
`a\n\nb` has the same shape: two paragraph intents, and a character
buffer that literally reads `ab`, because emitting a newline is the
renderer's job and there was no renderer.

So no new dependency. `apple/swift-markdown` would have bought a ready
tree and task-list checkboxes, at the price of a second Markdown parser
and a C target in a build that already carries three vendored ones — and
the actual labour, the SwiftUI block views, is identical either way.
What it costs instead is one pass that rebuilds nesting from the flat run
list: walk the runs, keep a stack of open containers, compare the
`identity` ints Foundation already hands out. `components` is ordered
innermost-first, which the grouper relies on and a test now pins, because
that is an observed fact about the framework rather than a contract.

Soft breaks are the one place we leave the spec. CommonMark renders a
lone newline as a space and Foundation obeys, emitting an untagged `" "`
run that is indistinguishable from a typed space — so the choice cannot
be made after parsing. It is made on the source, by appending a
backslash hard break the way GitHub's comment renderer does, because a
model that wrote a newline meant one. Every guard in `MarkdownSoftBreaks`
marks somewhere that edit would corrupt rather than break: fenced and
indented code, table rows, headings, thematic breaks, setext underlines,
list markers. One of them is not cosmetic. `composed(_:)` folds
attachments into the message as `<output label="…">…</output>` and
promises the bubble shows exactly what was uploaded; Foundation returns
that untouched as one HTML block, down to the `*` that emphasis would
otherwise eat, so editing a byte inside one would break that promise.

Two hazards found while wiring the shell fence card. `SSHSessionModel
.insert` writes raw bytes with no bracketed-paste wrapper, so an embedded
newline reaches the PTY as Return — "Insert" on a three-line script would
execute the first two while presenting itself as a preview. Multi-line
fences therefore get the risk badge and a copy button and nothing that
touches a session. And `selectedTab` is nil whenever the pinned Assistant
tab is showing, which is exactly where the full-width transcript lives:
`CommandCardView`'s Insert and Run have been dead there all along. The
new card disables them instead of looking live and doing nothing.

Rendering model output is a trust surface, and tool results are fed back
into it. Only `http`, `https` and `mailto` become clickable; a refused
scheme keeps its label as prose and names its destination on hover, which
is what stops `[click here](file:///…)` from reading as ordinary text.
Raw HTML is never interpreted. Images needed nothing — Foundation drops
the URL and keeps the alt text, closing the channel where a model-chosen
image address discloses the reader's IP and reading time the moment a
message renders.

The transcript is rebuilt around it. User turns are trailing bubbles that
size to their contents, assistant replies have no container at all, and
the rules between turns are gone because alignment and fill already say
who is speaking. Tool calls collapse to one line that opens itself while
the call is in flight or after it failed. Attachments fold to a labelled
row, unaltered and one click from open, rather than printing two hundred
lines of log into a right-aligned bubble. `ConversationView` and its
cards move out of `ContextPanelView`, where they had nothing to do with
the context panel.

Two fixes fall out of measuring the result. `TextEditor` has no
content-based ideal height — given `minHeight: 46, maxHeight: 120` it
takes 120, so an empty composer stood 185pt tall with the placeholder
marooned at the top; its height now comes from a hidden twin of the draft
and starts at 40. And the table rendered blank: a horizontal `ScrollView`
proposes unbounded width, the header `Divider` is greedy and took it, and
every centred or trailing column was aligned to an edge no one can reach.
Cells wrap instead, which also reads better at 260pt than a scrolling
region nested inside a scrolling one.

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