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>
`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>
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>
`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>
`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>
`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>