Commit Graph
11717 Commits
Author SHA1 Message Date
Brennan BensonandClaude 5c45337a6f fix(terminal): make Codex restart replace the pane's process instead of reattaching it (#22737)
* fix(terminal): make Codex restart replace the pane's process instead of reattaching it

A spawn for a pane that still has a live process is treated as a reattach.
Both Codex restart paths raced that: the open-pane restart killed the old
PTY without waiting and then spawned, and the unmounted-tab restart spawned
before killing. Either way the "fresh" spawn could re-adopt the old Codex
(dialog comes back) or hit the half-killed session and leave a plain shell.

pty:spawn now accepts replacesPtyId. Main stops that PTY and waits for the
exit before the spawn resolves the pane owner, so the existing dead-owner
path launches fresh and records the current Codex home. Both restart paths
send it and no longer kill the old PTY themselves.

Fixes #18174

* fix(terminal): send the replaced PTY once and keep the hidden-pane replacement

Two follow-ups to the restart handoff:

- The replaced PTY id rode the transport options, which outlive the first
  spawn. A later fresh spawn from the same pane (for example a hibernation
  wake) re-sent it, so main stopped that id again; SSH relay ids restart at
  pty-1, so it could name another pane's PTY. It is now a connection input
  consumed by the first fresh spawn only.
- The hidden-tab restart still treated a changed binding after the spawn as a
  reason to stand down and reap the replacement. Main has already stopped the
  old PTY by then, so its exit can clear the tab binding (a background-launch
  exit observer does) or a pane can mount, and standing down left the pane
  with no process. It now keeps the replacement unless the tab or leaf was
  actually taken over.

* fix(terminal): hold the pane while a restart stops its old process

A restart spawn stopped the pane's old process before reserving the pane,
so a hidden tab revealed during the stop could reattach the dying process
(and the restart could then join that reattach and return the old id).
The replacing spawn now reserves the pane before the stop, so any spawn
for the pane in that window joins the replacement; a failed stop still
settles the reservation.

The hidden-tab restart also tombstones the old PTY's buffered exit before
spawning, so a reveal mid-restart reconnects by pane identity instead of
replaying that exit into the pane.

* fix(terminal): label a restart's replaced-PTY exit and keep its stop owed until sent

Main now marks the PTY it stops for a replacing spawn and stamps
`replacedByRestart` on that PTY's exit (provider-observed, synthetic, and
SSH-unregistered stops all reach the renderer through the same finalize
step). A failed stop clears the mark with nothing sent; an undelivered mark
expires. The renderer classifies the labeled exit before any consumer runs:
parked-tab watchers end their subscription instead of collapsing the leaf or
closing the tab, the pre-attach buffer discards instead of queuing a death
for a later mount, and a mounted pane treats it as an intentional restart.

With that, the hidden-tab restart no longer tears down its parked watchers
and buffered output before spawning; it releases them only after the swap,
so a stop that fails leaves the still-running Codex fully observed.

The visible restart's pane session now holds the replaced PTY until a spawn
request actually carries it (the IPC transport claims it as it sends). If the
pane is closed or parked first, disposal stops that PTY with an ordinary
kill instead of orphaning it.

* test(terminal): type restart test fakes and merge a duplicate import

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 00:10:48 -07:00
Brennan BensonandClaude fec8fe4822 fix(native-chat): offer a resume for every chat that was working, and say what it was doing (#22560)
* fix(native-chat): offer a resume for every chat that was working, and say what it was doing

* fix(native-chat): keep a child-work resume offer after the chat is reopened

The offer's cut-off work was read from the items' current state, so once the
chat was opened (reattaching the provider, which rewrites the rows it lost),
a chat offered for its stopped subagents or background tasks dropped out of
the dialog and its retry. Judge the revision rows written after the marker's
cursor instead: they only accumulate, so the reading is the same before and
after reattach, and the per-run pre-reattach copy is no longer needed.

Also pass the lease fence into the shared "shows work" check, as the status
feed does, so a send stranded under an older fence cannot hold an unheld
session's provider alive while the sidebar shows it idle.

* refactor(native-chat): offer is the working check taken right before each child stops

Every review loop found the same bug class in the "is this offer still owed?" re-derivation that
ran when a child was stopped and again after restart. It re-read a journal the provider had already
rewritten on reattach (a notice turn of its own, restated subagent rows) and kept misreading it:
a send made after an earlier completed turn was dropped at shutdown, and Claude's notice turn
refused the resume after restart.

- Teardown now snapshots each session with the sidebar's working check in a new eviction step
  right before its provider child is stopped (after draining events Orca already accepted), and
  keeps it once the stop is proven. The capture-first/confirm-on-stop re-judgment is gone.
- After restart an offer is withdrawn only by a newer user message, dismissal or expiry, beside
  the structural checks (record, support, lease, no fork). Listing, retryable and the pre-send
  check no longer re-derive work state.
- The pre-send barrier drains again while a provider keeps streaming instead of refusing; both
  providers queue a message sent mid-turn.
- Row activity stays a display-only read of the rows after the marker's journal cursor.

* test(native-chat): pin the re-drained admission barrier and the proven-stop gate on offers

* test(native-chat): cover a send-shaped offer across a closed provider notice turn

* refactor(native-chat): drop the pre-stop drain nothing depended on

* docs(native-chat): align marker and retry comments with the simplified offer rule

* fix(native-chat): an unfinished admission drain no longer refuses the restart continuation

The drain before the continuation's pre-send check refused the send when provider events were
still arriving at its 2 s bound or the barrier failed. That refusal journaled the continuation as
rejected, so its own message then read as the user moving on and the offer could never be
retried. The drain is now best effort and the pre-send check judges what the journal holds.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin a stalled or failed admission barrier dispatching once

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): name a cut-off reply from the settlement's row, not the turn as restated

A reattached provider may restate the offer's turn, so the row's mid-reply label read off the
turn's current state could vanish and count the cut-off reply's own tool calls as background
commands. Also corrects the ineligible-offer comment to match the user-moved-on rule.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): the offer is a stop-time snapshot on the marker

The marker records the main agent's own state, the pending prompts and the
live child roster at the moment before its child stops; the dialog row,
status bar and candidate read only that snapshot. Deletes the journal
cut-off reader, journalCursor, the offer TTL and the pre-send admission
drain. Superseded offers and chat closes now delete their records.

* test(native-chat): every offer ending deletes the durable record

* fix(native-chat): preserve restart offers on unreadable journals

* fix(native-chat): release idle sessions with pending sends

* fix(native-chat): keep a message held while the CLI starts from being released

Idle release had been switched to the sidebar's working check minus pending sends, which dropped
the rule that a send held while the provider CLI is still starting keeps the session. An unheld
chat left during startup was then evicted and its message refused. Restore the release rule this
branch never needed to change: an open turn, or a pending send while the child is starting.
Also keeps the host file within its line budget.

* fix(native-chat): delete a restart offer whose conversation forked

A forked conversation can never become the marked one again, but its offer was only skipped, so
with no expiry it sat unseen in the recovery file forever. Report it for deletion on the same path
as a newer user message. Also corrects comments that still called listing read-only.

* fix(i18n): keep Agent untranslated in the Japanese subagent activity rows

The catalog keeps Agent in English for Japanese, and the localization
gate rejects the translated form.

* fix(native-chat): don't call a mid-reply command monitoring in the resume dialog

The live task roster also lists the foreground command a reply is
running, so a chat stopped mid-command read "Was mid-reply · Monitoring:
<command>". Speak monitoring only for an idle lead, as the sidebar does;
the row's tooltip still names every task.

* chore(native-chat): state the mid-reply label rule exactly

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 23:19:21 -07:00
Brennan Benson e0144a9eb6 fix(native-chat): show the Codex and Claude model picker the moment a chat opens (#22756)
* fix(native-chat): show the Codex and Claude model picker the moment a chat opens

A new structured chat showed no model picker until its session had been
created, spawned, initialized and had answered a model listing — and the
picker then listed the models a second time. Codex's listing often goes to
the network, so the picker took 0.6-2 s to appear.

- Keep a host-owned model catalog per agent and account home, persisted on
  success only and refreshed in the background once it ages out. A new
  read-only agentSession.modelCatalog RPC answers from it without a live
  session; sessions reuse it instead of listing again.
- Render the picker while the launch is still provisional, showing the saved
  default. A pick made before the session exists is held and applied once it
  publishes; only the host's acceptance saves it as the default.
- Mark the model and effort set in the user's Codex config as the listing's
  default (config/read), so the first frame names what the chat will run.
- Resolve the account a record-less read would use without running launch
  preparation, which writes and syncs account state.

* fix(codex): disable plugins in the model catalog probe app-server

* fix(native-chat): read the host model catalog only for panes on this machine

* fix(native-chat): name a pre-report model only for a chat this view launched

* test(native-chat): pin the launch latch across publish

* test(native-chat): pin a held pick reaching the host before the first send

* fix(claude): pin the catalog probe's config dir by the session spawn's rule

* fix(native-chat): read the host model catalog only for a visible chat

* fix(native-chat): rewrite the model catalog file only when a listing changes

* test(native-chat): type-check the first-send order fixture

* refactor(native-chat): keep the structured options hook under the line cap

* fix(native-chat): send the first turn only after every pick held during launch settles

* fix(claude): name no default effort from the catalog probe listing

* fix(native-chat): name no listed default model for a chat resumed from history

* refactor(native-chat): let the launch own picks made before it publishes

A pick made while a chat launches had no fence to go to, so the pane held it
and flushed it after publish; every other sender (the outbox, the launch
prompt) then needed its own gate to wait for that flush. The launch now keeps
those picks in its own state, applies them against the create receipt's fence
before it counts as published, and every sender follows publish by
construction. The pane flush, the outbox gate and the module-wide held-pick
registry are gone.

The launch also snapshots the saved selection its create seeds when the intent
is built, so a pick in another chat no longer relabels one still launching, and
a pick the host refuses is reported the way a refused mid-session pick is.

* fix(native-chat): name no default model a workspace's own config can replace

The catalog's default is the account's, read without a working directory, but
a chat runs in its worktree, where a project config (Codex's .codex/config.toml
between the project root and the worktree, or a Claude .claude settings file
that sets a model) picks the model instead. The picker named the account
default there while the chat ran the project's model.

A new chat's catalog read now names its worktree. The host checks that
workspace for such config (existence only for Codex, the model key for
Claude) and, when any is present or the workspace is not a local directory,
serves the listing with no default, so the picker names nothing until the chat
reports its model.

* fix(native-chat): name the listed default model before the report only for Codex

* fix(claude): let an option pick made while Claude starts wait for it instead of being refused

* fix(codex): name no listed default when the configured model is not in the listing

* fix(native-chat): show the picker as unavailable until a published chat attaches

* fix(codex): keep the catalog probe's listing when config/read stalls

* fix(native-chat): write the pending model catalog save before quit

* chore: drop an unrelated lockfile rewrite

* fix(native-chat): rename the catalog store's listing parameter off the global fetch name

* chore: drop an unrelated lockfile rewrite

* fix(native-chat): name the model Claude will run before its first turn

* fix(native-chat): keep Claude's pre-turn applied effort out of the saved session options
2026-09-24 23:04:07 -07:00
Brennan Benson f9356d491a fix(release): run the tag's own skill freshness inventory tests in the release gate (#22775)
The skill-sharing release gate checks out the release tag, then restores
main's copy of skill-freshness-inventory.test.ts. That file holds behaviour
tests, so a behaviour test added on main runs against a tag that predates the
behaviour: #22606 added one and failed the v1.4.211 gate on macOS and Linux.
Keep restoring skill-provider-runtime-roots.test.ts from the workflow ref.
2026-09-24 22:47:59 -07:00
Brennan Benson 8f7cbad07b feat(mobile): name the machine after pairing (#22104)
* feat(mobile): confirm host identity after pairing

* refactor(mobile): unify host descriptor state

* chore(i18n): translate the last-known host descriptor label

The remote-host row's "Last known" label shipped in English only. Every other locale now carries it, worded as each catalog already words "last known".

* fix(mobile): make the pairing naming step safe to abandon and show the machine live

Pairing:
- An unreadable status.get reply no longer strands the pairing race: the
  descriptor read ran inside the race's success handler and threw, so the
  candidate was never counted and a direct-only pairing sat on
  "Connecting..." until the timeout. The race now reads the status through
  a reader that returns null instead of throwing.
- A pending pairing is now a small state machine: a save in flight owns the
  outcome (Cancel, back, or unmount no longer clear the journal under it),
  a failed save stays pending and the naming screen shows the error with
  Save still available, and Cancel never rejects.
- When the desktop refuses relay provisioning, the journal is cleared before
  the naming step instead of at save, so an app kill on that screen no longer
  blocks every later scan with "recovery pending".
- A pairing that resolves after the screen went away is cancelled rather
  than left with its journal.
- The screens keep their root ref callbacks stable (the latest pending
  pairing is read from a ref) instead of re-creating them per pairing.
- The label field's placeholder shows the name that an empty label saves.
- "Is this an existing host" is derived from the id identity resolution
  hands back, so host-store and its tests go back to main's shape.

Machine descriptor:
- Mobile keeps the host-reported machine name and OS in memory only, filled
  by the status reads that already happen, and labels it "Last known" from the
  row's own connection state. This drops the per-host AsyncStorage copy, its
  web sibling and web-overrides entry, and the removal cleanup. It also fixes
  a latch: freshness used to stay true for the whole process once a host
  had answered.
- Desktop reads the descriptor through lastVerifiedRuntimeStatus and marks
  it "Last known" using the same reachability verdict as the row's dot.
- Drops the unused hostname/previousPlatform resolver inputs and moves the
  "OS · machine" formatting into the shared resolver.

Also restores main's page-only Reconnect gate in the host header (#22326),
undoes the no-op toStoredHostProfile reformat, and re-measures the web
session route at 4222 modules (one fewer: the dropped web persistence file).

* test(mobile): re-record RPC goldens for the deferred pairing save

Repins baseline to the pairing fix commit and re-records every golden.
Against main, 781 goldens move only in the header (baseline everywhere,
adapterSha256 for the pairing adapter family). Six pairing goldens move in
the body, and only in effect order: the pairing now resolves (closing its
candidate sockets) before the naming step saves the host, and a refused
relay provision clears its journal before the save rather than after. The
set of effects and every outcome match main.

This also removes the unhandled-rejection effects and the failed
result-absent/result-null cells that the previous recording captured from
the pairing race's throwing status read.

* fix(mobile): keep the machine name out of the host header title while the label loads

The host screen starts with an empty saved label and loads it asynchronously. With a descriptor
already in memory, the resolver fell back to the machine name as the title for that window, so
opening "Windows-Low Spec" briefly titled the header with the Mac's name. Read the descriptor
only once the label is known.

* test(mobile): build the pending-pairing status through its schema so the test typechecks

The mobile tests typecheck ratchet rejected a partial status literal: the reply type keeps every
optional field as a required key. Parsing the literal through the status schema yields that shape
without a type assertion.

* test(mobile-web): re-pin the session route closure after merging main

#22301 added two src/shared modules this route reaches; its own CI never ran this suite, so the
merged branch read 4224 against the 4222 pin. Measured on the merge.

* feat(mobile): name each host by what its desktop reports

Pairing saves the host immediately again and names it after the machine name the
desktop publishes; every connection refreshes that name and OS, so a rename on the
desktop reaches the phone. A name typed on the phone's Edit screen is kept as a
phone-local override that wins; clearing it returns to the desktop's name.

- Stored host profiles gain optional personalName, lastKnownMachineName and
  lastKnownHostPlatform; `name` stays the resolved value older builds read.
  Legacy records classify a generated "Host N" as desktop-named and any other
  name as a phone override.
- The connection layer runs one retrying status probe per connected host and
  records the descriptor; the capability probe becomes a projection of it.
- Rows and the header always show the OS, add the machine name under a phone
  override that hides it, and mark it "Last known" while the host is offline.
- Removes the deferred-save naming screen and the one-shot home status fetch.
- Name rules move to host-name-identity.ts and the host-list mutation queue to
  host-list-mutation-queue.ts; an unchanged mutation no longer rewrites storage.

* test(mobile): restore the RPC recordings to main's

Pairing saves the host back to back again and the recording adapter is main's, so
every recording matches main byte for byte; the earlier deferred-save re-record and
its baseline repin no longer apply.

* fix(mobile): keep stored host name identity across snapshot saves and on the web page

A connection re-saves its host profile snapshot on relay credential rotation or relay
re-resolution. The re-pair merge let that snapshot's name identity win, so a phone rename
cleared or changed since connect came back, and a newer desktop machine name rolled back.
The stored record now keeps the name identity on every save; the save supplies the rest.

The web page receives only the app's resolved host name, with no identity fields, so the
docked host header treated a phone rename as "no override" and titled the host with the
live machine name. The display hook now reads such a source the way storage reads a legacy
record: a non-generated name is the user's label.

* fix(mobile): hand the web page the host's stored name identity

The page received only the app's resolved host name, so it had to guess whether that
name was the phone's override or the desktop's name. It guessed "override" for any
non-generated name, which froze a desktop-adopted name as the title after the desktop
was renamed, and left an offline page header without the last-known OS and machine name.

The shell now puts the stored personalName and last-known descriptor on the init host as
optional fields. The page reads them exactly as the app does; a page handed its host by an
older shell still falls back to classifying the name, and an older page ignores the fields.

* fix(mobile): drop an unreadable host name field, not the whole host

The stored host record and the page's init host checked the platform against a closed list
and required non-empty names. A value this build does not know, such as a platform added in a
later build, failed the whole record: the host list dropped the paired host and the next write
persisted the list without it, and the page refused its init message. Those three optional
fields are now salvaged, so an unreadable value drops only that field.

Also states the one exception to the stored name rule (an OS reported without a machine name
keeps the adopted name), and brings four mobile test files in line with the branch: the edit
screen now saves `personalName`, and host opens now start a descriptor status probe.

* refactor(mobile): name the shared host name fields for their role

* fix(mobile): drop the "Last known" prefix from the host machine line

The OS and machine name line under a host's name reads the same whether or not the
host is connected; the connection status already says when it is offline, and a
prefix that users could read as applying to the name added nothing. Removes the
resolver's liveness input and the desktop row's translation key.
2026-09-24 22:35:22 -07:00
Jinwoo Hong 8fa5883217 fix(terminal): stop dropping visible alt-screen output tagged as hidden-resize repaint (#22587)
* fix(terminal): stop dropping visible alt-screen output tagged as hidden-resize repaint

After a resize that main received while a PTY was hidden, main tagged the
next chunk it accepted as background. The hidden delivery gate drops chunks
before they reach that tagging, so the tag outlived the hidden period and
landed on the first chunk after reveal. On the alternate screen the renderer
dropped that chunk without a model restore and "repaired" it with a
cols-1/cols resize pulse. OpenTUI debounces SIGWINCH and ignores an
unchanged size, so OpenCode's full post-reveal repaint was lost for good.

Remove the hidden-resize tag lifecycle in main and the renderer's
background alt-screen skip and pulse. Reveal already restores from the
host model and queues live chunks until the restore completes, so later
chunks are in order with the model and are written like any other chunk.

The snapshot-restore path that omits a too-wide alt frame no longer pulses
either: when the fit lands back on the capture grid (no real SIGWINCH), it
requests a fresh model restore instead of relying on the app to repaint.

* fix(terminal): restore a dropped reattach alt frame from the model when the fit lands on its capture grid

The reattach replay omits a too-wide alt frame and relied on the grid push's
resize plus SIGWINCH to make the app repaint it. When the fit lands back on the
capture grid that is a same-size signal, which OpenTUI ignores, so the alt
screen stayed blank. Record the omitted frame's capture width and request a
model restore after the grid push in that case. The reattach fit moves to
reattach-grid-fit.ts to keep apply-reattach-payload.ts within its line budget.

* test(terminal): drop type assertions from the reattach alt-frame test

* test(terminal): dispose the reattach alt-frame test's pane connection
2026-09-25 01:18:17 -04:00
Jinwoo Hong 2c7609bf6a fix(terminal): serialize only the visible width after a column shrink (#22586)
* fix(terminal): serialize only the visible width after a column shrink

xterm does not reflow the alternate buffer (or a normal buffer under
pre-21376 ConPTY), so after a shrink each line keeps its old length.
SerializeAddon walked every non-final row to line.length, so any snapshot
taken after a shrink carried stale right-hand cells that wrapped into extra
rows on replay; restores repainted that garbage and a differential TUI such
as OpenCode never cleared it.

Clamp the row walk and the wrap-boundary lookups to the terminal's columns
in Orca's addon-serialize source patch, and regenerate the bundles, maps and
lockfile hash per docs/reference/xterm-patch-regeneration.md.

* test(terminal): read shrink-snapshot fixtures through public APIs

Drops the private-terminal casts the casting gate flags; the normal-buffer case
now drives a plain pre-21376 ConPTY terminal and its SerializeAddon directly.

* fix(terminal): blank a wide glyph clipped by a column shrink when serializing

After a non-reflowing shrink a width-2 glyph can have its lead half in the last
column and its trailing half past the grid. Serializing the lead half makes the
replay wrap it to the next row and shift every row below, so serialize that
cell as a blank and keep the row exactly the grid's width. A glyph ending
exactly at the edge is unchanged.

* test(mobile): move the session closure pin past the main agent status modules

#22452 added src/shared/main-agent-status.ts and src/shared/agent-turn-outcome.ts,
which agent-status-types.ts imports, so the session route's closure grew by two
local modules (4218 -> 4220). That change was src/shared-only, so its own CI never
ran this suite; main has been at 4220 since, and any PR that fires the mobile web
app job fails on the stale pin. Measured on 4064653740 and on origin/main 3ea15dd0a2.

* test(terminal): differential serialize round-trip fuzz and transcript replay

Seeded VT streams (text, CJK/emoji/combining, SGR, cursor/edit ops, scroll
regions, DECAWM/IRM, alt-screen variants, DECSC, and shrink-heavy resizes)
drive a source terminal in three modes: reflowing normal buffer, alternate
buffer, and a non-reflowing pre-21376 ConPTY normal buffer. At each checkpoint
every SerializeAddon build under test serializes it, and each output is
replayed into a fresh terminal of the same size and compared cell by cell,
plus cursor, active buffer and modes.

CI runs 25 seeds per mode against a pinned list of pre-existing divergences,
and replays the committed PTY transcripts (the existing agent fixtures plus new
vim, less, pico, and OpenCode captures) under four resize schedules. Point
ORCA_OLD_SERIALIZE_ADDON at a previous patched build to also check byte
identity when no line is wider than the grid, and that no checkpoint regresses.

* test(terminal): build the differential serialize baseline from any git ref

config/scripts/build-serialize-addon-at-ref.mjs reverse-applies the patch that
produced the installed @xterm/addon-serialize dist, applies the ref's patch,
and verifies each step against the patches' blob hashes, so the fuzz can use
origin/main (or any fix commit) as its baseline without a second install.
ORCA_NEW_SERIALIZE_ADDON swaps in a built dist for the build under test, and a
seed-pinned test replays the nine I3 regressions found against origin/main.

* fix(terminal): serialize a clipped wide glyph as a width-1 blank

The stand-in for a wide glyph clipped by a column shrink came from getNullCell(),
whose width is 0. _nextCell skipped it as a wide trailer, and the row-end wrap
check counted the width-0 _backgroundCell as content, so a soft wrap after the
clipped column was taken as natural and replayed one column early
(conpty seed 1149: `abcdefghi中WRAPPED` at 12 -> 10 cols replayed as
`abcdefghiR`/`APPED`). Blank the cell in place instead: width 1, no codepoint,
its own attributes, so it counts as one empty column and forces the wrap.

Differential sweep vs origin/main, 7000 cases per mode: I1 0 byte diffs, seed
1149 fixed; the remaining I3 regressions are the trailing background-row seeds.

* test(terminal): neutral paths in the serialize fixtures and usage comment

The OpenCode transcript carried this machine's lane paths in its footer; replace
them with same-length neutral paths so the recorded cursor layout is unchanged.

* fix(terminal): keep trailing background-only rows when serializing

Without scrollback, _serializeString trims rows after the last content cursor.
A row made only of background-colored blanks emits its erase in _rowEnd but
never moved that cursor, so two or more such rows at the bottom were dropped
(4x3 `r1\r\n\e[48;5;157m\e[J\e[0m\e[3;1H` replayed with the last row blank).
Track the erase separately and extend the kept rows to it, except when the
cursor is wrap-pending: relative moves back from those rows cannot re-create
that state, and doing so regressed normal 508, alt 1425/6647, conpty 4699.

Harness: I1 now exempts checkpoints with a background row after the last text
row, the one place this fix changes bytes on purpose (scope helpers move to
serialize-grid-variant-scope.ts); conpty seed 5 leaves the pinned pre-existing
list. Sweep vs origin/main, 7000 cases per mode: I1 0, I3 regressions 0;
fixed/both-fail normal 967/3914, alt 2628/8316, conpty 6657/2937 (was
323/4558, 2296/8648, 5466/4128).

* test(terminal): narrow the I1 carve-out to where the serializer keeps background rows

Trailing background-only rows change bytes only when the serialized range has no
scrollback (the trimming path) and the cursor is not wrap-pending; checkpoints with
scrollback or a wrap-pending cursor are held to byte identity again. The 7000-per-mode
sweep against origin/main stays at I1 0 and I3 0.

* test(native-chat): pin that screen scrapers ignore kept background rows

The serializer now keeps trailing background-only rows, so a painted TUI's screen
read as text ends in \r\n\x1b[NX rows. Serialize the same frame with and without
them and check the Claude option scrape, the empty-prompt check and the fork
transcript read the same thing.

* test(terminal): read xterm core internals through a checked parser, not Reflect.get

The fuzz oracle reached xterm's private _core with Reflect.get, which the
low-evidence gate rejects. Narrow _core, writeSync and the DECSTBM bounds with
in/typeof checks into one named XtermCoreInternals shape instead.

* test(terminal): keep captured serialize transcripts byte-exact on Windows checkouts
2026-09-25 01:18:14 -04:00
Jinwoo Hong a05649de91 fix(terminal): prove an idle Git Bash prompt through its bin launcher (#22752)
* fix(terminal): prove an idle Git Bash prompt through its bin launcher

Git for Windows' bin\bash.exe is a launcher that runs usr\bin\bash.exe as a
child and waits, so an idle Git Bash pane's job always holds two pids and the
Windows shell proof never confirmed it. Accept exactly the launcher plus its
direct bash.exe child, checked against the identity process table.

* test(terminal): wait for the Git Bash prompt before asserting the hand-off job

* fix(terminal): prove a Git Bash prompt as one unbranched MSYS bash chain

Orca launches Git Bash as bin\bash.exe -c "chcp.com ...; exec \"$BASH\" ... -i",
and each MSYS exec leaves its pre-exec process alive as a stub, so an idle
pane's job is launcher -> stub -> interactive bash. Accept any job that is one
parent-to-child chain rooted at the launcher whose every later member is
bash.exe, instead of a fixed two-process shape.

* ci: register the Git Bash shell-proof win32 test in the package-test list

* fix(terminal): read the spawned shell as a path, and keep one shell map

A spawned shell path with a space (/Users/John Doe/bin/zsh) was split as a
command line, so the POSIX proof compared against "john" and never
confirmed. Local panes now keep only the spawned shell path and derive the
name from it; the Git Bash chain walk drops guards the member check already
covers.
2026-09-25 01:00:36 -04:00
Jinwoo Hong f559c0588a fix(terminal): ground a program that dies with input modes armed on the normal screen (#22739)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(terminal): one process-boundary ground for every known or proven boundary

Three copies of the "the process that armed these modes is gone" reset had
drifted: the cold-restore seed cleared only pen and mouse, the recovery
barrier used the renderer's dead-TUI profile, and the cold-restore payload
had none. A cold restore therefore left the dead process's focus reporting,
bracketed paste, application cursor and keypad modes armed in the live
emulator, the first checkpoint, and main's mirror. And the seed wrote the
dead process's torn escape after the reset, so the new shell's first bytes
could complete it (for example retitling the pane).

PROCESS_BOUNDARY_GROUND replaces them: CAN, leave the alt screen without
moving the normal-buffer cursor, every mouse protocol and encoding off,
focus/paste/app-cursor/keypad off, cursor shown and style reset, kitty
popped, SGR reset, grounded DECSC. It stays inert for the lifecycle
scanner. The seed, the recovery barrier, and the cold-restore payload all
use it, and the seed no longer carries the torn tail.

The first fold after a cold restore now always rebases on live, because
focus and keypad are not in TerminalModes and the zero-record shortcut
could not see them differ.

* fix(terminal): ground a program that dies with input modes armed on the normal screen

The daemon's in-stream crash detector only fired when a program died with
the alternate screen up. A normal-buffer program that armed mouse tracking,
focus reporting, keypad or kitty keyboard flags and exited without
disabling them was cleaned up only in the renderer, so the daemon kept the
modes and re-armed them on the next reattach, mobile included (#13077's
garbage-at-the-prompt family).

The lifecycle scanner now tracks armed input modes (mouse protocols and
encodings, ?1004, ?66, and kitty flags as per-screen stacks that mirror
xterm's main/alt swap and its 16-entry cap). ?2004 and ?1 are excluded:
shells arm them at their own prompts. Modes armed when a command starts
(OSC 133;C) count as the shell's, so a prompt that leaves modes on never
triggers. At OSC 133;D the trigger is now "alt screen or a program-armed
input mode", still one-shot and still gated by the shell proof, and the
existing PROCESS_BOUNDARY_GROUND is recorded through the stream so live
and durable history change together.

WSL panes spawn wsl.exe, which the shell proof does not recognise, so the
detector never grounds them; a test pins that and the renderer keeps
covering them. The mouse-leak e2e now keeps its arming process alive until
the live pane is checked, because the daemon grounds a proven exit.

* fix(terminal): keep shell- and host-armed input modes through the process-boundary ground

ConPTY arms focus reporting (?1004h) before the first prompt, and the live
recovery ground cleared it for the rest of the pane. The barrier now re-arms
the modes that were on at OSC 133;C right after the ground, so only the dead
program's modes are reset.

* fix(terminal): re-assert only modes the shell or host armed outside a command

A mode a program leaked past a refuted proof was still on at the next OSC
133;C, so the baseline snapshot re-armed it after a later ground. Record who
armed each mode instead: only enables outside a command (before any marker,
or between 133;A/D and C) form the baseline.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* test(daemon): follow the isFirstTake rename in the process-boundary ground suite

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.

* test(daemon): pass pendingRecords to the process-boundary ground fold

* fix(terminal): reset alt-screen kitty flags in the process boundary ground

Kitty keyboard stacks are per screen, so resetting only after ?1049l left
a dead TUI's alt-screen flags for the next alt-screen app. Also drop the
inert CAN from the ground (every site grounds after complete bytes) and
correct two stale comments.

* fix(terminal): track input-mode ownership in one map

Each armed mode now has one owner: host (before any marker, or a prompt a
133;C proved), prompt (unproven until C), command, or stale (left past a D).
Host arming is sticky, 133;D demotes command modes (the one-shot), and the
ground re-asserts only host modes. Fixes a D without C triggering on host
modes, an ESC c mid-command turning later enables into host modes, and a
program's repeated host enable dropping host ownership. The reattach e2e now
keeps the arming program alive so only the reattach reset can disarm it.

* refactor(terminal): stop treating kitty flags as host state

fish, the one shell that pushes kitty flags at its prompt, pops them before
running a command and re-pushes at the next prompt, so the ground never needs
to restore them. Only host private modes are re-asserted now.

* fix(terminal): keep host input-mode ownership across RIS

ConPTY answers a mid-command ESC c by re-sending ?1004h, which reset() had
recorded as the command's, so the ground turned host focus reporting off for
the rest of the pane. RIS now drops only non-host ownership.

* fix(terminal): let only the host own focus reporting and leave it in the ground

Host ownership covered every mode armed before the first marker, so a tmux
that died with mouse on had it re-armed by the ground. And the re-assert's
?1004h enable made the runtime's ownership mirror revoke, so remote owners
never settled on Windows. Only ?1004 can be host-owned now, and the ground
skips its ?1004l instead of turning it off and back on, so injected bytes
carry no enables.
2026-09-25 00:50:04 -04:00
Brennan Benson 58ba75b5a5 feat(agent-status): child work records say what the child is doing, how it ended, and when (#22521)
* test(agent-status): pin the legacy child-work projection of published background tasks

* feat(agent-status): child work records say what the child is doing, how it ended, and when

A child-work record gains the facts every surface needs from one host-owned
record: the child that owns it (parentChildWorkId), whether the provider said
it may outlive its launch turn (residency, host-only), what it is doing now
(operation, with an open/reported basis), the newest thing it said
(lastMessage), and when its current invocation settled (settledAt, stamped by
admission, never by a producer).

The codec enforces one membership x state legality matrix: live work is never
done and carries no outcome or settle time; only a shell or monitor stores
monitoring; settled work is done with an outcome and a settle time inside its
own evidence window; an operation exists only while live and working, waiting
or blocked. A settled record written without an outcome reads as unknown, never
success. Malformed descriptive fields drop and keep the record.

A new read-only view (AgentChildWorkView) is the one projection surfaces read;
the legacy subagent and background-task shapes are derived from it with
today's output unchanged for today's inputs. deriveAgentChildDisplayState
folds a child's own state and the liveness of the work it owns through the
same fold a parent row uses, so a child whose own work is idle or done reads
monitoring while a shell it launched runs.

Codex children get a thread_id alias kind.

* fix(agent-status): an unknown child ending can gain its real outcome; operation clock clamped

A settled child whose ending was first recorded as unknown (a roster omission
can land a tick before the frame naming the outcome) now accepts the definite
outcome for the same invocation and keeps its original settle time. A definite
ending still never changes, and a later unknown ending is ignored rather than
downgrading it.

Admission clamps operation.observedAt into the child's evidence window, so an
operation stamped in provider time is kept instead of silently dropped.

The record codec is pinned as host-internal: it rejects a whole record over one
unknown key, so a ratchet test fails if anything outside the host store and
admission path imports it.

* test(agent-status): pass the fold-parity input as a value; name the hook lane's alias kinds

* fix(agent-status): a sparse child observation never erases what the record already knows

Admission merged a later observation by replacing the whole record, so an
ending that knew only that the child was gone dropped its name, model and
token count, and an outcome refinement dropped the recorded last message.
Labels now fill or replace but never clear, tokens never shrink, and a
settled ending keeps its last message unless new evidence carries one.
The provider-id preference is keyed by alias kind so a new kind cannot
compile without a rank.

* fix(agent-status): a child's owner, residency and last message outlive a sparse observation

A settle that knows only that the child is gone dropped who owned it and
whether it ran in the background, and the last thing the child said while
live. They now survive like the labels do: the last message for its
invocation, owner and residency for the child.

* fix(agent-status): an unknown ending keeps a definite outcome and still lands its evidence

A settled child's later `unknown` (or omitted) ending was acknowledged without a write, so a
late last message, token count, alias or reclassification it carried was dropped while the
caller was told it was accepted. The outcome now merges like every other sparse fact: an
`unknown` claims nothing and keeps the stored definite outcome, and only a different definite
ending conflicts.

* fix(agent-status): group child aliases and owned work in one pass

Appending by spread copied each bucket on every insert, quadratic in a bucket's size on the
projection and per-row liveness paths.

* fix(agent-status): group child aliases and owned work without Map.groupBy

The relay runs this core on Node 18, which lacks Map.groupBy; a plain loop into a Map is
equally linear and portable.

* fix(agent-status): a child's activity and last message survive the codec

Admission folded raw provider text with the status-row normalizer, which can leave a tab or
other control character and can end a truncation on a space. The record codec drops such a
field, so a long command cut at a space, a tab in a command, or an escape in a message
silently erased the child's current operation or last message. Admission now folds control
characters to spaces and trims the cut, with the codec's own control-character predicate.

* refactor(agent-status): parse child facts, merge, then check the record

Admission merged provider values before anything knew they were valid, and
the codec then either rejected the whole record or silently dropped the
field depending on how old the field was. A malformed owner erased the
stored one, a label cut on a space rejected the announce, and a bad token
count blocked a settle.

Admission now parses every descriptive fact into a value the codec accepts
or "not said", merges it over the stored record with one rule per fact (a
typed map, so a new request field without a rule fails to compile), and the
codec checks the result. Text goes through one normalizer and the codec
accepts exactly its image; any value outside it is a writer bug and
rejects. The owner is now a fact of the invocation, like the last message.

* refactor(agent-status): name each erasure row by what makes its value malformed

* refactor(agent-status): pin the token parse where the max-merge cannot hide it

* fix(agent-status): provider timing lasts only for its own run

providerTiming records the provider's start and end of one run. Keeping it
across a resume left a live restarted child claiming the previous run's
completion time. It now follows the owner and last message: kept within an
invocation, reset by a new one.
2026-09-24 21:44:35 -07:00
Brennan Benson eb746a6d32 fix(codex): a Codex native chat that never sent a message reopens after restart (#22639)
* fix(codex): start a new thread when a chat's thread was never saved

A structured Codex chat records its thread at create time, but Codex writes
no rollout until the first input. After a restart, launch resumed that
thread, Codex answered "no rollout found for thread id", and the chat could
never run again.

When the head of the handle chain is the session's own creation and Codex
answers that exact error for that exact thread, start a new thread instead.
The new link supersedes the unsaved creation in place and names it, so the
chain keeps one live identity and does not grow across restarts. A thread a
resume, fork or adoption proved is never superseded, and no other resume
error starts fresh.

* test(codex): build launch-resolution chains without a type assertion

* fix(codex): match only Codex's own no-rollout text, pinned through the real connection

The fallback matched Orca's own error-wrapper prefix too, and every test built
that string itself, so rewording the wrapper would have disabled the fallback
with the suite green. Match the method, code -32600 and Codex's exact detail
as the message suffix, and drive Codex's raw error frame through the real
connection in a test.

The link builder now refuses, at the type level, a supersession on an adopted
or resumed link, which the chain would reject downstream anyway.

* docs(codex): note why the no-rollout text is safe on the resume path
2026-09-24 21:43:04 -07:00
Brennan Benson a0e24905f6 fix(agent-status): a cancel never hides live work (#22476)
* fix(agent-status): a cancel never hides live work

After the user cancels a turn, a background shell, scheduled check or
subagent that is still running keeps reading as it truly is in both
lanes. The fold no longer takes a verdict input; the cancellation
survives only as lead.outcome, restated as the row's interrupted flag on
a settled row for readers that predate lead.

* fix(agent-status): keep a cancel's verdict and clock on every settle path

A Grok turn cancelled while a task ran now reads monitoring, and the
idle_prompt backstop that later settles it restated done without the
row's `interrupted` flag, so notification readers announced the
cancelled turn as a clean finish. Derive `interrupted` from the main
agent's outcome, as the Claude builder already does.

The inferred Claude cancel now folds through the host's local main
agent record, which a relayed pane never refreshes, so a second cancel
on an SSH pane inherited the first cancel's clock. The caller admits
only a working main agent, so the cancel always starts a new done clock.

* fix(agent-status): keep the shell fact on an inferred cancel so restart can seed it

An inferred Ctrl+C cancel beside a working subagent publishes a row held
open by child work, but the synthesized event dropped the row's paired
claudeRunningNonAgentTask fact because mainAgent changed. Hydration seeds
a settled main agent only when that fact says no shell ran, so after a
restart the child's drain left the row working with no mainAgent. Carry
the fact forward: a cancel does not change what the shell inventory said.

* fix(agent-status): a Ctrl+C at an idle main agent's prompt cancels nothing

Every row that publishes the main agent fact now admits an inferred cancel
only while that main agent is working. Grok's Ctrl+C at the idle prompt
leaves its background task running, so settling the monitoring row to
done hid live work. Rows without the fact keep the evidence guard, and
Codex keeps it too because its synthesized row is a plain done.

* fix(agent-status): fold a relayed pane's cancel from its row, not the desktop's records

The inferred Claude cancel read and wrote the desktop's own listener
records for every pane. For an SSH pane those records are not the relay's:
hydration seeds them from the saved row and nothing reaps them, so a
subagent that finished on the remote after a desktop restart kept a
cancelled row spinning with nothing running. A local pane still records
the verdict on its listener and folds its own roster; a relayed pane
folds only the child work its row carries. The relayed-pane parameter and
forced clock the shared record path grew for this are gone.

* fix(agent-status): hold a cancel verdict in the store until a new turn or the provider's own

A relay never learns of the cancel the desktop infers from Ctrl+C, so its
next child hook or reconnect replay restated the main agent as working and
flipped the row back. The late-hook suppression that guarded this keyed on
a done row flagged interrupted, which a cancel held open by a shell or
subagent no longer is; it also dropped Grok's own stop_cancelled when the
inference won the settle race, hiding the task that hook reported.

The suppression is replaced by a latch derived from the row: its main
agent reads cancelled (or, from an older host, a done row flagged
interrupted). A settled incoming main agent, another prompt, an explicit
prompt or a session start releases it. Child and replayed events keep the
latched main agent and are re-folded with their own child evidence; late
main agent work is held as before, and Codex keeps its record re-mark.

* test(agent-status): pin Codex's evidence guard beside the main agent fact

* fix(agent-status): a prompt submission ends the cancel verdict latch

The task notification Claude starts when background work ends is a real
turn, but it keeps the cached prompt and carries no explicit prompt, so
within 15 s of a cancel the latch held its prompt submission and every
tool event after it: the turn read as monitoring under a cancelled main
agent until its Stop. The captured shell cancel has exactly this: the
notification lands 0.17 s after the cancel key.

* fix(agent-status): derive a Codex row's interrupted flag from its main agent

The cancel verdict latch lets any settled mainAgent through, so a late
root Stop after an inferred Codex cancel now applies where the old
same-prompt window held it. It restates the cancellation on mainAgent
but, unlike Claude and Grok rows, carried no interrupted flag, so mobile,
the dashboard and notification text read the cancelled turn as finished.
Codex rows (local and relayed) now derive the flag from the main agent
record, like the other providers that publish one.

* docs(agent-status): describe cancel admission for every provider and the store's cancel-verdict hold

* docs(agent-status): correct the idle-prompt Ctrl+C claim to the measured CLI behavior

* fix(agent-status): preserve waiting relay children on cancel

* fix(agent-status): resolve the cancel hold before a child's permission card adopts a relayed main agent

The permission-card hold took the incoming event's mainAgent before the cancel hold ran,
so on an SSH pane a child's next tool under a sticky card restated the relay's stale
working main agent and dropped the cancellation the desktop had inferred.

* test(agent-status): pin that a cancelled turn's drained subagent settles as stopped, not completed

* fix(agent-status): keep a cancel through a restarted relay's child hook and a teammate's idle

A relay that restarts after a desktop-inferred cancel has lost its prompt cache,
so the child's next hook arrived with an empty prompt, read as a new turn, and
replaced the cancelled main agent with none; the row then stayed working after
every child stopped. A child's empty prompt is now unknown, not another turn; a
non-empty different one still releases, since it is the listener's newer prompt.

TeammateIdle names its child by teammate_name and carries no agent id, so the
latch treated it as the main agent's and let the late-hook window apply it after
15 s, reviving the cancelled turn. It is now re-folded as child work.
2026-09-24 21:24:59 -07:00
Jinjing b419b3183e test: remove redundant mobile and GitLab checks (#22748) 2026-09-24 20:45:15 -07:00
Jinjing c279760fcc fix(docs): update translated readme asset and WeChat links (#22763)
Update feature wall asset paths to new documentation site structure and bump WeChat community group reference from 8/9 to 10 across all translated README files.
2026-09-24 20:43:07 -07:00
Jinwoo Hong fe46138716 fix(terminal): one process-boundary ground for every known or proven boundary (#22735)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(terminal): one process-boundary ground for every known or proven boundary

Three copies of the "the process that armed these modes is gone" reset had
drifted: the cold-restore seed cleared only pen and mouse, the recovery
barrier used the renderer's dead-TUI profile, and the cold-restore payload
had none. A cold restore therefore left the dead process's focus reporting,
bracketed paste, application cursor and keypad modes armed in the live
emulator, the first checkpoint, and main's mirror. And the seed wrote the
dead process's torn escape after the reset, so the new shell's first bytes
could complete it (for example retitling the pane).

PROCESS_BOUNDARY_GROUND replaces them: CAN, leave the alt screen without
moving the normal-buffer cursor, every mouse protocol and encoding off,
focus/paste/app-cursor/keypad off, cursor shown and style reset, kitty
popped, SGR reset, grounded DECSC. It stays inert for the lifecycle
scanner. The seed, the recovery barrier, and the cold-restore payload all
use it, and the seed no longer carries the torn tail.

The first fold after a cold restore now always rebases on live, because
focus and keypad are not in TerminalModes and the zero-record shortcut
could not see them differ.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* test(daemon): follow the isFirstTake rename in the process-boundary ground suite

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.

* test(daemon): pass pendingRecords to the process-boundary ground fold

* fix(terminal): reset alt-screen kitty flags in the process boundary ground

Kitty keyboard stacks are per screen, so resetting only after ?1049l left
a dead TUI's alt-screen flags for the next alt-screen app. Also drop the
inert CAN from the ground (every site grounds after complete bytes) and
correct two stale comments.
2026-09-24 23:38:53 -04:00
Jinwoo Hong 1c1b7829ec fix(daemon): rebase durable checkpoints on the live terminal (#22732)
* fix(daemon): rebase durable checkpoints on the live terminal

A durable checkpoint was folded from the previous checkpoint plus recorded
output, so it inherited that checkpoint's modes forever. After a daemon
restart killed a full-screen TUI and a new process started inline, the chain
kept the dead TUI's alt screen and mouse tracking (?1049h ?1003h ?1006h)
while the live emulator was clean. Every reattach and getBufferSnapshot
served the stale chain, the renderer re-armed mouse tracking, and wheel
scrolling went to a program that never asked for it: scrolling froze.

Each full checkpoint is now the live snapshot verbatim (screen, layout,
alt frame, modes, owner) with only the normal-buffer rows live has evicted
taken from the durable replay. A checkpoint can no longer carry a dead
process's modes, and checkpoints already poisoned on disk heal on the next
compaction.

- The first fold after a cold restore replays the same seed segments live
  was given, so rows line up even over a dead TUI's alt screen.
- Idle zero-record folds keep the disk copy when it already agrees with
  live, so quit and relaunch bursts don't replay every session.
- Held teardown bytes are already in the drained records and the live
  snapshot, so they are no longer replayed twice or appended as a tail.
- The bounded getBufferSnapshot path honors the requested depth even when
  the live window is deeper, without phantom link rows.
- The fold's ownership scanner and frame merge are removed; owner and
  frame come from live.

* fix(daemon): keep OSC links and kitty flags through durable checkpoint folds and trims

Stop seeding persisted OSC link ranges into the fold replay: they index the
base buffer, so rows evicted by pending output left a link on the wrong text.
The serializer already writes OSC 8 into the ANSI the fold replays.

Re-apply kitty keyboard flags when replaying a snapshot for trimming, since
rehydrateSequences omits them.

Bound a smaller restore request by trimming the committed checkpoint instead
of re-reading disk and rebasing the live window at a smaller depth.

* refactor(daemon): drop the unreachable deep-live branch from the durable fold

The live window's override cap now derives from the restore depth, so live can
never be deeper than the fold. pendingRecords and isFirstTake are required.
2026-09-24 23:09:36 -04:00
Jinwoo Hong bf40d35b0b docs: restore translated README assets reverted by stale APK bump (#22755)
#22740 bumped the translated Android APK links from a stale base, which also reverted #20416's feature-wall paths, the WeChat group 10 QR, and the Muse agent badge. Restore those from the parent commit and keep only the 0.0.50 APK bump.
2026-09-24 22:11:45 -04:00
mmarabel 7a71e20860 fix(pi): isolate status ownership in new terminals (#22717)
* fix(pi): isolate status ownership in new terminals

* docs(pi): explain terminal ownership boundaries

* docs(pty): clarify environment rescrubbing
2026-09-24 18:47:39 -07:00
Brennan Benson 517ef56c66 fix(agent-status): end a Claude helper's turn when an API error stops it (#22745)
* fix(agent-status): end a Claude helper's turn when an API error stops it

When a Claude helper agent's request fails (for example a 429 rate limit),
Claude skips the helper's SubagentStop and TeammateIdle hooks and sends only
a StopFailure carrying the helper's agent_id. Orca treated that StopFailure as
ordinary helper activity, so the helper row stayed "working" and pinned the
pane "working" indefinitely, even after the lead agent finished.

Route a helper's StopFailure through the same child turn-end path as
SubagentStop, in both the hook listener (roster update) and the server's
sticky-permission rule (a failed helper no longer holds its permission prompt).

* test(agent-status): cover a failed background child that held a permission prompt

* test(agent-status): pin the captured order of a rate-limited teammate before the lead stops
2026-09-24 18:39:58 -07:00
mmarabelandNeil 60b0d01edb fix(agent-history): list older Pi sessions in Workspace and Project views (#22482)
* fix(agent-history): surface older Pi sessions in Workspace and Project scopes

The session list is capped to the newest sessions across every workspace.
Scoped views already bypassed that cap for Claude, whose transcripts live in
one directory per cwd, but a Pi session older than the cap vanished from its
own workspace's list.

Pi buckets transcripts the same way (`--<cwd with separators as ->--`), so
the scoped pass now takes a per-agent directory layout and runs for both.
Codex keeps date-based folders with the cwd only inside each file, so it
still relies on the capped scan.

* fix(agent-history): match Pi buckets under a root scope and drop the unused Claude wrapper

* fix(pi): verify each scoped transcript when cwd buckets collide

* docs(pi): clarify inherited Claude bucket cache behavior

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-24 18:33:14 -07:00
mmarabel 9678dfb9aa fix(pi): stop pi-subagents workflows from pinning a pane on working (#22533)
Awaited workflow children announce subagent:async-started but never get a
subagent:async-complete, so the #21882 roster kept their ids forever and
suppressed every later agent_end. Treat subagent:process-terminal (the
runner exiting) as the end signal for tracked runs, with a short grace so
runs that report their own completion keep settling through that path.
Also accept completion events that carry only runId.

Fixes #22527
2026-09-24 17:54:33 -07:00
Neil 7ea01279cd feat(search): bundle ripgrep for local, WSL, and SSH search (#22396)
* feat(search): bundle ripgrep for local, WSL, and SSH search

Ship @vscode/ripgrep-universal's prebuilt rg for all six relay platforms in
every desktop artifact. Local and WSL searches spawn the bundled binary and
drop the git ls-files / git grep fallbacks; SSH deploys upload the remote's
binary once per ripgrep version and the relay prefers it over PATH rg.

* fix(search): address bundled ripgrep review findings

- Key the SSH ripgrep cache on the binary's content hash; a package bump is the only update step
- glibc verifier: read arch tokens below the slice root and accept static ELFs (arm64 release blocker)
- Ship ripgrep/PCRE2/musl license notices; bundle rg with orcad
- Packaged builds never spawn a bare rg; report fd pressure as transient
- SSH: install rg before sweep/GC, size-validate installs, back off instead of disabling on launch failure
- Scope Dependabot to @vscode/ripgrep-universal; revert unrelated lockfile churn

* chore(search): drop bundled-ripgrep reference doc; assert full packaging layout parity

* refactor(search): one entry point for spawning the bundled ripgrep

Local Quick Open, Quick Open path search, the Explorer name filter, and
runtime text search each repeated the same three steps: resolve the bundled
command, spread in the WSL distro, spread in the WSL shell expression. Fold
that into spawnBundledRipgrep so one place owns the rule that a bare 'rg'
must never reach spawn, and simplify the resolver's command/packaged checks.

Restore the AGENTS.md ripgrep rule dropped alongside its reference doc in
63f4dac, and note why the relay's availability probe may spawn a bare 'rg'.

No behaviour change; verified by the existing suites plus a new test that
pins the local, WSL-routed, and distro-routed-but-Windows-output cases.

* refactor(search): drop the local install-ripgrep path; enforce the rg rule

Bundling rg removed the local git/readdir fallback, so nothing can produce
the "install ripgrep on the host running the Quick Open scan" guidance any
more -- only a remote host an upload never reached still reaches the capped
listing. Drop the host parameter, the renderer's local branch and its
translation key, and the relay wrapper that existed only to pass 'remote'.

Add a ratchet test for bare 'rg' spawns, since the AGENTS.md rule alone had
nothing enforcing it. Its one allowlist entry is the relay's PATH probe,
which asks about PATH by definition. Verified the guard catches a planted
offender rather than passing vacuously.

Also stop chaining the remote cleanup sweep behind the ripgrep upload: on a
cold host that is a multi-MB transfer, and stale upload stages and
superseded version dirs were left on the remote for its whole duration. The
two touch different trees, so they now run concurrently.

* test(ssh): pin that the cleanup sweep does not wait on the ripgrep upload

* fix(search): derive rg spawn types instead of importing node:child_process

A type-only import still counts against the child_process ratchet, whose pin
and allowlist only ever shrink. Derive both types from wslAwareSpawn instead.

* fix(search): surface an unreachable WSL workspace instead of an empty result

Inside `bash -c`, a failed `cd` exits 1 -- the same code ripgrep uses for "no
matches" -- so a WSL workspace whose directory had gone away reported an empty
listing as a successful scan. main did not have this hole: checkRgAvailable ran
the same `cd` wrapper first and settled on `code === 0`, diverting to the git
fallback that this PR deletes. The WSL wrapper now takes an optional
cwdFailureExitCode; rg passes 97, and all four close handlers reject with a
clear error before the unavailable check can blame the install.

Also from review:
- Bound the fire-and-forget ripgrep upload with deploySignal. The controller
  aborts only on the deploy timeout, never on success, so this cancels a
  still-running upload when the deploy gives up.
- Run the stale-stage sweep before the installed check rather than inside its
  else branch. Once rg was installed every later deploy took the PRESENT path,
  so a stage orphaned by a dropped connection was never collected again.
- Note in orcad-remote-deploy.ts why wiring it up needs ripgrep work first:
  build-orcad.mjs copies only the build host's rg, and orcad reports
  isPackaged() === true, so a remote of another platform would find nothing.

ssh-relay-deploy.test.ts sat at the max-lines cap, so any edit to it failed the
gate. Split the four Windows named-pipe deploys into their own file (926 -> 737
+ 333); both are now well clear of it.

* fix(search): name the unreachable root in every handler, not three of four

Round-two review caught that the missing-cwd branch in scanRipgrepPaths sat
AFTER isRipgrepUnavailableExit, which classifies any code above 2 as a broken
install -- so for exit 97 it was dead code and Quick Open still told the user to
reinstall Orca. Reordered; all four handlers now check it first.

Also from review:
- A vanished workspace makes spawn fail with ENOENT, which read as a damaged
  install on every local path. Confirm the cwd with isRipgrepSpawnCwdUsable --
  the guard the relay already applies -- before blaming the binary. The async
  continuation re-checks `resolved`, because finish() drops its argument once
  settled and the rejected promise would otherwise go unhandled.
- bundledRipgrepCommand returned a bare 'rg' for an arch outside the bundled
  set, bypassing the guard that exists so Windows cannot resolve a bare name
  against the repo cwd. A packaged app now always names an absolute path.

Drop ci-shards/unit-assignment.json, a 9,425-line CI artifact swept in from
reproducing a shard locally, and gitignore the directory that produced it.

The "rg genuinely cannot start" test pointed at a synthetic /repo, which the
new guard correctly reports as unreachable; it now resolves to a real root so
it still tests what its name says.

* fix(search): let the error handler own the spawn-failure verdict

A failed spawn emits 'error' and THEN 'close' with a negative code. The cwd
check added in the error handler did not settle, so the close handler settled
first -- synchronously, with the reinstall message -- and won the race every
time. The branch was not merely flaky, it was unreachable in all four handlers:
it is guarded by pid === undefined, which is exactly the case that always
produces a following close(code < 0). Verified against a real spawn: 3/3 runs
give error(ENOENT) -> close(-2). The error handler now detaches 'close' before
the probe, so it owns the outcome.

The probe also had no rejection handler, so a probe that rejected left the
search unsettled forever -- a hang, not just a wrong message. It now falls back
to the prior verdict rather than inventing one.

Tests: filesystem-search-rg-timeout and orca-runtime-files-search already cover
error-first and close-first, but against synthetic roots that the new guard
correctly calls unreachable; they now resolve to a real root, keeping each
test's stated intent. Added a Quick Open case for the vanished-workspace path
and confirmed it fails with the old ordering.

* test(search): cover exit code 97 in all four ripgrep close handlers

Round-four review found the missing-cwd branch had zero handler coverage: no
test anywhere emitted close(97), only -2/0/1/2/127. Ordering was correct, but
guarded by source-line order alone -- and that exact ordering was wrong in
three of four handlers two commits ago. Each suite now drives close(97) through
its real handler and expects the unreachable-root message.

Verified the tests earn their place: neutering the missing-cwd check fails
exactly four tests, one per handler.

Also drop a Reflect.get the anti-slop gate rejects, in favour of `in` narrowing.

* docs(search): stop claiming the close handler always wins the race

The previous commit asserted close "would beat this threadpool round-trip every
time", from an n=3 sample that measured event ordering -- which was never in
dispute -- rather than probe-vs-close. Two later measurements disagree with each
other: 50/50 close-first here, 30/50 probe-first in review. Either way it is a
race on a sub-millisecond margin, and the detach is what makes the verdict
deterministic.

Why this wording matters: "close wins every time" is an argument for deleting
the detach as a guard against an impossible race. No test would catch that --
the suites emit error and close in the same synchronous tick.

* chore(search): ship the jemalloc and libunwind notices the Linux rg needs

The statically linked Linux builds carry jemalloc (BSD-2-Clause) and LLVM
libunwind (Apache-2.0 WITH LLVM-exception) in addition to PCRE2 and musl, and
both require their notice on binary redistribution. Confirmed with `strings`:
their symbols are present in linux-x64 and linux-arm64 and absent from the
darwin and win32 builds. Texts taken from the upstream canonical sources.

extraResources already copies the whole licenses directory, so these ship
without a packaging change.

* fix(relay): stop spawning a bare rg, name unreachable roots, collect old builds

Three gaps the reviews surfaced on the remote side, all pre-existing on main.

Bare `rg` on Windows remotes. Both relay spawn sites pass the user's repo as
cwd, and CreateProcessW searches the cwd before PATH -- the same hijack the
desktop side already fixes. The relay now walks PATH itself and spawns an
absolute rg.exe, skipping relative PATH entries because those resolve against
the cwd. No rg on PATH yields null, which callers treat as "ripgrep
unavailable" rather than handing spawn a bare name. POSIX keeps the bare name:
execvp never consults the cwd, so there is nothing to resolve and nothing to
gain. With the last probe converted, the bare-spawn ratchet allowlist is empty.

Empty results for an unreachable root. settleLaunchFailure resolved an empty,
successful-looking scan when the root was gone but PATH rg existed, and the
git/readdir chain never engaged because it only triggers on
RipgrepUnavailableError. Both relay paths now reject naming the root, matching
local workspaces. Missing-rg keeps precedence over a missing root, because only
that verdict engages the fallback chain -- two tests pinned that deliberately
and it would have been wrong to flip it.

Unbounded ~/.orca-remote/ripgrep/. Nothing collected this tree; the relay's
version GC only matches `relay-*`, so every rg bump left another ~5 MB per host
forever. The probe command now also drops sibling builds older than two weeks,
sparing the current one and live upload stages, on POSIX and PowerShell alike.
Two weeks because a client pinned to an older build may still be using it; the
cost of collecting one early is that client re-uploading once.

* fix(relay): probe the rg that failed, and close the drive-relative PATH hole

Five review findings against the previous commit, all reproduced first.

The launch-failure classifier probed PATH rg, but the spawn that failed was the
bundled binary. On the normal remote setup -- no rg on PATH, which is why Orca
uploads one -- the probe failed and a moved workspace was reported as a missing
ripgrep, telling the user to install what Orca already ships. So the fix was
inert on exactly the hosts the uploader exists for. It now takes a candidate
list and asks the binary that actually failed first, then PATH.

path.win32.isAbsolute accepts `\tools` and `/tools`: rooted, but carrying no
drive, so they resolve against whatever drive the process is on. The probe
would have validated one against the relay's drive while the spawn, running
with the user's repo as cwd, resolved it against the repo's -- the same
cwd-dependence this lookup removes, narrowed from directory to drive. A real
drive letter or UNC root is now required.

probeRipgrepVersion had lost the timeout's kill in the rewrite, leaking a live
process and a ref'd handle per launch failure -- for a hang, which is the very
case the bundled-rg back-off exists for. It also spawned without windowsHide,
which would flash a console; fixing that made an allowlist entry stale, so the
entry is gone and the pin ratchets down 63 -> 62.

`windowsPathRipgrep ??= …` never memoised a miss, because null is nullish. The
caching was inverted against cost: a hit stops at the first directory, a miss
stats every one, and only the miss was repeated -- per spawn.

The bare-spawn ratchet claimed "nothing in production spawns a bare rg", which
is false on POSIX. It now also matches PATH_RIPGREP_COMMAND at a spawn site,
and the comment states plainly what a textual guard cannot see: the POSIX bare
name reaches spawn as a parameter, and is safe because execvp ignores the cwd.

The drive-rooted predicate is tested directly rather than through the
filesystem -- a temp dir on a POSIX CI host has no drive letter to exercise
win32 semantics with, so the filesystem test could never have caught this.

* test(mobile): repin the session closure past #22452's two shared modules

Merging main brought the closure to 4220 against a pin of 4218. The two extra
modules are `src/shared/agent-turn-outcome.ts` and `src/shared/main-agent-status.ts`
from #22452, which the status projection this route already reaches import.
That change was src/shared-only, so the mobile job never ran on it -- the same
way the structured tool line slipped past, as the ledger above already records.

Repinned here because this PR's file set is what next made the job run, not
because this PR reaches either module. Verified: of the 28 source files this
branch changes, none appear anywhere in the route's 4220-module closure.

* fix(search): preserve remote binaries and complete runtime packaging

* test(relay): pin the probe's env now that it inherits the relay's PATH

8d6759a threaded the relay env into probeRipgrepVersion -- correctly, since the
probe decides whether a launch failure was the binary or the root and so has to
resolve the same rg the failed spawn would have. It left the assertion that
pins the probe's spawn arguments behind, which is what CI caught.

Asserting buildRelayCommandEnv() rather than loosening the match to any object:
under process.env the probe could resolve a different rg, or none, which is the
regression the change exists to prevent.

* feat(ssh): collect remote ripgrep builds by reference, not by age

Nothing collected `~/.orca-remote/ripgrep/`: the version GC matches only
`relay-*`, so every change to the shipped bytes left another ~5 MB on every SSH
host, permanently. The age window this replaces was the wrong instrument --
a directory's mtime is when it was written, not when it was last used, so it
cannot tell a superseded build from the one a live relay was launched against.
Deleting the latter is not graceful degradation: without a PATH ripgrep remote
text search rejects outright, and listing drops to the capped walk this PR
exists to remove.

So the question is reference. Each relay directory now records the build it
runs against in `.ripgrep-ref`, written only once that binary is confirmed
present, and the GC collects a build only when no installation names it.

The discipline is ssh-relay-native-deps-cache-gc.ts': anything the pass cannot
account for blocks the whole pass. A relay directory with no readable marker is
an older Orca's, possibly running right now against a binary it never recorded,
so the pass declines rather than guessing. Those directories are removed by the
version GC in time, which is what makes their builds collectable -- hence
running after it, not beside it. Deletion is the same tombstone, recheck under
the rename, then remove, so a deploy that takes a reference mid-pass gets its
tree restored. Windows has no pass yet, matching the native-deps cache's gate.

One test note: the first version of the "unaccountable blocks the pass" test
passed against a deliberately broken guard, because the tombstone recheck
masked its absence. The test now puts a readable recheck behind an unreadable
first scan, which is the only shape that fails when that guard is removed.

Recording the reference lives inside ensureRemoteBundledRipgrep rather than at
the call site: it is the same concern, and it keeps the deploy's ripgrep
surface to one call for the tests that mock it to protect their exec queues.

* feat(ssh): collect Windows remotes too, and ship the Rust crate notices

Three items previously left documented-but-open.

Windows remote accumulation. The cache GC was POSIX-gated, so the leak did not
go away -- it moved to the platform with the larger binary (rg.exe is 5.43 MB on
win32-x64, against 4.77 MB for linux-arm64). The PowerShell dialect now does the
same reference scan: entries and references carry token prefixes, because
PowerShell writes every uncaptured value to stdout and an untokenised listing
would feed Remove-Item whatever a cmdlet happened to emit.

Verified on a real Windows host rather than a mock: the listing emits its
ENTRY/LIST_OK tokens, a relay directory carrying a marker yields REF <entry>,
and a relay directory without one yields REFS_ERR -- the safety path, on the
real interpreter.

Rust crate notices. The crate set was read out of the shipped binary's symbols
and the licence identifiers taken from crates.io rather than assumed. Where a
crate offers the Unlicense, Orca elects it: a public-domain dedication carries
no notice obligation, and that covers eight of them. The four that do not offer
it get their MIT text reproduced. encoding_rs carries a BSD-3-Clause notice for
its WHATWG-derived encoding data that is joined by AND, not OR, so electing MIT
does not discharge it.

Release-only validation, corrected rather than repeated. Linux AppImage/deb/rpm
already runs in CI's package job on every PR, and Windows signing was already
rehearsed on this branch. macOS notarization is the only item a release must
still exercise, and the exposure is narrow: notarization requires signatures on
Mach-O binaries, and of the six bundled builds only the two darwin ones are
Mach-O -- `file` reports ELF for linux and PE32+ for win32 -- so signIgnore
excludes only files the notary never asks about.

orcad-artifacts.test.ts caught the new notice file missing from the standalone
runtime's shipped list, which is exactly the gap that test exists to catch: a
notice committed to the repo but never actually shipped.

* fix(search): protect relay cache references and handle failed spawns

* fix(ripgrep): close review gaps and repair deployment fixtures

* test(mobile): refresh merged session module census

* fix(ssh): preserve ripgrep caches with empty legacy references

* test(mobile): assert bundle boundaries instead of global module count
2026-09-24 17:25:48 -07:00
Jinjing edbb2a91e2 docs: update translated Android APK links to 0.0.50 (#22740) 2026-09-24 17:02:17 -07:00
Brennan BensonandClaude 1069bb053f fix(agent-launch): a cwd at the workspace root no longer forces a terminal (#22729)
"Continue in New Session…" always names a cwd, and both the renderer route
input and the host launch-mode decision read any cwd as a custom start
directory, so the continuation opened a terminal agent even when chat was
the user's default. Both now share one rule: only a cwd outside the
workspace root (after normalising slashes, Windows case, WSL aliases and
the distro's Linux spelling) requires a terminal. A subdirectory still
does, because a structured session cannot start there.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 16:57:38 -07:00
Brennan Benson 5e6fcca0b3 fix(native-chat): date a session by its own lifecycle, not its subagents' work (#22520)
* fix(native-chat): date a session by its own agent's rows, not its subagents'

The journal reducer's lastActivityAt is the structured status summary's
updatedAt, which the status row uses as its completion stamp and
acknowledgement clock. It took the max over every journal row, and a
session's subagents write into the same journal after its own agent has
settled, so an idle parent was re-dated and marked unread on child work.

A row now dates the session only when the session's own agent produced it:
not a row whose producer linkage names a subagent, and not a subagent
roster row (a subagent-group block), which the session writes but revises
on every child transition. The roster rule is derived from the row body;
no new persisted field. Replay folds through the same rule, so existing
journals are re-dated to their own last row on reopen.

Claude: a backgrounded subagent emits no child frames, so its re-dating
came entirely from roster revisions (task_updated, task_notification) and
from the stale-roster revision written when a journal reopens. Codex: the
roster is revised on every child token-usage report; child-thread rows
carry no producer linkage yet, and read as the session's own until they do.

* fix(native-chat): a reopened journal's verdict on stale work does not date the session

Reopening a journal settles rows the previous host left live (a working
subagent roster, a live background task) to unverifiable. Those revisions
were appended at the reopen moment, and a background-task row is the
session's own non-roster row, so a crash-restarted session with a live
shell was re-dated to the restart although no agent acted.

The reconciler now writes each verdict revision with the row's own observed
time. That is one rule at the one writer, covering both settle shapes; the
render item's observedAt was already pinned to the row's first write, so
nothing the transcript shows changes. The live-transition roster exclusion
stays: live roster revisions are written by the providers, not here.

The `recovered` row flag is not used as the discriminator: the live
unexpected-exit settlement also writes recovered rows, and a clock rule
keyed on it would stop dating a provider crash the host just observed.

* fix(native-chat): date a session by the reducer's attribution of what a row wrote

The clock read producer linkage off the raw row. A lifecycle batch names no
row-level producer, a tombstone names none, and a revision may name none while
the reducer still attributes the item to a subagent, so each of those dated an
idle parent. The clock now asks the reducer: after a row applies, whether any
item it wrote is the session's own work; before a removal, whether the item it
removes was.

* fix(native-chat): date a session's status by its own lifecycle edges

A subagent writes into its parent's journal and keeps going after the parent
settles. Every one of its rows advanced the summary's updatedAt, and the status
row re-dated a done parent to it, so an idle parent read as newly finished and
unread on each child step.

The host now publishes statusStartedAt beside updatedAt: when the session's own
agent entered its status, read off edges only it writes. Idle is when its newest
turn ended; working is when the running turn was requested, or the earliest
send still unanswered; attention is its own oldest pending ask, or a subagent's
when that alone holds it. A turn that recovery settled after its host went away
ended when that settle was written, so it reads as a completion the user has not
seen; it carries no outcome, so no completion event or notification calls it a
success. Render items carry recoveredAt, the recovered row's own write time, so
nothing new is persisted.

The sidebar bridge and the host ingest date the row and the main agent's clock
from it whenever the row shows the main agent's own state, and keep their
existing rules for a row child work holds open or a summary from an older host.
The status feed republishes when the clock moves instead of on every idle row.

* revert(native-chat): keep the journal clock over every row

The row filter this branch put on the reducer's lastActivityAt decided which
rows could date a session: a list of exclusions that each new row kind could
slip past. The session's state is now dated by its own lifecycle edges, so the
filter, its attribution helper and the backdated reopen verdicts go back to
main. lastActivityAt, and the summary's updatedAt it feeds, is again the
evidence clock over every row, including a subagent's.

* fix(native-chat): keep republishing an idle session its live child work holds open

A row held open by live child work is dated by when each reader saw the
publish, and mobile decays a working row whose evidence is older than the
staleness window. Suppressing row-activity republishes for every dated idle
session froze that evidence, so a subagent running more than 30 minutes past
its parent's turn made the row read idle on mobile. Only a session nothing
holds open stays quiet on row activity now; its state clock is unchanged.

* fix(activity): order an agent's timeline by when each state was seen

An answered ask returns a settled parent to its own turn's end, so its done
repeats the time of the done before the ask. Activity keyed and ordered
events by that time: the new done collided with the old one and was
dropped, and the row took its state from the newest-dated event, the
blocked ask, so a done parent read Blocked and needed attention.

Each state switch now records the `updatedAt` it was seen at. Events are
keyed and ordered by that, while unread and "Clear completed" still compare
the state's own time, so the answer neither re-lights unread nor revives a
cleared done. The row's state comes from the pane's own status entry, so a
clear that hid the done cannot leave it reading Blocked either.

* test(activity): pin the timeline across repeated asks, a clear, and a stale turn

Three parts of ordering the timeline by when each state was seen had no test
that failed without them:

- A second ask moves the answered done into history. Both dones share the
  turn's end, so only the history entry's own seen time keeps them apart;
  without it one done collided with the other and the timeline showed two
  Blocked events in a row. Three asks also exceed the per-pane cap, which must
  keep the most recently seen events, not the most recently started.
- "Clear completed" on an answered row must cut off past the ask, which is
  dated after the done, or the cleared row stays listed. A done that the user
  cleared must also stay hidden once a later ask moves it into history.
- A stale working row must not read as running just because the pane's own
  status says working.
2026-09-24 16:17:08 -07:00
Aaryan Porwal 29480cd8cd fix(skills): recognize symlinked provider skill roots (#22606) 2026-09-24 16:00:43 -07:00
Brennan BensonandClaude 6ae6ed08bb fix(claude): open structured chat without a startup deadline, and make Retry start fresh (#22364)
* fix(claude): open structured chat without a startup deadline, and make Retry start fresh

Publish the Claude session as soon as its process is spawned instead of racing
initialize against a fixed 10s deadline. Prompts sent before startup lands are
held and written in order once it does. An exit or sign-in failure before startup
ends the session with the reason and the CLI's stderr.

A create that failed because the process provably exited now carries
ownerVerdict 'exited', so the client marks the launch failed and Retry mints a
new operation instead of replaying the stored failure.

* fix(native-chat): sending into a chat that failed to start restarts it

* fix(native-chat): a send with no live owner restarts it once

A provider child that timed out or exited hands its lease back, and every
later send was refused agent_session_ownership_unknown. Clients read that
code as "not admitted yet" and resend forever, while only a surface hold
could make a new child, once per mount, with its failure swallowed.

The send now routes to a live owner, otherwise restarts one from the
persisted resume state where resume eligibility allows it (single-flight
per session), otherwise refuses with the new settled
agent_session_owner_unrecoverable. Unverifiable, reserved and handed-off
leases are left alone. The desktop hold now logs its failure.

* test(native-chat): pin the unrecoverable refusal as settled in the outbox

* test(native-chat): pin the release clock after a send restarts an unheld owner

* test: read the sent operation id without a cast

* fix(native-chat): type the send-recovery record lookup as the store returns it

* fix(native-chat): a send ensures its owner before admission, and an unheld owner idles for 30 minutes

* fix(native-chat): a create that throws releases its event sink

A child that dies between spawn and journal attach can still write through
the host's event sink, which attach unbound in onAcquiring and never re-bound
because onAttached never ran. The orchestration released that sink only when
performAttach returned a refusal; a thrown failure (the root-exit path) kept
the sink cached with its queued write, so the next attach's drain barrier and
runtime shutdown's flush waited forever.

Also pins the publish-on-root-exit clause for a start that never proved:
deleting it reddened nothing before.

* fix(native-chat): a resend the journal answers restarts nothing, and a send joining a restart rebases from the fence it replaced

* fix(native-chat): the host learns a Claude start positively, and persists only proven options

A publish-first create used to read the session's options before Claude had
answered initialize. With startup pending that read fell back to the built-in
catalog's default, so `record.options.model` was persisted as `sonnet` for
every user whose CLI default is something else; an owner handoff or a reopen
then replayed `set_model('sonnet')` and silently switched their model.

The adapter now reports `started` once startup facts are applied and saved
options restored. The host keeps a `providerChildPhase` on the session it
owns: a starting child hands over nothing but the saved options as intent,
and the `started` event re-reads the options as fact and persists them through
the same record write a user's option change takes. The status summary carries
`hostExecutionPhase` (optional, wire-safe), and the chat pane says the agent is
still starting instead of showing nothing.

A child whose exit already reached the adapter before acquire returns is no
longer handed over as live; the create fails with the CLI's diagnostic.

* fix(native-chat): a hold and a send that find the owner gone share one restart, and a send the ledger already holds restarts nothing

* fix(native-chat): a failed create answers one refusal shape, stamped once at the boundary

A create whose Claude process was seen to exit answered twice in two shapes:
the first call threw a generic runtime error, and only the replay of the same
operation carried the `ownerVerdict: 'exited'` refusal that lets a client
retry under a new operation. Three sites stamped the verdict and the store
failure path stamped nothing.

The first-hand root exit is now returned as the refusal on the first call,
with the provider's own diagnostic as its message. The verdict is stamped in
one place, at the boundary of the attach, from the durable row the operation
settled to, so every refusal shape answers the same fact and no site can
forget it. The per-site stamps are gone.

* fix(native-chat): a send into a session whose child ended restarts it before admission

A session that published and then lost its Claude child before startup (not
signed in, for one) keeps a released lease and a chat the user can still type
into. The send was refused as ownership-unknown, the outbox parked it as
pending admission, and nothing ever restarted the child: the message sat
there until the user closed and reopened the tab.

A send reaching a session with no provider child now runs the same resume a
surface's first hold runs, before the write is admitted. The resume reserves
a new fence, so that send is answered stale with the published fence and the
client's outbox re-drives under it, as after any fence change. A resume that
fails is not this send's answer; admission reports the lease as it stands.

* chore: restore pnpm-lock.yaml to origin/main (local pnpm rewrote it)

* test(native-chat): pin the pre-handover exit as a failed acquire; stub the status feed in the delivery test

An exit the adapter observes before acquire returns now fails the acquire
with the CLI's diagnostic instead of handing over a dead child; the
published-then-ended path stays pinned by the slow-init startup case. The
delivery test renders the pane, which now activates the host status feed.

* test(native-chat): a same-ID re-hold over the wire joins the one resume, and a replay reopen goes on the idle clock

* test(native-chat): a re-hold that joins a failing resume proves one resume ran

* fix(native-chat): a create whose child was proven gone answers the refusal on the first call

The previous change answered a first-hand root exit as the exited refusal on the
first call, but the common failed start never took that path: when the close
ladder proves the whole tree dead the acquisition error is a plain one, the
store-failure classifier rethrows it, and the client still saw a runtime error
first and the refusal only on replay.

The cleanup that proves the child gone now names such a failure
`AgentSessionAcquisitionExitProvenError`, carrying the provider's diagnostic,
unless it already names its own verdict (a refusal, a typed exit proof, a host
store code). The attach answers both proven-exit kinds as the refusal its replay
gives. How a failed acquisition settles and how it is first answered now live
beside the verdict stamp, in the failed-create module.

* test(native-chat): pin the outbox re-driving a stale-refused send under the resumed fence

A send into a session whose child ended is answered stale once the host has
restarted the child. The outbox keeps that operation queued and blocked, and the
fence change the resume publishes re-drives the same operation under the new
fence; the host admits it.

* fix(native-chat): a child restarted for a send nobody holds is still released

The restart a send runs for a childless session takes no holder, on the premise
that the sending surface already holds one. A one-shot writer holds nothing, so
the child it restarted had no release clock and lived until the app quit. The
write resume now arms the clock when no holder is present, as the first-hold
resume already does. The send-after-failed-start cases also pin that the stale
answer's operation is admitted when re-sent under the new fence, and that two
racing sends restart the child once.

* test(native-chat): pin the picked Claude model across a resume whose child starts on its own default

The started event re-reads and persists what the child reports. A resumed child
answers initialize with its CLI default before the saved pick is restored over
it; the record must hold the pick while starting and after started.

* Revert "fix(native-chat): a child restarted for a send nobody holds is still released"

This reverts commit e52c4a6f08.

* Revert "test(native-chat): pin the outbox re-driving a stale-refused send under the resumed fence"

This reverts commit 136a39deb0.

* Revert "fix(native-chat): a send into a session whose child ended restarts it before admission"

This reverts commit 39234e44bf.

* refactor(native-chat): make ensure-owner a step of the serialized send

A send that found the owner gone restarted it OUTSIDE the host's per-session
serialize, through a single-flight resume map shared with the surface hold, then
rebased its fence by heuristic. The attach body is now callable from inside
`serialize` (`attachStructuredAgentSessionUnderSerialize`), and every restart
runs there: a hold, a send's ensure-owner step, provider-exit recovery and the
rewind owner replacement take turns on one queue, so the first to run attaches
and the next finds its child. The single-flight map and `isResuming` are gone.

Admission is two-phase for a send: the ledger's answer comes first and places
nothing; a send it will admit gives the session an owner, and only then are the
row placed and the lease and fence checked. A send it will replay into a closed
session makes the journal readable and spawns nothing. The session entry
records the released fence the child replaced (`resumedFromFence`), so a writer
current as of that owner is admitted at the new fence by bookkeeping, whether it
ran the restart or arrived behind it.

The resume reads its record only after this host has reconciled it and exited
any recovery stage a failed attempt latched, so a hold behind a failed attempt
makes its own attempt against the lease as it now stands.

* fix(native-chat): a Claude start proving itself no longer waits on the CLI

The host handles a Claude child's `started` on the recovery chain every
session's unexpected-exit handling shares, under that session's serialized
step. It then asked the CLI for the model list and settings again, so one slow
CLI held every other session's exit recovery, and its own close, behind up to
two request timeouts.

The adapter already holds those answers when startup proves: the settings read
at startup, the restore's confirmations, and the initialize result the SDK
answers the model list from. `started` now carries that snapshot, and the host
turns it into one record write without any provider I/O.

* test(native-chat): a hold reads its lease only after this host has reconciled it and exited a latched recovery stage

* fix(claude): a chat whose first start failed resumes as the same conversation

A Claude start that dies before initialize writes no transcript, so the next
start launches the chain head's provider id fresh instead of `--resume`. The
launch flag that chose that mode also chose the provider-handle link's origin,
so the fresh launch published a second `created` link onto a chain that
already had a head. The store refused it, the healthy child was closed, and
every later reopen, hold or send spawned and killed another Claude.

The launch now carries the two facts separately: `resumesTranscript` (launch
mode, from whether Claude wrote a transcript) and `continuesChain` (lineage,
from the record's chain head). The link origin reads lineage; rewind and the
Fast opt-in carry-over read launch mode.

* refactor(native-chat): a resume answers with a typed refusal the send classifies

`resumeHeldStructuredAgentSession` and the holds' `ensureProviderChild` answer
`{ ok: true } | { ok: false, refusal }` instead of throwing the refusal code.
The refusal is the attach's own, with its message and, when the failed attach
proved its child gone, its `ownerVerdict`. An attach that settles a failed
acquisition in the ledger and then rethrows the cause is read back off that row,
so a durably failed restart is a refusal and only an unrecorded error is a fault.

The send classifies the refusal through a `Record` over every wire code — a new
code does not compile until it is placed — into transient (the lease is someone
else's to settle; the send runs as the lease stands) or terminal. A terminal
one answers `agent_session_owner_unrecoverable` carrying the cause, forwards the
verdict, and writes the same status row into the chat that a start that failed
leaves, so the user sees why after the error strip is gone. Nothing about the
failure is remembered; a Retry is a fresh attempt. A fault thrown by the restart
itself is reported and the send runs as the lease stands, since bookkeeping
never gates a user's action.

`hold()` still raises the refusal code for its RPC caller.

* fix(native-chat): a child's event sink belongs to the attach attempt that spawned it

The runtime kept one event sink per session id and handed it to every attach.
An attach that acquired a new child unbound that sink first, so when the
acquire then failed its dead child's queued frames stayed in the cached,
unbound sink. The earlier guard only discarded it when no session entry was
left, which a resume of a still-indexed session never satisfies: the next
attach's drain and shutdown's flush waited on it forever. A TUI-to-native
handoff acquire had the same shape.

Each acquiring attempt now mints its own sink. Only a successful attach (or a
proven handoff owner) adopts it as the session's, closing the one it
replaces; any other exit closes it with whatever its child queued. A re-attach
to a live child keeps the sink that child already writes through. A sink that
is not the session's own can no longer force the session's provider down.

The native handoff acquisition moves to its own module, which keeps the
handoff file under its line budget.

* test(native-chat): pin that only the adopted child's event sink still takes writes

Closing a failed attempt's sink and closing the sink a resume replaces were both
unpinned: removing either left every suite green, because neither sink is in the
map that drains and flushes read. The resume test now asserts the failed
attempt's sink and the exited generation's sink refuse writes, and the adopted
one accepts them; deleting either close reddens its own assertion.

* perf(native-chat): the chat reads only the host's startup phase from the status feed

The chat took the whole status summary to read one field, so every status change
for its session (prompt, update time, background tasks) re-rendered the chat
view. It now subscribes with the phase itself as the snapshot, so it re-renders
only when the phase changes.

* fix(native-chat): the startup-phase hook answers a phase or null, never undefined

* fix(native-chat): every restart is counted from the moment it is asked for, and a handoff clears the restart fence

Provider-exit recovery now restarts through the holds' `ensureProviderChild`
like a hold and a send do, so a child whose only surface left while the attach
ran goes on the idle clock instead of living until quit. A hold's resume and a
client attach are tracked as in flight from enqueue, not from their turn on the
queue, so a quit's drain waits for one queued behind a close before it decides
what to evict. A handoff back to native moves the fence in place and now clears
`resumedFromFence`: only a restart may rebase a writer. The failed-restart
status row is keyed by the send's operation id, not the clock, so a resend of
the same id that fails again adds no second row.

* fix(native-chat): a Claude start no longer waits behind another session's exit recovery

The runtime delivered every Claude lifecycle event on the single chain
exit recovery uses so teardown can drain it. That chain orders nothing
across sessions, and an exit recovery on it can run a full reacquisition,
so one chat's `started` waited on an unrelated chat's respawn and kept
its 'still starting' line up. `started` now takes only its own session's
serialized step, is queued the moment it is emitted (ahead of any later
exit of that child), and is tracked in a set the same teardown drain waits
on.

* fix(native-chat): a Claude create that dies at spawn is refused with the CLI's own diagnostic

A CLI that exited before its acquisition handed the child over was
refused with 'claude stream-json for session … exited while being
acquired', or with an unreadable start time, and the stderr the exit
carried (for example 'not signed in') appeared nowhere. The acquisition
now keeps the error its connection ended with and answers with it at
both sites; the generic message is only a fallback when none exists.

* test(native-chat): pin that a reopened Claude chat dying before initialize says why

A resumed start is published at spawn, so a CLI that exits before it
answers initialize fails a chat the user is looking at. Pin that the
open chat is sent the 'stopped before it finished starting' row with the
CLI's diagnostic even when the child's tree cannot be proven gone, and
that a message held for that start is refused rather than left in doubt.

* fix(native-chat): Stop while a Claude start drains its held prompts withdraws the rest

Stop withdrew held prompts only while startup was pending. Once startup
landed and the gate began writing them one by one, a Stop interrupted the
CLI and the prompts still waiting were written straight after it. Stop
now withdraws whatever the gate still holds in both states; the drain
takes each prompt off the queue immediately before writing it, so a
withdrawn prompt can never be written. The one already written still
gets the interrupt.

* fix(native-chat): the release clock keeps a session that still owes a sent message

When the last surface stops holding a session, the release clock evicts
it after the grace unless a turn is running. A message sent while Claude
is still starting is held, not running, so switching away from that chat
for the grace evicted the session and refused a message the user had
already sent. The clock now asks whether the session owes work: a running
turn, or a submission the provider has not taken yet (still pending in
the journal). Both are read from the journal; nothing new is stored. A
starting session that owes nothing is still released, and an explicit
close still ends everything.

* fix(native-chat): only a start that holds a sent message keeps a released session

The release clock kept any session with a pending submission. A Codex
send is admitted and stays pending until its echo, which may never come,
and only an eviction retires it, so such a session was never released
while the app ran. A pending send now keeps the session only while its
child is still starting, which is when the send is held for that start.
Pins that a ready session with an unechoed send is evicted, and that a
Claude chat whose turn finished is released after the grace.

* fix(native-chat): a send waits for the owner it met to prove its start before it is admitted

A Claude child is published before the CLI has answered initialize, so a send admitted right
behind a restart — or right behind the first start — was dispatched into a child that could die
milliseconds later, and learned of the death only as a delivery nobody could confirm. The terminal
refusal the send was written to give was unreachable on the real adapter for exactly the failure
it was written for.

The send's serialized step now admits nothing against a `starting` child. It registers for the
child's startup verdict and returns having placed nothing; the send waits off the session's queue
(the `started` and `ended` settlements run on it) and admits again once the child is `ready`, or is
refused `agent_session_owner_unrecoverable` with the child's own exit reason when it exits first.
The exit settlement writes the one status row, decided by the host's own phase rather than only
the provider's flag. A close, an eviction or a replacement answers the wait too, and quit releases
whatever is left; there is no timer. One spawn per user action holds across re-entries.

* fix(native-chat): restart the release grace when a start writes its held prompts

A prompt held while Claude starts is written when the start lands, but
its turn opens only when Claude echoes it. The release clock stopped
counting it once the child read ready, so a tick landing in that gap
stopped the child before it ran the user's first message. The start
landing now restarts a pending release's full grace, the same grace a
message sent to a ready chat gets before it is released.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): a starting child owns the send; the adapter holds the message for its start

A send that meets a child still proving its start is admitted against it, as it was before the
off-queue startup wait: the adapter holds the message until startup lands and rejects it with the
child's own diagnostic when the child dies first, the exit settlement writes that cause into the
chat, and the release clock keeps a starting session that holds a sent message. The startup watch,
the off-queue wait loop and their teardown phase are gone; the exit settlement still reads a start
that failed off the host's own phase when the provider omits the flag.

The scripted-CLI test now pins that contract end to end: a restart a send asked for whose CLI dies
at initialize leaves the message rejected with the diagnostic, one row naming it, and the fence
moved by two; a healthy CLI is restarted once and written to; a send during the first start is
held and written once initialize answers, or rejected with the diagnostic when the CLI dies.

* fix(native-chat): a failed send restart says why, and offers a new chat only when nothing can restart it

The refusal a send gets when the host cannot restart the chat's agent is renamed
agent_session_owner_restart_failed and now reads "<Agent> couldn't restart: <reason>." with the
restart's own cause. "Start a new chat to continue." is added only when the resume was refused
because this host has no record to restart from or cannot run the one it has. Any other failure,
such as a CLI that is not signed in, leaves the chat retryable: the outbox stops auto-retrying, and
a manual Retry or a new send tries the restart again, since a refusal before admission leaves no
ledger row.

* fix(native-chat): a Claude start skips an option write the CLI never answers instead of faulting at the request deadline

* test(native-chat): wait for the recovery's reserved lease, not the released one it replaces at once

* fix(native-chat): a send whose restarted child dies before starting is rejected with the child's diagnostic

A child that never proved its start has accepted nothing: input is written
only after it initializes. A send admitted against such a child, whose
dispatch then found no session, settled unknown, twice, and the outbox took
Retry away. It now settles rejected with the child's own diagnostic, both
when the dispatch throws and when the exit settles the sends it left
unanswered, so the chat says why and offers Retry. A proven child's
unanswered sends stay in doubt, as before.

* test(native-chat): expect a send held for a start that never proved itself to settle rejected

* fix(claude): write a prompt held after startup already drained, instead of stranding it pending

* test(native-chat): pin that a send to a child that died before starting is answered rejected

* test(native-chat): leave the cast exit-session fixtures as they were, since a proven exit never rejects

* fix(claude): a saved option the CLI never answered stays saved instead of being replaced by the CLI's value

A start skips an option write the CLI does not answer within the request
deadline, and then persisted what the CLI reported in its place, so a slow
answer silently replaced the user's saved model or dropped their saved
permission mode. Silence is not a refusal: the unanswered option is now
recorded apart from a rejected one, the live child keeps running on the
CLI's value, and the saved choice stays on the record for the next start to
retry. An option the CLI rejects is still dropped as before.

* fix(native-chat): a rejected send opens no turn, so the row naming why it failed is not folded away

A send whose restarted child died before starting is rejected, and the
exit writes a row naming the cause. The chat's local clock had watched the
send go pending and stop, so it gave the message "Worked for 0s"; that
settled a turn that never ran, and the fold hid every non-prose row after
the message behind it, including the one naming the cause. The row only
appeared when a later send moved the turn anchor, which read as two rows
for one Retry. The host's journal already says the send was rejected; it
now answers that such a message opened no turn, which outranks the local
clock on desktop and mobile alike. A rejected send whose journal does
record a turn keeps its duration.

* fix(native-chat): a send whose restart died starting leaves the same row as any start that died

One failed attempt already leaves one row, but which row depended on when
the child died. A child that died after the send was admitted left "The
provider stopped before it finished starting: <cause>."; one that died
before the send was admitted left "Claude couldn't restart: <cause>." So the
same failure read two ways from one Retry to the next. When the refused
restart proved its child exited, the send now writes the startup-failure row
itself, as its comment always said it did. The refusal under the composer
still says the restart failed; a restart that failed for a reason other than
a child exiting keeps its own wording.

* fix(native-chat): a send rejected because the agent never started names the cause under the composer

When the child a send was admitted against died before starting, the host
rejected the send with the child's diagnostic behind the internal transport
marker. The client rightly hides that marker's detail, so the red line read
"Couldn't reach the agent" while the cause sat in the record. A startup
death is not a failed write: the host now words that rejection the way the
chat row does, "The provider stopped before it finished starting: <cause>.",
at every site that rejects for it. Desktop and mobile show a reason in words
verbatim already, and older clients do too, so no client change is needed.
Real write failures keep the marker and the generic copy.

* fix(claude): a saved option the CLI never answered survives a later change to a different option

The saved choice a start could not apply was kept on the record, but the next
option the user set persisted only what the child had applied, so changing the
permission mode or effort, or clearing the chat, silently dropped the saved
model. The adapter now reports which saved options are still unanswered, every
option write keeps those saved values, and a write the child accepts for that
option retires it.

* fix(claude): a send that meets a child whose exit already settled names that exit's cause

When the child a send was admitted against died starting and its exit
finished settling before the send reached it, the send was rejected with
"no live claude stream-json session for <id>", now shown under the composer
as the cause. The adapter keeps a settled exit's diagnostic until the chat is
acquired or closed again, so that send names what the CLI said. A refused
restart whose child died at spawn or while its start time was read is pinned
to leave one row in the words any failed start uses.

* test(native-chat): pin the words an exit settlement rejects a never-started send with

The startup gate and the dispatch reject a send first in every existing
scenario, so the exit settlement's own rejection had no test of its wording.

* fix(claude): derive which saved options are still unanswered from what the child applied

A write that lands already puts its option in the session's applied set, so
the unanswered list is that list minus what has since been applied, rather
than a second copy every option write must remember to edit. Session
fixtures built without the new set no longer throw on an ordinary write.

* fix(native-chat): a cleared chat starts from a saved choice the child never answered

Clearing a chat seeded the replacement from the values the child reported,
so a saved model or effort whose restore write the CLI never answered was
replaced by the CLI's own value in the new chat, even though the retired
record kept it. The replacement now keeps those saved values too, and its
start retries them.

* fix(claude): closing a chat forgets its exit's diagnostic even when the exit settles during the close

The diagnostic was dropped when the close began, but closing over an exit
that was still settling finishes that settlement, which kept it again, so a
closed or deleted chat held it until its next acquire. It is now dropped once
the close finishes. Pins that an acquire and a close each retire it.

* refactor(claude): keep a saved option the CLI never answered as the wanted value, not a list beside it

A restore cleared the session's wanted options and added back only the writes
the CLI answered, so an unanswered one lost the user's value and every later
writer had to be told to put it back: the start report, each option change and
/clear each carried a list of unanswered keys. The restore now keeps the saved
value as wanted and unconfirmed, so what the session reports and persists
already carries it, and the list, its adapter method and the started-event
field are gone. A refused option is still dropped.

/clear now starts the replacement from the record's options instead of reading
the child's live values, which can be a model the CLI fell back to.

* test(claude): wait for the start to finish before changing the saved model

The record holds the saved model from creation, so waiting for it returned at
once and the option write could reach Claude while it was still starting,
which refuses it. Wait for the effort the finished start reports instead.

* fix(i18n): translate the still-starting chat notice

The notice that a structured chat is still starting was only in English.

* test(claude): pin the failed acquisition's own reading-control release

The merge re-pointed this test at a child that exits after publish, where the
exit path also releases the binding, so it passed with the acquisition's release
deleted. A child that exits before publish leaves only that release. Also drops
the create 'init' phase, which lost its last producer when rewind stopped
proving before publish.

* refactor(claude): move unexpected-exit handling into the exit lifecycle module

The adapter crossed the 300-line limit once main's context-usage change
landed beside this branch's growth. The two methods that turn a Claude
process exit into an ended event now live next to the existing exit
helpers; behavior is unchanged.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 15:23:15 -07:00
Brennan Benson f0b3f44f10 feat(agent-session): let the host own a chat's tab id and let a create reserve it (#22616)
* feat(agent-session): let the host own a chat's tab id and let a create reserve it

A structured chat's tab id was derived from its session id by every layer that
needed one: the renderer, the host snapshot and the status address each built
their own spelling. The join between a conversation and the tab that shows it
must be a pointer the host owns, not a derivation each client repeats.

The session record now carries surfaceTabId. A create pins it: the tab half of
the pane agent.launch reserved, an optional tabId on agentSession.create, or a
host-minted UUID. Records written before the field existed are backfilled at
open with the string clients derived, in memory at once and on disk with the
store's first transaction, so nothing keyed by it (read state, notification
ids, worker rows) moves on upgrade. A second record under a held id is refused.

Only the record and the two create wires change here. The snapshot still
publishes agent-session:<sid> and the renderer still derives its local id;
those move in the next two changes. agentSession.create is a strict object, so
the field is advertised as a capability a client checks before sending it.

* fix(agent-session): record the derived tab id for an unreserved create

A create that reserved no tab minted a random UUID that no reader uses: the
renderer, status address, worker rows and host-shared read state all still key
by structured-agent-session-<sid>. Persisted, that id would move every chat
created before readers switch to the recorded one, orphaning its read state
and worker rows the way the backfill exists to prevent. An unreserved create
now records the derived id, the same rule the backfill applies, so the record
always matches the prefix every existing key uses; an opaque mint belongs with
the change that moves the last reader.

Also:
- a chat tab id must be a host tab id on the record, the create wire and in
  admission, matching what agent.launch already requires of paneKey; a
  web-surface id would decode as another tab
- the stored launch-result guard checks the structured outcome's tabId
- comments no longer claim a retry naming another tab conflicts; replay keys
  on the attach fingerprint and answers with the recorded id (now pinned)
- the wire refusal test used a non-hex digest, so the schema refused it for
  that reason; it now reaches the tab id rule
- pin that the reload path refills the id without forcing a save

* test(agent-session): correct the tab-id fingerprint comment to match replay
2026-09-24 14:42:12 -07:00
Brennan Benson 8f18930c48 feat(native-chat): add a hover copy button to sent messages (#22721) 2026-09-24 14:35:06 -07:00
Jinwoo Hong 6cda65fe5a fix(browser): import empty-value Chromium cookies instead of their domain hash (#22719)
Chromium cookie DB schema 24+ prefixes every decrypted value with
SHA-256(host_key). The strip heuristic required more than 32 bytes, so an
empty-value cookie (plaintext = the hash alone) kept the 32 hash bytes as its
value. Chromium rejected it on load ("Sanitizing cookie failed"), it was staged
for restart, and it was dropped from the jar after restart, after the import
had already cleared the matching existing cookie.

Strip when the prefix equals SHA-256(host_key) exactly, falling back to the
existing heuristic so browsers with any other prefix behave as before.
2026-09-24 16:45:24 -04:00
Brennan Benson 98584332a3 fix(native-chat): record which Codex agent produced each journal row (#22532)
* fix(journal): a batch revision restates the producer of each row it revises

The reducer rebuilds a row's producer linkage from its NEWEST revision, and
absence is a positive claim: no agent id means the session's own agent wrote
the row. So any revision written without the stamp hands a subagent's row
back to its parent, permanently.

Three host paths revise rows they did not write, from the render item they
already hold, and all three dropped the stamp:
- answering a prompt re-appended the asker's row with the fence only;
- dead-generation settlement failed running tool calls and cancelled pending
  prompts through a lifecycle batch;
- stale-session settlement on acquire cancelled lost prompts the same way.

The batch path could not carry a producer at all: linkage was removed from
the batch row because one row covers N mutations, with a note that a mixed
batch would have to stamp per mutation. Dead-generation settlement is such a
batch already, and Codex settlement is about to become one. So each item
mutation now names its own producer, inline like the row base. A mutation
that names none falls back to the row's linkage, which is what a batch read
before. Parse sanitizes a bad per-mutation id the same way it does a row's:
the field is dropped and the mutation kept.

No schema version bump. An older host's mutation validator ignores unknown
keys, so it reads a stamped mutation as the session's own, which is exactly
what it shows today. Old journals carry no stamp and read as before.

Turn revisions still carry nothing: a turn is the session's unit of work,
and the live-turn scans rely on a turn row never carrying linkage. The note
recording that invariant is updated to the new write sites.

* fix(native-chat): attribute a Codex subagent's journal rows to the subagent

Codex journals every thread on its app-server connection into the session's
journal, and a spawned subagent's items arrive on the child's own thread.
None of those rows carried producer linkage, so under the journal's rule that
absence means the session's own agent wrote a row, every child's command,
message, reasoning, prompt and status row read as the PARENT's: the parent
could show its child's running command, its child's reasoning as "thinking",
and its child's prose as its own latest line.

The Claude lane's model is reused, not reinvented: the same fields and the
same absence rule. What differs is how the producer is known. Orca opens
exactly one thread per app-server, so any other thread is one Codex spawned.
That decides WHETHER a row is a child's from its first frame, announced or
not, and the thread id is final at once: it is never re-minted the way a
tool-call reference is, so no correction ledger is needed for identity.

- agentId: the child thread id, the same id the status side keys a Codex
  child on.
- parentAgentId: the thread whose stream carried the child's `started`
  activity. Codex emits that item on the spawning agent's own session, so a
  child that spawned a grandchild is named; the session's own thread is not.
  Other activity kinds ride whichever agent acted and are not used.
- producerKind: 'agent'.
- attempt: which run of the child the row's own turn was, counted from the
  child turns the roster already observes; absent on the first run. Taken
  from the row's turn rather than the child's latest, so a persistent shell
  that outlives its turn keeps its run across revisions.
- providerParentRef is omitted: a Codex child's frames carry no parent
  reference of their own beyond the thread id, which is already agentId.

One resolver, owned by the roster (which already owns what is known about
each child thread), is handed to every writer: items, streams, generic and
summary rows, prompts, compactions, goals, and the three settlement batches.
The session-end settlement mixes every thread's rows in one batch, so each
mutation names its own producer. Turn rows stay unstamped: Codex writes them
only for the primary thread.

The spawn-group roster row stays unstamped on purpose: a child's frame can
trigger its write, but it is the parent's list of its children.

The translator's construction moves to a parts module so the translator
stays a router under the line cap, and the item streams reuse one
append-and-publish helper instead of two copies. Children are never swept
at turn end; nothing here changes that.

* test(native-chat): pin Codex subagent attribution at every writer and every parent reader

Two layers, so a stamp that is correct in the store and never read, or read
and never persisted, cannot pass.

The readers, through the real path: translator, deferred sink, on-disk
journal, snapshot. Each is a defect on main: the parent named its child's
running command as its own tool, read its child's reasoning as itself
thinking, showed its child's compaction as its activity line, and quoted its
child's prose as its latest line (checked after closing and reopening the
journal, so the stamp is read back from disk). The transcript still renders
the child's rows.

The writers, through a sink that records the linkage of every plain append,
batch mutation and lifecycle transition: start, streamed checkpoint and
completion of one command all restate the child; a row that beats the spawn
announcement is still the child's; a grandchild names the child that
announced it, while an `interacted` activity names no parent; a follow-up
turn is the child's second run, and a shell that outlives its turn keeps its
own; the exit batch settles each thread's rows under its own producer and
the turn row under none; a child's provider frames, approval and goal rows
are its own; nothing is stamped while the session thread is still opening;
and the spawn-group row stays the parent's.

* test(native-chat): pin linkage forwarding on the sink's lifecycle-transition path

A Codex child's goal row is written through a lifecycle transition, so a sink
that forwarded only the fence there would file the child's goal as the
session's own.

* test(native-chat): type the Codex item fixtures as thread items

* refactor(journal): keep a row's producer across revisions that name none

The reducer took a row's producer linkage from its newest revision, so every
writer that revised a row it did not write - a prompt answer, a dead-generation
or stale-session settlement, the reopen sweep of stale subagent rosters - had to
restate the producer or silently hand a subagent's row to the session's own
agent. Three of those writers had been patched to restate it; the next one to
forget would reintroduce the bug.

Attribution is now fixed by a row's first write. A revision that names no
producer keeps the row's existing linkage; one that names any replaces the
whole bundle, which is how a provisional stamp is still corrected in place. A
row re-created after a tombstone starts with nothing. The reducer runs the same
fold on replay, so the kept producer survives a reopen.

The three restatements are removed. Per-mutation linkage on lifecycle batches
stays: a batch can create a row (a Codex child's prompt, or a child's item
settled before any checkpoint landed) and one batch can mix producers.

* test(journal): pin producer inheritance in the reducer and across a reopen

A revision naming no producer keeps the row's, on the plain item path and in
a batch settling a child's row beside the session's own; one naming any
replaces the bundle wholesale; a tombstone clears it; a stale revision cannot
touch it; and a reopened journal replays it exactly as it was folded live.

* refactor(codex): name the translator's writer factory for what it builds

* docs(codex): say why a settled row names its producer

* test(journal): drop a producer test the stale-revision guards make unreachable

The stale revision is dropped whole by two independent guards before the
inheritance rule runs, so its producer assertion could never fail; the
reducer's own stale-revision tests already cover the drop. Also say what
the batch sink does forward: each mutation's own producer.
2026-09-24 13:33:56 -07:00
Jinwoo Hong 5610b11703 feat(ipynb): create a .venv when pip is locked out, and show ipykernel setup progress (#22710)
* feat(ipynb): set up ipykernel in a new .venv when pip is locked out, and show install progress in the dialog

* fix(ipynb): drop the retired installFailed string from translated catalogs

* refactor(ipynb): drive the setup dialog from one setup state; fix review findings

- Kernel status now only describes the kernel; a single `setup` object (base, offer, phase, error) drives the dialog, replacing the extra statuses and the externallyManaged/setupError fields.
- The picker's 'Create virtual environment…' opens the same dialog; success switches through selectEnvironment, so a running kernel is only replaced once the venv exists.
- Async setup results are dropped when the dialog they belong to is gone (tab closed/reopened).
- main verifies ipykernel imports after pip, reuses an existing .venv instead of re-running venv over it, and explains failures that printed nothing.
- Windows copy command guards the install with if ($?); notebooks at a filesystem root get a correct .venv parent; 'Try again' shows for both retry paths.

* fix(ipynb): close the setup prompt when another Python is picked
2026-09-24 16:22:34 -04:00
Brennan Benson 1985c1b11e fix(native-chat): load older messages automatically before the reader reaches the top (#22541)
* fix(native-chat): load older messages automatically before the reader reaches the top

The transcript paged older history only on an upward scroll within 80px of
the top, and only when the visible row count had changed since the last
request, so readers hit a "Load earlier messages" button and waited. A page
that landed only hidden rows left the count unchanged and stalled paging.

A sentinel above the first row is now observed by an IntersectionObserver
rooted at the transcript scroller with a 600px top margin. The observer is
recreated each time a page settles, so it keeps paging while the sentinel
stays in range and stops once a prepend pushes it out or history runs out.
The virtualizer's existing prepend anchoring keeps the reader's row in place.

Both lanes' load calls now reject when a page does not land (error result,
rejected read, host reset, exhausted re-anchoring). A rejection stops
automatic paging and shows the manual button; a successful retry resumes it.
While automatic paging works, the top shows only a polite status line.

* fix(native-chat): keep the reader in place when the last older page lands

The older-history row sat in the transcript column above the window, so the
final page, which takes that row away, removed its height and the column gap
from above the window and every row jumped up by that much.

The row now sits out of flow, absolutely positioned inside the scroller's
existing top padding, so its presence never changes the content height or
the window's scroll margin. The sentinel keeps the same place relative to the
top of the transcript, so prefetch is unaffected, and the button stays in the
padding band above the first row.

* fix(native-chat): keep auto-loading older history when a page is abandoned mid-flight

A reconnect snapshot, replacement, or hide ends the lane's loading while its
read is still outstanding. The auto-load latch stayed held until that read
settled, so the recreated observer's report was dropped and paging stalled
with no button. Release the latch once the lane reports loading, and have the
transcript lane resolve rather than reject when a superseded read fails, so a
replaced connection cannot switch the current transcript to the retry button.

* fix(native-chat): let each chat lane alone own the in-flight older page

The older-history autoload kept its own "page in flight" latch beside each
lane's guard. It could not tell a request the lane had not yet reported from one
the lane abandoned before rendering it (a reconnect snapshot in the same batch),
so a read that never settled could stall paging with no retry button.

The transcript lane now dedupes synchronously on the epoch its page is in flight
for, so an epoch bump abandons it with no extra reset path; the structured owner
already dedupes synchronously. The autoload hook drops its latch and the effect
that cleared it.

* test(native-chat): pin the structured page result and paging generation reaching the list

The structured session test still fed a rejection into loadEarlier, a contract
neither lane has any more. Assert the resolved page result and the paging
generation the list needs to stop and re-arm auto-loading.
2026-09-24 12:11:32 -07:00
Jinjing 59d8275996 fix(i18n): align ko artifacts search keyword with value override (#22711) 2026-09-24 12:06:51 -07:00
Jinwoo Hong c4e58fac56 fix(mobile): restart the streamed browser pane on every return to the app (#22694)
* fix(mobile): restart the streamed browser pane on every return to the app

The browser pane stops its screencast when the app leaves the foreground and
starts a new one when it comes back, keyed on an `appActive` boolean. When the
leave and the return are handled in one React render (the JS thread was held
across the whole trip, as a suspended or frozen app is), React applies
false-then-true as no change, so the stream effect never re-runs: the old
subscription is kept and no new one starts. If the desktop ended that
subscription while the phone was away (it evicts a viewer whose socket
refuses 90 frames in a row), the pane shows the last frame it had
indefinitely, with no error and nothing that would restart it.

The pane now keeps `foregroundVisit`: null while the app is away and a new id
on each return. A batched leave-and-return still moves it to a new value, so
every return starts a fresh stream, and the host's start snapshot repaints the
pane.

* refactor(mobile): drop a busy reset both stream-effect branches overwrite
2026-09-24 14:47:12 -04:00
github-actions[bot] 2c2414be57 Update README downloads badge 2026-09-24 18:32:19 +00:00
Jinwoo Hong 9f7f406b57 test(mobile): repin the RPC recording corpus and session closure after #22392 (#22702)
* test(mobile): repin the RPC recording corpus to main after #22392

#22392 pinned baseline to a branch commit (2c2b84ce9e) that the squash left
unreachable, so the recording-pin ancestry job failed on main and every PR.
Repin to main's tip 80e0bee23b and re-record the whole corpus: all 787
goldens change only their baseline header, so no recorded behaviour moved.

* test(mobile): re-measure the session route closure on main after #22392

Main reads 4219, not 4217: #22301 added two src/shared modules to the route
without touching mobile/, so main was already two over when #22392 measured
its -2 against a branch base that lacked them.
2026-09-24 14:30:59 -04:00
Jinjing 5613c4fe71 fix(i18n): add missing translations for artifacts and browsing (#22697)
Adds translations for artifact publishing, remote browser features, SSH workspace routing, browser identity settings, and skills management across all supported languages (Spanish, French, Japanese, Korean, Chinese).
2026-09-24 11:18:34 -07:00
Brennan Benson 80e0bee23b fix(floating-workspace): keep agent launches from moving the main window's tab (#22603)
* fix(floating-workspace): keep agent launches from moving the main window's tab

Launching an agent from the floating workspace's "+" menu switched the main
window off whatever chat or editor tab it was showing and onto its terminals.

The main window's selection is supposed to move only for the worktree it is
showing: browser and editor tab creation, splits, moves and drops all check
`activeWorktreeId === worktreeId` before touching it. Two places did not:

- `launchAgentInNewTab` called `setActiveTabType('terminal')` without a
  worktree, which targets the active worktree whatever worktree the launch
  landed in.
- terminal `createTab` wrote the global `activeTabId` for a tab in any
  worktree.

Both now follow the store rule. The launch still selects its tab within its
own worktree, which is what the floating panel renders.

The floating titlebar button had side-stepped this with an `activate: false`
opt-out plus manual selection. That opt-out had no other caller and is removed;
the button now launches and focuses like every other entry point.

* fix(tabs): scope the remaining launch surface writes to the launch's worktree

Three more launch paths create a terminal tab and then call
`setActiveTabType('terminal')` without a worktree, which targets whatever
worktree is active when the call runs rather than the one the tab landed in:

- the paired-host agent launch, after the host's asynchronous create
- Session History resume, which can target a worktree the user is not viewing
  and activates it only afterwards
- sleeping-agent resume, which the activation gate runs after asynchronous
  readiness checks, by which time the user may have moved to another worktree

Each now names its worktree, like the local agent launch. The new tab still
lands selected when the user switches to that worktree.

* test(tabs): pin the paired-host launch scope in its existing web-runtime test

* test(tabs): type the left-worktree resume fixture instead of casting it

* refactor(tabs): require the worktree that setActiveTabType applies to

`setActiveTabType(type, worktreeId?)` quietly fell back to the active
worktree when the caller left the worktree out. A caller acting on a tab in
another worktree (the floating workspace, a background launch, a reveal that
lands after an async step) therefore retyped whatever the main window was
showing. The launch paths fixed earlier in this branch were instances of that;
54 other callers still relied on the fallback.

The worktree is now a required argument (nullable only for the no-active-
worktree case), so every caller states which worktree it means and a new
unscoped call fails to compile. Each call site passes the worktree of the tab
it acts on; where that is by construction the active worktree (shortcuts,
palette, tab strip), the result is unchanged. `activateTabAndFocusPane`
resolves the tab's owning worktree the same way `setActiveTab` does.

End-to-end helpers that drive the store directly pass the active worktree,
which keeps their previous behaviour.

* fix(floating-workspace): let the floating New Terminal activate its own tab

The floating "+" New Terminal created its tab with `activate: false` and then
selected it with `activateTab`, because creating an active tab used to write
the main window's selected tab even for another worktree. `createTab` now
activates a tab only within its own worktree's group unless that worktree is
the one on screen, so the workaround is no longer needed.

Creating the tab active also moves the floating workspace's remembered tab to
the new one; before, it stayed on the previously selected floating tab, which
auto-acknowledge reads to decide which floating agent the user is looking at.

* refactor(floating-workspace): route every floating New Terminal through one creator

The floating "+" New Terminal had stopped deferring activation, but Cmd+T with the
floating panel focused still went through a separate creator that created the tab
inactive and activated it by hand, which left the floating workspace's remembered tab
on the previous tab. Both now call createFloatingWorkspaceTerminalTab, which creates
the tab active in its own group and focuses it.

* docs(tabs): say why an unowned tab id keeps the on-screen worktree scope
2026-09-24 10:54:43 -07:00
Jinwoo Hong 060743d813 fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps (#22392)
* fix(mobile): keep the streamed browser pane flipping on slow phones, and stop double taps

Frame pacing. The pane decodes each frame on a hidden layer and flips to it on
onLoad. While one frame decoded, every newer frame re-pointed that same hidden
layer, which cancels the in-flight load. On a phone that decodes a frame
slower than frames arrive (~10/s during page loads, menus, spinners), onLoad
never fired for any of them and the pane sat on an old frame until the page
went still. A decoding layer is now never re-pointed: only the newest frame is
held, and it takes the layer once the decode settles. A 1.5s watchdog frees a
layer whose decode never reports, and a frame the hidden layer already holds
(a blinking caret alternating two frames) flips at once, since an unchanged
source reloads nothing.

Double taps. When browser.mouseClick failed, the pane replayed the tap as
move/down/up. On a timeout the click is still queued on the host, so the
replay landed a second tap on whatever the first one opened. The replay now
runs only when the click definitely did not reach the host.

* test(mobile): re-record the corpus without the timed-out tap replay

The corpus certified the move/down/up replay after a transport-rejected
browser.mouseClick, which the commit before removes. Scoped like #22179:
baseline bumped by editing that one line, then --record.

788 files. Every changed line classified:
- `baseline`: 787 files (786 goldens + pilot-scenarios.json), nothing else.
- matrix-browser.pointer-click-browser.mouseclick-1.json: the
  transport-rejection and transport-rejection-no-message partitions of
  browser-pointer-click-fallback now send only browser.mouseClick#1. The
  refused partitions still replay, unchanged.

* test(mobile): move the session closure pin past #22452's main-agent-status modules

#22452 changed only src/shared, so its CI never ran the page-closure suite; main
now measures 4220 modules (1034 local) against a pin of 4218. This branch adds
nothing to the closure: its own count matches main's.

* docs(mobile): say a re-pointed decoding layer loses its onLoad, as measured on Android

* refactor(mobile): give the streamed browser pane's double buffer one owner

The frame pacing, decode watchdog, layer flip and reset were spread over three
hooks and a helper module, wired back through the stream hook and the pane.
They now live in one plain pacer (browser-frame-pacer.ts) with one timer, and
the pane binds each layer's View/Image straight to it.

Behaviour fixed on the way, each with a failing test first:
- A slow last frame with nothing newer queued was abandoned by the watchdog and
  never shown. The decode deadline now only applies when a newer frame waits.
- Any pane re-render re-pointed both layers at the newest frame behind the
  pacer's back, so a blinking caret froze. The Image source prop is now only
  the mount-time frame; every later source write is the pacer's.
- A frame that failed to decode left its layer marked as holding it, so an
  identical frame flipped to an undecoded layer. Giving up on a decode now
  clears the layer's source.
- A native onLoad for a source the layer has since moved off could flip early.
  The flip now checks nativeEvent.source.uri, which Android and iOS Fabric both
  report as the raw source string; RN Web's own load event has none, so the web
  flips only through its decode probe.

The session closure pin drops by the two modules this removes.

* refactor(mobile): send the tap's mouseClick directly instead of through a flag

The delivery-unknown check was a mutable flag set inside the request callback.
The click now calls browser.mouseClick itself in a try/catch: a delivered click
returns, a delivery-unknown failure returns without replaying, and a refusal or
null result still replays as move/down/up. Same wire traffic; the corpus
certifies it unchanged.

* fix(mobile): never cut a streamed frame's decode short

The 1.5 s decode watchdog abandoned a slow decode whenever a newer frame was
queued and re-pointed its layer. On an Android emulator under load that is the
original freeze again: noise frames decode in 2-10 s, every abandon starts a
decode the next abandon cuts, Fresco reports the superseded loads (30 stale
onLoads in one run) and the pane showed 11 of 41 applied frames. Without it,
the same run flips every applied frame (16/16, no stale load), and a slow last
frame is shown in every cycle.

Nothing else needs it: with the layer never re-pointed mid-decode, native
answers every load with onLoad or onError, and the web probe's decode()
always settles. The pacer keeps one timer, for the interval.

* fix(mobile): track what each frame layer's Image holds, so no write goes unanswered

The pacer cleared a layer's source when it gave up on a decode (a reset
mid-decode, or a failed decode) while the native Image still held it. The
next identical frame was then written again, which is a native no-op on
Android (ReactImageView.setSource returns on equal sources) and iOS
(ImageShadowNode skips equal requests): no onLoad, no onError, and the pane
stayed frozen until the stream restarted. Returning from the background to
an unchanged page is enough to trigger it.

Each layer now records the source its Image holds and whether that source
has answered (loading, ready, failed). A layer is written only when it is
not loading and only with a different source, so every write gets exactly
one answer. A reset no longer abandons anything; the load under way still
answers for its layer. A frame the hidden layer already holds flips at once
if it decoded and is skipped if it failed.

The pane test's native model now treats a same-source write as a no-op and
answers each change once with the layer's current source; both new cases
(reset mid-decode then the same frame, failed decode then the same frame)
freeze on the previous head.

Also, per review: the pacer no longer touches busy or metadata. The stream
hook creates it and receives each frame as it goes on screen, so metadata
(and with it touch mapping) now follows the visible frame rather than one
still decoding.

* test(mobile): hold the pane test's AppState listener without a type assertion

* fix(mobile): never replay a tap the host answered

A fulfilled browser.mouseClick ran on the host, but a null result still
replayed it as move/down/up. The native bridge always answers { clicked },
while the external-Chromium provider returns agent-browser's `data` as is,
which can be null, so a right-click there was a double tap. Only a refusal
that is not delivery-unknown now replays.

* test(mobile): re-record the corpus for the answered-tap rule and rename its checkpoint

The commit before stops replaying a tap the host answered with a null
result. The seed's checkpoint was named clicked-by-fallback, which several
partitions no longer do, so it is renamed tap-settled in the same record.
Baseline bumped to 2c2b84ce9e by editing that line, then --record.

788 files, 841 changed lines each side. Every one classified:
- `baseline`: 787 goldens + pilot-scenarios.json.
- the checkpoint rename: pilot-scenarios.json (1), its id in
  browser-pointer-click-fallback.json (1) and the eleven partition ids in
  each of the four matrix-browser.pointer-click-*-1.json (44), plus
  `scenarioSha256` in those five goldens.
- behaviour, one checkpoint: the result-null partition in
  matrix-browser.pointer-click-browser.mouseclick-1.json now sends only
  browser.mouseClick#1 (sender and payloads drop move/down/up). The refused,
  method-not-found and result-absent partitions still replay, unchanged.

* fix(mobile): cover the frame layer remount, and trim the pacer's edges

Nothing tested the remount path: a fresh Image loads the source it mounted
with, so the pacer re-arms that load and puts back what the layer held.
The pane test's native model now mounts each host fresh (an Image loads its
mount source), and a new case remounts both Images mid-decode through a
zero-size layout: the visible layer keeps its frame and the stream goes on.
Deleting either the re-arm or the put-back fails it.

Also per review: drop the dead mountedUri guard in drain, return early from
attachImage when nothing is mounted, say that replace writes over a loading
layer, unexport the unused pacer types, cover the modifier bail in the tap's
comment, and move the frameUri state into the stream hook, which now returns
the source the layers mount with.
2026-09-24 13:37:51 -04:00
Brennan Benson 85642d0d88 fix(agent-status): count only agent work in stats, and read a Grok background subagent as working (#22474)
* fix(agent-status): renderer and recorder consumers read the question they mean

Since #22295 a row's combined `state` reads `working` both while the lead's
turn runs and while a subagent or background shell outlives a settled lead.
The lead's own state now rides beside it (`lead`); each consumer in this slice
reads the question it actually asks.

- Smart sort and the Activity unread badge keep reading the combined state:
  their classes and rows are what the sidebar shows. Pinned with tests,
  including a restored `lead.state: 'working'` row that must never read live.
- The stats recorder asks "was an agent executing" and now reads a shared
  derivation (`isAgentExecutionOwed`): the lead's turn, or live agent child
  work holding a settled lead's row open. A settled lead's background shell
  no longer accrues "Time agents worked". Old hosts without `lead` fall back
  to today's read; restored and replayed rows still never open a session.
- The `agent.status.changed` plugin event gains `lead` as an optional field
  through one tested projection; `state` keeps its meaning and restored rows
  still project to nothing.
- A Codex root Stop that follows an inferred interrupt keeps the
  `cancellation` verdict, as the Claude lane already does at its turn
  boundary, on both the hook and relay paths.

* test(agent-status): pin that a child's approval wait no longer splits the recorded span

The recorder's move to the lead fact quietly changed one more story: a Codex
child's PermissionRequest turns the combined row waiting while the root's own
turn keeps running. The old state read closed the span there and minted a
second spawn on resume; the new read keeps one span, because the lead never
stopped. Pin it at both boundaries (the shared derivation and the recorder)
so the change is deliberate, not incidental.

* fix(agent-status): date stats edges by this host's clocks and scope the accrual predicate to stats

The recorder dated a start by the producer's mainAgent.stateStartedAt. An SSH
host stamps that with its own clock while every stop is stamped locally, so each
span gained or lost the clock skew. The same clock also survives a row that
briefly lost the fact (an OSC repaint to another state), dating the reopen
before the close already sent, and a subagent reopening a monitoring row took
the row clock the hook lane pins to the main agent's turn start, re-billing the
whole watch-loop window. Edges now use the row clock when the row settles or
pauses and the evidence clock otherwise.

Rename isAgentExecutionOwed to isAgentTimeAccruing and state that it is the
stats question, not a liveness gate: it excludes watch loops, which lifecycle
gates must keep treating as live. Note on the plugin schema that
mainAgent.stateStartedAt is the execution host's clock.

* fix(agent-status): pause agent time while the row waits on the user, whoever asked

Time agents worked now accrues only while the combined row reads working. A
child's approval or question wait pauses the clock exactly like the main
agent's own prompt, and the pause edge is dated by the row's own clock.

* refactor(agent-status): read agent time from the combined row and date edges by the row's own state

Time agents worked now accrues while the combined row reads working and is not
a watch loop. The shared fold emits monitoring only for a settled main agent, and
hosts that predate the main agent fact did the same, so this is the same answer
on every new-host row without reading mainAgent, and it applies the watch-loop
rule to older hosts too instead of billing their monitoring windows.

An edge that leaves working is dated by the row's state clock; an edge inside
working is dated by the evidence clock. This also stops a live repeat of a
hydrated working row (any row without the main agent fact, such as an OSC row)
from dating its start at the persisted state clock from the earlier runtime.

* fix(grok): read a background subagent as agent work, not a watch loop

Grok's end-of-turn Stop lists each in-flight background task with its type
(shell, monitor or subagent). Orca filed a running subagent with the shells,
so a Grok subagent that outlived the main agent read "Monitoring background
tasks" and, with the stats recorder now skipping watch loops, stopped the
"Time agents worked" clock. Map shell and subagent entries to the shared
child-work kinds and let the shared liveness classifier decide: any live
subagent keeps the pane working, a shell alone or an active stop hook stays
monitoring, monitors stay excluded.

* test(agent-status): cover a waiting child in the fold's every-input accrual check

Since the shared fold learned a child's human wait, a waiting child makes the row wait, so it
must not accrue agent time whatever the main agent is doing. The exhaustive check now includes
that input.
2026-09-24 10:12:16 -07:00
122b8c25d7 fix(terminal): let Linux IMEs keep the candidate key for a preedit they own (#22607)
* fix(terminal): let Linux IMEs keep the candidate key for a preedit they own

Sogou on fcitx draws its preedit and candidate list in its own window and
opens no Chromium composition session, so every composition-scoped candidate
guard is idle when the user presses Space or a candidate digit. The selector
reached the PTY as literal text and the commit arrived after it.

The marker the input framework does still deliver is the claimed letter
keydown - `keyCode 229` / `key 'Process'` over the physical key. A bounded
window armed from that claims the next Space or digit, and stands down for a
real composition session, for the commit, and on expiry.

Co-authored-by: nxiaobai <48705519+nxiaobai@users.noreply.github.com>
Co-authored-by: Big-ios <18501866+Big-ios@users.noreply.github.com>
Co-authored-by: cporoske <14369468+cporoske@users.noreply.github.com>

* fix(terminal): keep the claimed-preedit window open while the IME edits it

The window released on any keydown that was not a letter the IME had
claimed, but the IME claims the keys that edit and page a preedit too —
Backspace, the arrows, `-`/`=` for the next candidate page — and Chromium
keeps the original `code` on those 229/Process keydowns. Paging to a later
candidate therefore disarmed the window and the digit that picked from that
page reached the PTY.

A claimed keydown now refreshes the deadline instead of clearing it. Arming
still requires a claimed letter, so a bare navigation key with no preedit
behind it cannot open the window from cold.

* fix(terminal): release the claimed-preedit window when a composition ends

An engine that does run a composition session still emits the claimed
`keyCode 229` letter keydowns that arm this window, and both its commit and
its cancel travel as `insertCompositionText` — the one input type the commit
release deliberately ignores. Nothing else closed the window, so a cancelled
preedit left it open and the next literal Space was swallowed. Refreshing the
deadline on every claimed keydown had widened that tail.

`compositionend` now releases it, as `compositionstart` already did. The
engines this guard exists for emit neither event, so their path is unchanged.

The selector suite drives the state object directly and cannot see a missing
listener, so the release wiring gets its own DOM-level suite.

* test(terminal): pin a Space-committing composition session end to end

The regression the compositionend release fixes needs the whole session to
show up: compositionstart fires once, so every letter after the first re-arms
the window, the commit and the cancel both travel as insertCompositionText,
and the committing Space arrives claimed rather than bare. Only the end of the
session closes the window before the user's next literal Space.

Fails with the compositionend listener removed.

* fix(terminal): end the claimed-preedit window on the selector the IME took

A claimed selector means the engine picked the candidate itself, so the round
is over. It reads `key: 'Process'`, which the selector predicate cannot see,
so this asks the physical code instead.

Belt and braces for an engine that opens a composition session and never closes
it — a documented defect in at least one shipped input framework. Without a
`compositionend` the session-end release never runs, and the refresh added for
candidate paging would otherwise hold the window open across the commit and
swallow the user's next literal Space. Paging keys still refresh, so a long
candidate browse is unaffected.

* fix(terminal): let a claimed Enter or Escape end the pick too

Japanese and Zhuyin commit with Enter, not Space, and Escape cancels. Both
arrived claimed and fell into the refresh branch, so for an engine that opens a
composition session and never closes it they held the window across the commit
— the same defect the Space case was written to close, reached by a different
key. Arrows, `-`/`=`, Backspace and PageUp/PageDown still refresh, because those
page the candidate list rather than ending the round.

Physical codes rather than `key`: `key` reads `Process` on all of them, and
`Digit*` survives AZERTY, Dvorak and Colemak moving the digit row.

Also corrects the window comment, which still described the orphan-keyup
picking window. Since claimed keys refresh it, it is now a stuck-state valve
that only expires when the IME stops reporting.

---------

Co-authored-by: nxiaobai <48705519+nxiaobai@users.noreply.github.com>
Co-authored-by: Big-ios <18501866+Big-ios@users.noreply.github.com>
Co-authored-by: cporoske <14369468+cporoske@users.noreply.github.com>
2026-09-24 00:50:03 -07:00
Brennan BensonandClaude ad6cb0e05c fix(worktrees): version every catalog publication so a stale listing cannot undo a create (#22507)
* fix(worktrees): version every catalog publication so a stale listing cannot undo a create

A worktree listing is a snapshot from when its scan began. The renderer treated any
authoritative listing that lacked a known worktree as proof of deletion, judged at apply
time against the live store, so a listing delayed past a create reply purged the new
workspace: tabs wiped, selection cleared to the landing, pending structured launch
tombstoned so the host session was closed the moment it published. #22311 re-runs a scan a
mutation overtakes, which covers a bump during the scan but not a reply that is simply
applied late, on the host or in the renderer, or a refresh that joined an older one.

The host now stamps every listing with the catalog version its scan began at (the existing
per-repo scan generation, scoped by a per-process epoch) and every create and remove reply
with the version the mutation produced. Clients keep the newest version applied per repo
and host; a listing older than that is not applied at all, not its rows, not its purge, not
the pre-merge terminal teardown. Coalesced joiners inherit the reply and therefore the rule.
Fields are optional on the wire; older hosts and clients keep today's behavior.

* fix(worktrees): relist after a refused stale listing and stop version churn

- A refused listing can be the only answer a caller gets (a change-event
  refresh that joined an older in-flight listing), so fetchWorktrees lists
  once more; that listing scans at or past the applied version.
- An equal catalog version keeps the held object, so a no-op listing no
  longer writes store state on every refresh.
- Versions the client cannot order are treated as unstamped at ingest.
- worktree.rm takes the repo from its id selector instead of resolving the
  worktree a second time, which also stamped nothing for an id two hosts share.
- A removal on one of two hosts sharing a worktree id records its version.

* test(worktrees): pin the scan-generation bump right after git worktree add on every create path

A listing is stamped with the generation its scan began at, so a create must
advance it before any post-add work can yield. Pins the local desktop, SSH and
runtime local create paths.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(worktrees): bump the scan generation right after git worktree remove

A listing is stamped with the generation its scan began at. Removals bumped it
only at the end, after watcher release, push-target cleanup and the metadata
purge, so a listing scanned before the git removal and one scanned after it
could carry the same sequence. Applied out of order, the older one restored the
removed row until the removal reply. Bump right after the git removal on the
desktop local, desktop SSH and runtime paths, as creates already do.

The ordering pins now witness the generation at the first step after the git
mutation rather than at the re-list, so moving a bump past any intervening
await fails them.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(runtime): stop leftover worker-recovery retries from firing into later tests

The legacy worker terminal recovery retry timer re-arms itself and had no
way to end, so a runtime from one aggregator test kept rescanning repo-1
through the shared listing mock during later tests, consuming the listing a
lineage create expected ("Worktree created but not found in listing").

Give the controller a dispose() that cancels pending retries and refuses to
re-arm, and have the runtime test harness dispose every controller it
constructed after each test.

* Revert "fix(runtime): stop leftover worker-recovery retries from firing into later tests"

This reverts commit 5879110163. The leaked
recovery retry timer is a pre-existing test-harness flake that also hits
main; it belongs in its own change, not in the catalog-version fix.

* test(worktrees): pin the relist bound and the teardown gate on fetch-all and paired runtime listings

The relist after a refused stale listing had no test holding it to one retry, and the
pre-merge terminal teardown gate was pinned only on the direct fetchWorktrees path: removing
it from fetchAllWorktrees or from the paired-runtime listing path left every suite green.

* fix(worktrees): keep an SSH reconnect going when its listing is older than an applied create

A direct SSH listing refused because a newer catalog is already applied for
that host reported 'stale', the same result as a moved connection. The
reconnect preparation ends on any 'stale' repo and skips the post-connect
workspace sync and terminal correction, and nothing retries that while the
connection holds. The refused listing now reports the host's answer, since
the store already holds a newer catalog; 'stale' stays for a moved
connection or owner.

* test(worktrees): pin the listing teardown gate on the fetchAllWorktrees startup hydration pass

The hydration pass lists through its own call site, and removing its gate left every suite green.

* fix(worktrees): decide a listing's refusal reason inside the merge, and defer the startup purge behind a newer create

The listing merge now returns 'applied', 'superseded' (a newer catalog is already
applied) or 'not-current' (its connection or repo owner moved), decided against live
state inside the store update. Callers switch on it instead of re-deriving the reason
afterwards, which misreported an owner that went away during an older listing as current.

The one-shot startup purge keeps only ids from scanned rows. A create applied after a
repo's listing but before the purge wrote only the visible rows, so the purge closed the
new workspace's tabs, chat tab included. It now defers when any repo's listing is older
than that repo's applied catalog, like a refused listing, and runs on the next pass.

* test(worktrees): pin the startup purge deferral on a listing refused for a changed repo owner

* test(worktrees): pin a direct-authority fetch reporting an older listing as current

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-24 00:46:29 -07:00
Brennan Benson 25d7c21fcb feat(native-chat): show context window usage in the composer (#22301)
* refactor(native-chat): move the composer's stop action into its own hook

The composer is at its line budget; lifting the stop action out makes room
for the context usage ring without changing what Stop does.

* feat(native-chat): record the Claude CLI's context window facts on the structured journal

A structured Claude session now keeps what the CLI says about its context
window on journal rows, so every client reads the same answer and a restart
replays it:

- Each main-thread assistant response keeps its API usage. A subagent's
  response measures its own window, so it carries none.
- The turn a result settles records the session's window: the largest
  contextWindow across the result's per-model usage, since side calls to a
  smaller model report their own smaller window.
- After a result and after a compaction boundary the host asks the CLI for
  its /context breakdown (5s bound) and records the answer on the current or
  last turn. An answer is dropped when the main conversation moved, a send was
  accepted, a newer request was issued, or the session was released while it
  was in flight; a failure or an older CLI leaves the row unchanged.
- A compaction boundary or conversation reset records that the used count is
  unknown until the next response or report, so the pre-compaction size is
  never shown as current.

Every part carries its own host clock, and the reader takes the newest, since
a revised turn row keeps its place in the transcript. The persisted validator
admits every value the writer can write, including a zero auto-compact
threshold: a row replay rejects truncates the journal from that row.

* feat(native-chat): show context window usage in the composer

A structured Claude chat shows a ring beside send once the journal can state
the session's context usage. Hovering shows used/window with a bar and, when
the CLI has reported its breakdown, one row per CLI category as a share of the
window, largest first. Between reports the ring shows the newest response's
usage against the newest window the CLI reported, marked as estimated. Before
the CLI has reported any window, and after a compaction or reset until the next
response, there is no ring. A terminal-backed chat shows none.

* fix(native-chat): measure the context ring against the main thread's model window

The result's per-model usage is cumulative across the session and includes
subagents and side calls, so the largest window was often not the one the
main conversation runs in: after switching from a 1M model to a 200k one, or
when a subagent ran on a larger-window model, the ring read against the
wrong window after every turn. Pick the entry named by the model that served
the newest main-thread response, and among its [1m]/non-[1m] entries the one
the result moved; fall back to the largest only when nothing names it.

* fix(native-chat): keep the context ring moving through tool-only responses

The live estimate lived on assistant message rows, and a response with only
tool calls or thinking writes no message row, so the ring froze through long
tool loops and stayed hidden after a mid-turn auto-compaction until the next
text reply. Record every main-thread response's usage on its turn row
instead, once per response, so the selector sees each one.

* fix(native-chat): read the ring's window from the model the turn's init names

The CLI keys per-model usage by the main loop's model string, [1m] included,
and every turn's system/init frame carries that exact string, while a
response drops the suffix. Match the init's key first, so a session that
switched between the 1M and 200k variants of one model reads the right
window; fall back to the newest response's model, then the largest entry.

* fix(native-chat): correct the composer's control-order note for the context ring

* fix(native-chat): keep a running turn's context facts when the host settles it

A turn row now carries the live context estimate while it runs. When the host
settles a running row itself (a crashed or stale generation, a close the
translator never saw), it rebuilt the record field by field and dropped those
facts, so after a crash the ring fell back to an older turn's size, or to a
pre-compaction size the dropped reset had superseded.

* refactor(native-chat): revise Claude turn rows from the journal so the ring survives a restart

The context ring's facts were written to turn rows through an in-memory list
of recent turns. A new translator is built on every acquisition, so after a
restart or reattach that list was empty and every fact for a turn that was
not open was dropped: a /compact as the first action after a restart never
cleared the ring and never showed the fresh breakdown.

Every Claude turn-row write is now a revision of the row as the bound journal
holds it when the write runs. The sink gains a resolved revision that reads
the target row and its body at execution; the queue runs one operation at a
time, so the read-modify-write cannot interleave, and revisions are never
coalesced. Lifecycle writes own the lifecycle fields and context writes own
contextUsage; each keeps every other field. Only the open turn is kept in
memory. A fact with no open turn lands on the newest turn row, and a report
lands on the turn it was requested for.

The persisted facts are simplified to a window, which now names the model it
was measured for, and a single used part (report, estimate or unknown) that
each write replaces. The ring reads the newest turn row carrying each part,
and hides an estimate whose model the window was not measured for instead of
dividing by another model's window. A turn opening, and a reset, count as
activity, so a late report can never land behind a newer turn.

Host settlement of a stale running turn now drops only the fields its verdict
owns, so context facts and any field a newer build wrote survive it.

* fix(native-chat): keep a turn row whose context facts this build cannot read

Context facts are validated deeply, so one malformed or future-shaped fact
made the whole turn row malformed, and replay truncates the journal from that
row on. Replay now drops unreadable facts from a turn row, in item rows and in
settlement batches, and keeps the row, the same way it already drops producer
linkage it cannot trust. The ring shows nothing for that turn instead of the
session losing its history.

* test(native-chat): pin that a child exit mid-turn keeps the ring's last size

A lifecycle-only revision, the end a turn gets when its child exits without a
result, must keep the context facts the row already carries.

* perf(native-chat): revise a named Claude turn row by key instead of scanning the journal

Every Claude turn-row write walked every reduced journal item to find its
row, even when it already knew the row's identity, so a long session paid
O(items) per write on the main process. The journal now answers a keyed read,
and a context report names its turn by row identity rather than turn id, so
only a write made while no turn is open still scans.

* fix(native-chat): tell a 1M window from a 200k one of the same model

Responses drop the [1m] suffix, so after a switch between the 1M and 200k
windows of one model the running turn was measured against the previous
turn's window until its result arrived. An estimate now records the turn's
init model, which keys the window exactly, and the reader requires the full
model id to match.

* fix(native-chat): show no ring for a context kind a newer host writes

A paired client reads turn rows from the host unvalidated, so a used-count
kind this build does not know fell through to the estimate branch and threw
reading its missing usage. Only the kinds this build can measure now produce
a ring.

* fix(native-chat): keep the context ring through plan-mode turns on another model

Plan mode can run a turn on a model the turn's init does not name (opusplan
upgrades to Opus's 1M window). The estimate then carried only the response's
id, which drops [1m], and the exact comparison against the window hid the ring
for every plan-mode turn. The estimate now records the response's id beside
the init's exact key, and the reader matches the base model only when no exact
key was recorded.

* refactor(native-chat): pair the context ring's window by model change, not by model id

The ring divided the newest response's size by the newest window only when
their model ids matched, which meant comparing ids from the init frame, the
response, per-model usage keys and canonical ids. Those disagree in plan mode
and across 1M and 200k windows of one model.

The writer now knows when the model may have changed: after a model or
permission-mode write that changes the value, when a restore cannot put the
stored model back, and when a main-thread response comes from a different
model than the one the window serves (an approved plan). It then marks the
size unknown, holds estimates, and asks the CLI for its context report, which
states the new model's window. Any new window, from a report or a turn
result, releases the hold. The reader compares nothing: a report, or the
newest estimate over the newest window.

Turn rows no longer store window.model, window.canonicalModel,
estimate.model or estimate.responseModel.

* fix(native-chat): keep a late context report's window when only its count went stale

* test(native-chat): pin that each turn's init lets its result restate the context window

* fix(native-chat): open the context card on click and tap

* fix(native-chat): wait a beat before a mouse hover opens the context card

* fix(native-chat): publish each context write in the operation that makes it

A context report answers after the turn's last frame, so a revision that waited
for the next frame's publish reached live clients only on the next turn. Context
writes now queue their revision and its publication as one operation.

* fix(native-chat): write million-token counts with a capital M

A lowercase m read as minutes on the context card.

* fix(native-chat): keep the context card open while the pointer crosses into it

The card closed the moment a mouse left the ring, so the pointer could not
cross the gap into the card. Leaving now waits a beat, and entering the card
cancels the close.

* fix(native-chat): let Escape close the context card without stopping the agent

The card keeps focus in the composer, so the Escape that closed it also
reached the composer and interrupted the running turn. The composer now
skips an Escape an open layer already handled.

* fix(native-chat): show the context ring when the chat has not loaded the turn it belongs to

The ring read context facts only from the rows the chat had loaded, so a
reopened chat whose recent page started after the last turn row, or a live
turn longer than the retained window, showed no ring until the next turn.

The host now derives the newest context facts from its whole journal with
the same selector the chat uses, and returns them on agentSession.options
for sessions that write them. The chat prefers each fact its loaded rows
carry and takes the host answer for a fact they lack. When a live batch
revises a turn row older than the loaded window, the chat asks for options
again so that answer stays current.

* fix(native-chat): bound context refresh reads and refresh when the turn row is trimmed

Each turn-row revision the loaded window missed started its own options
read. Those reads share the session's host queue with sends and interrupts,
and each asks the CLI for its settings, so a burst could pile reads in front
of a user action and discard every answer before it landed. The chat now
keeps one options read in flight and at most one behind it.

A live turn longer than the retained window also lost its turn row to the
trim without asking for a fresh host answer, so the ring fell back to the
answer read at turn start until the next response. Trimming a turn row now
asks again, like a dropped revision does.

* fix(native-chat): show the context ring from the first response, sized from the session's model

A new session has no measured window until its first result, so the ring
stayed hidden for the whole first turn. The host now keeps the window the
applied model's name implies (1M for a [1m] name, unknown for default, 200k
otherwise) and writes it beside an estimate when the journal holds no window,
or after a model write, until the result or the CLI's report replaces it.

* fix(native-chat): imply a context window only from a [1m] model name

A bare model name does not fix the window: first-party runs today's opus,
sonnet and fable models natively at 1M while a gateway or cloud provider runs
them at 200k, and opusplan and haiku run another model in plan mode. Sizing
their first response at 200k read the ring about five times too full, so only
a [1m] name implies a window now; any other name waits for the result.

* fix(native-chat): size the first response from a report taken before any turn

A model picked in a chat with no turn yet asks the CLI for its context
report, but with no turn row the report's write lands nowhere. Recording it
still marked the journal as holding a window, so the first response wrote
none and the ring stayed hidden until the turn's result.

The report's window now serves as the fallback a response writes while the
journal holds no window, and recording a report no longer assumes its write
landed.

* test(native-chat): move the fake Claude connection out of the structured integration suite

The context-report delivery case pushed the suite past the 800-line limit,
failing repo-wide lint. The fake child now lives in its own fixture.
2026-09-24 00:12:08 -07:00
Neil 8ddad49ae5 fix(opencode): retire a subagent's blocker when the root turn ends (#22604)
A descendant's question is stored under its own sourceSessionID but displayed on
the root session it rolls up to. clearAttentionForSession matches only the
source, so the root's own idle could never retire it: the blocker outlived the
turn that raised it and pinned the pane on "needs input" with nothing left to
answer. The lead agent truthfully reports no pending question, because the
blocker belongs to a child it does not know about (#22371).

Add clearAttentionForTurnEnd, matching the source id or the rolled-up root, and
use it only from the idle branch. Scoped to turn end deliberately: a live
blocker must still outrank the root going Busy, since a subagent can be waiting
on the user while the root keeps working — widening the existing clear broke
exactly that test, which is what surfaced the right scope.

Shared with the OpenCode family, so mimo-code gets the same fix; both pinned
plugin digests move.

New test fails without the change: after the root turn ends the last post is
still AskUserQuestion instead of SessionIdle.
2026-09-23 23:45:50 -07:00
Lesley MurfinandJinjing b2940a18ff fix(file-explorer): toggle folder instantly on name click (#22325)
* fix(file-explorer): toggle folder instantly on name click (#22324)

Single-clicking a folder name held back directory expansion for the 500ms double-click window (PR #10867), which reads as latency compared to the chevron and editors like VS Code or Zed.

Toggle immediately on the first click (0ms) and skip the second click of a double-click rename so the folder does not flip back under the rename input. Delete the 500ms timer machinery.

Fixes #22324

* Fix double-toggle of symlink directories during rename

Treat symlinks the same as directories when skipping toggle on rename.
Symlink rows remain file-shaped in the UI until activated, so checking
only isDirectory would cause a stat and toggle during the rename flow.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-09-23 22:50:03 -07:00
Neilandrasata 5ba9649058 test(terminal): pin Option-composed currency on a punctuation key (#22608)
#21082 widened the kitty composed-text check from ASCII to every printable
codepoint, which is what restored French Option+$ -> €. Nothing pinned a
composed character on a punctuation code: the Polish cases all sit on `Key*`
codes, whose unmodified character the US fallback table also answers, so they
pass even when the layout map is the only source of `characterWithoutOption`.

Reverting the widening fails this case at kitty flags 1, 3, 5 and 7.

Co-authored-by: rasata <93783+rasata@users.noreply.github.com>
2026-09-23 22:43:39 -07:00
Jinwoo Hong ca75bc4c8d fix(orchestration): type a request ahead of pasted dispatch briefs so Claude workers follow them (#22582)
* fix(orchestration): type a request ahead of pasted dispatch briefs so Claude workers follow them

Claude Code wraps a bracketed paste in <pasted_content> and tells the model to
follow instructions inside it only where the user's own message asks. Orca sent
the whole dispatch brief as a bare paste, so Claude workers (Opus 5.5, Sonnet 5)
refused it as suspected prompt injection. Every dispatch path now types a short
lead line in the same PTY write as the paste frame, the preamble drops shouted
rules, and dispatch detection accepts the lead line and pasted_content wrapper.

Fixes STA-8200

* refactor(orchestration): tidy dispatch lead-line delivery after review

- Share one dispatchPreambleSendOptions() across the four dispatch paths.
- Fold every C0 control and DEL out of the typed lead line.
- Bound the <pasted_content> tag scan and let compaction return null for
  non-dispatch prompts, removing the separate detector.
- Restore the stay-off-other-channels rule in plain wording.
- Test through the real status normalizer and trim duplicated assertions.

Refs STA-8200

* test(orchestration): guard coordinator auto-dispatch lead line

- Capture send options in the coordinator runtime fake and assert the
  auto-dispatch send uses dispatchPreambleSendOptions.
- Drop the helper test that only restated its literal.
- Share DispatchPreambleSendOptions with the coordinator runtime contract.

Refs STA-8200

* docs(orchestration): fit the pasted-spec note inside the kernel line budget

Refs STA-8200

* docs(orchestration): drop the pasted-spec note from the coordinator guide

The typed lead line is the fix; the advisory note cost always-loaded context.

Refs STA-8200

* fix(orchestration): type the dispatch lead line only for Claude agents

Codex discards typed text that shares a PTY write with a bracketed paste,
so the lead line never reached it. Only Claude Code needs the lead to follow
a pasted brief, so known non-Claude agents now get the pre-lead bytes and
unidentified agents keep the lead in case they are Claude.

Refs STA-8200
2026-09-24 01:33:16 -04:00
github-actions[bot] 420fcb3e77 Update README downloads badge 2026-09-24 05:26:45 +00:00
Brennan Benson b4d732685c feat(agent-status): combine Codex child work through the shared main-agent status fold (#22475)
* feat(agent-status): combine Codex child work through the shared main-agent status fold

* docs(agent-status): correct two comments the waiting child-work arm made stale

A child failure reported in place as `blocked` now pins the row `waiting`, not
`working`; and no relay ever sent an unfolded `working` beside a waiting child.

* fix(agent-status): only a waiting child asks for a human

A child's `blocked` state means its task failed (the only producer maps a
failed background task to it, and the background-task view labels it
"failed"), not that a human must act. Folding it into the waiting arm would
surface a failed child as needs-you. It stays live work, as before this
series.

* docs(agent-status): say a waiting child, not a blocked one, makes the row wait

A child's blocked state means it failed; only its waiting state feeds the
waiting arm. Two fold comments, a test describe and two parity story names
still called the waiting child blocked.

* docs(agent-status): name where a child's wait is still lost, and pin the structured lane's real input

The doc said the Claude hook lane's rows match Codex and that every lane feeds a
child's wait into the fold. Neither holds: Claude keeps the wait in one slot the
next main agent event overwrites, the structured lane turns a child's prompt
into the main agent's own attention, and Codex drops its roster on a root Stop
when it tracks no child transcripts. The parity story now drives the structured
lane with the input it actually receives.
2026-09-23 22:09:07 -07:00