Commit Graph
11760 Commits
Author SHA1 Message Date
Jinwoo Hong ce2f75e172 refactor(mobile): the live input's composing range comes from a platform seam pair (#23037)
#22958 made the page on Android report no composing range with a user-agent check inside the shared
live-input hook. Host facts live in a `src/platform` pair, so the check moves to
`live-input-composing-range.web.ts`; the native file passes the event's range through. Behaviour is
unchanged. The hook test mocks the seam; the user-agent cases move to the seam's own test.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 23:41:46 -04:00
77bc0d77d8 fix(editor): highlight scoped dotenv filenames on desktop and mobile (#22416)
Co-authored-by: willydallas <willy.dallas@pm.me>
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
2026-09-25 20:40:53 -07:00
02a5594434 fix(mobile): give each host field one writer so relay routing can't revert edits (#22956)
* fix(mobile): give each host field one writer so relay routing can't revert edits

Relay learners (director re-resolution, credential rotation, direct-to-relay
upgrade) saved the whole HostProfile snapshot their connection opened with,
so a late relay write reverted an Edit Host endpoint and rewrote the device
token. The relay overlay also stored a copy of the paired address, which the
direct probe kept dialling after an edit.

Pairing is now the only full-profile writer (savePairedHost, fenced by a
census). Learners call setRelayRouting(hostId, relay), which never touches the
row or keychain, refuses a removed host, and skips unchanged routing. The
overlay is relay-only in memory; one serializer writes the v2 shape older
builds parse, without the direct entry. Saving a new endpoint rebuilds even a
Relay-active client from the saved row.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for relay-only host routing

Baseline moves to the product commit. adapterSha256 moves for the pairing and
relay-credential adapters, which now read relay.relayHostId and inject
saveRelayRouting. Only the four direct-upgrade bodies change: the settled host
no longer carries the overlay's endpoints copy (direct-primary, relay-primary)
or the duplicate relayHostId; relay and every effect are unchanged.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): relay learners own only relay routing

Follow-up to the field-ownership split. The supervisor keeps its host
read-only and holds the relay in a small owner that persists moves through
setRelayRouting; the direct upgrade returns { relay, bundle } and the
lifecycle composes the profile once. With one direct endpoint left, the probe
takes a bound openDirect and the lifecycle computes the direct path once.

One name per writer: setRelayRouting and savePairedHost are also the
dependency keys, and the removed-host error is RelayRoutingHostRemovedError.
The census now counts real value imports of savePairedHost, not mentions.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* test(mobile): re-record RPC goldens for the { relay, bundle } upgrade result

Baseline moves to the refactor commit, and adapterSha256 moves for the
pairing and relay-credential adapters (dependency keys renamed to
savePairedHost and setRelayRouting). Only the four direct-upgrade bodies
change: the settled upgrade result is now { relay, bundle } instead of
{ host, bundle }, with identical relay, bundle, state and effects.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

* refactor(mobile): refresh edited hosts and let the establisher own relay

Edit Host now reuses refreshHostClient, which already rebuilds an owned
client (relay-active included) from the saved row after re-pairing, instead
of a savedAddressChanged flag on forceReconnect.

The session establisher, the only relay dialer, holds the relay and adopts
learned routing through setRelayRouting; the supervisor takes a host id and a
required relay, so the no-relay guards and the synthetic upgraded profile go.
enqueueHostListMutation returns its operation's value, and the overlay store
names its API after routing.

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>

---------

Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-25 23:15:12 -04:00
Brennan Benson c5f33bd139 fix(ipynb): run no workspace interpreter until the notebook is trusted (#22962) 2026-09-25 20:08:24 -07:00
OrcaWinandm4air a00c424a11 Preserve pending SSH terminal layout edits (#22991)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 20:00:04 -07:00
OrcaWinandm4air ff74506c0b fix(native-chat): keep terminal pane chat ownership stable (#22984)
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-25 19:40:32 -07:00
Neil fa018fe520 chore(deps): refresh maintained dependencies (#22964)
* chore(deps): refresh maintained dependencies

* fix(deps): defer upgrades that violate runtime and test contracts

* test: align catalog response assertion and updated formatter
2026-09-25 19:04:12 -07:00
8846987c99 feat(rate-limits): add Cursor usage tracking (#22633)
* feat(rate-limits): add Cursor usage tracking

## ELI5

If you use Cursor, Orca now shows how much of your monthly Cursor plan you
have used, next to the Claude, Codex and Grok meters, and in Settings →
Accounts. It reads the sign-in Cursor already saved on this computer and never
changes it.

## What changed

Cursor becomes a rate-limit provider like Grok: a status-bar meter (default-on,
with its own toggle), a row in the usage roster, and a Settings → Accounts
section naming the signed-in account.

The credential is read from whichever of three stores has it, first match wins,
all read-only:

- the macOS login keychain item `cursor-access-token` / `cursor-user`, which is
  where `cursor-agent` 2026.06+ keeps the session;
- `~/.cursor/auth.json` and its platform variants, used by older CLIs;
- the Cursor IDE's `state.vscdb` (`cursorAuth/accessToken`), for people who
  never run the CLI.

The keychain entry is the one current CLIs use, and reading only `auth.json`
finds nothing on an up-to-date macOS install. A locked keychain cannot mask a
readable `auth.json`, and a locked `state.vscdb` cannot mask either.
`~/.cursor/cli-config.json` supplies the account's email and display name; it
never holds a token.

Usage comes from the dashboard route the Cursor web dashboard itself reads,
because Cursor documents no individual-user usage API — every documented API is
team- or Enterprise-scoped. Per Cursor's pricing docs an individual plan has two
pools, Cursor Models and Other Models, both resetting with the billing cycle,
plus optional on-demand spend; each becomes a named bucket. The headline
percentage prefers `used / limit` over the sibling percentage fields, which are
pre-rounded for the dashboard's own copy. Because the route is undocumented the
mapping is defensive: an unrecognised payload resolves to `unavailable` and
hides the bar rather than publishing a zero that reads as "no usage".

Orca never runs `cursor-agent login` and never writes, refreshes or rotates a
Cursor credential. An expired token short-circuits to an actionable
"run cursor-agent login" instead of spending a request that can only 401 — not a
rare case, since `cursor-agent status` still reports `isAuthenticated: true`
against a token that expired months ago.

## Why this shape

Six open PRs implement this feature and none reads the keychain, so each finds
nothing for a large share of users; this takes the auth layer further and keeps
what those PRs verified live. The bar is not gated on `cursor-agent` being on
PATH, unlike other CLI providers, because an IDE-only session is real usage with
no CLI to detect.

`readKeychainPassword` moved out of the Claude keychain reader into
`src/main/macos-keychain/generic-password.ts` so both providers share one
`security(1)` wrapper. It is a byte-for-byte relocation, so Claude's credential
path is unchanged; the two child_process allowlists move the entry with it and
neither ratchet count changes.

Co-authored-by: Preschian Febryantara <preschian@users.noreply.github.com>
Co-authored-by: Qwesdy <qwezdi@proton.me>
Co-authored-by: ivo922 <github.concur614@passmail.net>
Co-authored-by: Mihail Vratchanski <mivrkiki@gmail.com>
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com>

* test(rate-limits): name the JWT helper's segment type in the Cursor tests

The anti-slop gate rejects a bare `object` parameter; the fixtures build a
claims record, so say that.

* fix(rate-limits): render Cursor's pools and keep its plan total visible

Review of the first commit found the meter effectively blank for a healthy
account, which the screenshots missed because the only Cursor session on hand
had expired and never reached the success path.

- The verbose status-bar segment filtered buckets through an allowlist written
  for Gemini's experimental models, so both Cursor pools were dropped and the
  fallback needed a `session` window Cursor never reports. A signed-in account
  rendered an icon and no number. The allowlist now admits Cursor's pools, and
  the fallback accepts a monthly window.
- `getWindowSections` dropped `monthly` whenever buckets existed. Cursor puts
  the plan total there and its sub-pools in buckets, so a plan at 92% showed as
  50% in the roster, the tooltip, and the tightest-usage pick.
- A plan reporting `enabled: false` still published its 0% pools, painting a
  healthy meter for a pool the account does not own and skipping the
  request-quota fallback.
- `redirect: 'error'` turned the dashboard's bounce to /login into a generic
  network failure, hiding the actionable sign-in message.
- A busy `state.vscdb` (the IDE holds it open) surfaced as a provider error,
  which would pin an alert bar on Cursor IDE users who never set Cursor up in
  Orca. It falls through to "no credential" instead.
- Refreshing the Accounts section read the keychain twice for one update.

* fix(rate-limits): pin the platform in the Cursor keychain tests

Review caught three cases that assumed macOS: the keychain source is behind an
explicit `process.platform` check, so on the Linux CI runner the mocked read was
never reached and the tests read the CLI file instead. They now set the platform
they mean, and two new cases assert the off-macOS fall-through.

Also track the credentials reference doc (docs/** is ignored by default, so a
new reference needs its own allowlist entry) and give the visibility fixtures
their own provider id instead of Grok's.

* fix(rate-limits): prefer a live Cursor session and report a failed refresh

Review round two, from CodeRabbit and Pullfrog.

- Credential precedence returned the first token that parsed, so an expired
  keychain token in front of a fresh Cursor IDE session reported "sign-in
  expired" on every poll while a usable session sat one source below. A live
  session now wins; the expired one is returned only when nothing live exists,
  so the actionable message still appears in that case.
- The usage schema took `.optional()` where the route sends `null` for an absent
  sub-object, so one null pool failed the parse for the whole body and threw
  away valid pools and the billing cycle with it.
- Cursor usage could survive an account switch: a failed refresh for account B
  kept account A's figures beside B's name in Accounts. The snapshot now carries
  a hashed account fingerprint, and a known-and-changed identity clears the
  previous reading. A refresh that names no account still keeps its own.
- The Accounts section rendered nothing at all when a signed-in account's fetch
  failed, and could repaint an older account when two status reads overlapped.
  It now states the failure — beside the numbers when a stale snapshot remains —
  and ignores superseded reads.
- A web client claimed "not signed in" for a host it cannot read, contradicting
  the meter beside it; it now says the detail is host-only.
- Signed-out copy named `cursor-agent login` as the only way in, though an IDE
  sign-in works just as well.
- The census comment ended at 4219 after the pacer squash without naming the two
  modules #22616 added; recorded them, re-measured on a clean origin/main.
- Narrowed the docs claim: Cursor documents all-plan APIs, but no individual
  usage endpoint.

* fix(i18n): localize the web client's Cursor host-only notice

It reaches the Accounts pane like any other string, so the coverage gate is
right to want it in the catalog rather than allowlisted.

* fix(rate-limits): name the Cursor account on failed refreshes, and ship the reworded copy

Review round three. Both findings say an earlier fix did not actually take.

- The account-switch guard reads `authProvenance` off the fresh result, but the
  fetcher stamped it only on success and network failures. The `stale-token`,
  429, 5xx and parse results omitted it, and so did the expired-session branch —
  so a switch whose first refresh failed, which is precisely the case the guard
  exists for, still rendered the previous account's figures under the new name.
  Every failure holding a readable session now names its account; a missing or
  unreadable credential still names none. The service test also fed a result
  shape the fetcher never produces, so it proved nothing; it now uses the real
  stale-token shape, and the fetcher test asserts provenance across 401/429/5xx
  and expiry.
- The reworded signed-out copy never rendered: a present catalog value beats the
  `translate()` fallback, and `sync:localization-catalog` only adds missing keys
  rather than updating changed defaults. Updated both strings in en.json, which
  also prunes them from the runtime-required catalog now that they match.

* docs: keep the Cursor credentials reference out of the tree

Its content lives in the PR description instead; docs/** stays ignored rather
than gaining an allowlist entry for this branch.

* test(mobile): drop the census note main no longer pins

main removed `SESSION_ROUTE_MODULES` and re-pinned this lane on a different
count, so the paragraph this branch added documents a number series that is
gone. The branch touches nothing in this file now.

---------

Co-authored-by: Preschian Febryantara <preschian@users.noreply.github.com>
Co-authored-by: Qwesdy <qwezdi@proton.me>
Co-authored-by: ivo922 <github.concur614@passmail.net>
Co-authored-by: Mihail Vratchanski <mivrkiki@gmail.com>
Co-authored-by: Tauri-EPO <enrico.pin@gmail.com>
Co-authored-by: Raajik <44516546+Raajik@users.noreply.github.com>
2026-09-25 18:54:11 -07:00
1c2cf120e3 fix: stop process-tree loops (#22411)
Based on the report and proposal by @brynnclaw.

Co-authored-by: brynnclaw <brynnclaw@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 18:35:48 -07:00
Brennan BensonandClaude 16784c1a67 fix(native-chat): name a chat write by its target, not the owner generation (#22812)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

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

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

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

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

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

* test(native-chat): type the stalled snapshot drain without a cast

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

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

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

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

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

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

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

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

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

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

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

* test(native-chat): type the stand-in roster decoder without a cast

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

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): name a chat write by its target, not the owner generation

A write carried the fence of the last frame the pane read, and the host refused it
unless that fence was still current. An idle release and the restart after it each
move the fence, and the release publishes nothing, so a send after a release was
refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a
cold start was refused as stale.

Every write already names what it acts on: a send its conversation, a cancel its
turn, a prompt answer its item revision, a rewind its epoch; an option is
last-writer-wins. So admission stops comparing the client's fence, and the rebase
that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it.
The writer-lease check stays, and so does the attach's compare-and-swap.

Frames now stamp the fence read when each frame is sent instead of a copy each
subscriber kept, which went stale on the same release.

* docs(native-chat): say mutation admission checks only the writer lease

* docs(native-chat): drop the send rebase from comments that still described it

* fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent

* docs(native-chat): drop the fence from the admission the send effects run behind

* docs(native-chat): give the fence move on release the reason that still holds

* docs(native-chat): stop citing a write fence check in launch and mailbox comments

Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 18:31:29 -07:00
NeilandSiinXu e785cacb67 fix(worktrees): keep failed orphan cleanup retryable (#22409)
Co-authored-by: SiinXu <SiinXu@users.noreply.github.com>
2026-09-25 18:28:22 -07:00
Brennan Benson 19d472fad8 fix(native-chat): report a chat's owner from its record, not its running agent (#22808)
* fix(native-chat): report a chat's owner from its record, not its running agent

Released desktop clients gate worktree activation on agentSession.handoffStatus
and count a chat tab as claimed only when the owner is `native`. The host
answered `native` only for a live lease, and threw not_attached for a chat idle
release had forgotten, so a chat at rest (idle-released, or restored after a
restart) blocked its whole worktree from activating.

The answer now comes from the record store for any record this host supports:
the owner is the lease's runtime kind whatever its liveness, and manual recovery
still answers `none`. With no map entry needed and nothing to wait for, the
serialized read that kept a mid-start chat's answer honest goes too.

* test(native-chat): pin the two owner answers that still refuse to vouch

With liveness gone, the manual-recovery branch and the unsupported-record
refusal are the only paths that keep a chat from answering native; neither
had a test.

* refactor(native-chat): say plainly why an unsupported chat reports no owner

* test(native-chat): say what a blocked activation gate actually skips
2026-09-25 18:27:58 -07:00
Brennan BensonandClaude 600adba9f0 fix(native-chat): every journal append reaches the chats that are open (#22811)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

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

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

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

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

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

* test(native-chat): type the stalled snapshot drain without a cast

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

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

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

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

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

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

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

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

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

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

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

* test(native-chat): type the stand-in roster decoder without a cast

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

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* fix(native-chat): every journal append reaches the chats that are open

A journal write and its delivery to open readers were two calls, and some
writers made only the first. A failed start whose lease could not be handed
back, a provider revision with no frame behind it, and eviction's settlement
were all journaled without reaching an open chat.

A journal handle now reports every durable change, and the host's session map
binds that report to the session's readers when the handle is set. Writers no
longer publish what they append; the per-writer publish calls are deleted.

* test(native-chat): an epoch replacement reaches the open chat

* test(native-chat): each row reaches an open chat once, and a live handle enters only through the map

* perf(native-chat): a publish behind a delivered commit reads nothing

Each commit now delivers itself, so the publish a provider frame still sends
afterwards found every reader caught up but still read rows and rebuilt the
timeline for each one. A caught-up reader now skips the read.

* test(native-chat): state why the teardown test's fake journal is safe to cast

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 18:25:03 -07:00
Neil 8d21fdbf60 fix(mobile): update fflate for security advisory (#22961) 2026-09-25 18:15:30 -07:00
Jinwoo Hong 77829142ac fix(mobile): a terminal opens once at the phone's size, on the page and natively (OTA phase C follow-up) (#22960)
* refactor(mobile): extract the once-per-route terminal viewport measure

Behaviour-preserving move of measureViewportOnce into a pure module so the
first-subscribe ordering can be tested without mounting the session route.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send phone dims on a terminal's first subscribe

A fresh terminal document has no xterm until its first init, so the viewport
measure could not answer before the first subscribe. The subscribe went out
without dims, the host serialized the snapshot at the desktop's size, the
phone painted it, measured, resubscribed and replayed the whole snapshot a
second time at the phone's size.

The first subscribe of each document now opens an empty terminal, measures,
and only then subscribes, so the host fits the PTY before serializing and the
phone paints once. Measure failure still subscribes without dims and the
existing fit pass takes over.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): converge the fit pass on the viewport the subscribe sent

A frame-height refit can clear the measured flag after the first subscribe
has already carried the phone's viewport. The fit pass then treated the
snapshot as dimensionless and resubscribed, replaying the whole snapshot a
second time at the same size. It now judges convergence against the
viewport that subscribe actually sent; the refit still owns real layout
changes through updateViewport.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): reject the failing measure without an async wrapper

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the fit-pass decision on the measured flag

Converging on the viewport a subscribe carried accepted a stale one: after
native chat covered the terminal through a rotate or dock, the return
resubscribe sent the old dims, the host matched them and the pass never
re-measured (#4579). The decision is back on the measured flag; the sent
viewport only lets a matching fresh measure skip the round trip.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): forget a document's first-subscribe mark only on web-ready

The terminal handle re-attaches null then new on every theme or text-size
change, and clearing the mark there let the next resubscribe post an empty
init onto a live document. web-ready is the one signal that a new document
started, so the mark is dropped there instead.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): pin each held-subscribe teardown guard on its own

Splits the teardown test so dropping either the generation check or the
in-flight check alone fails a test, and shares one MutableRef type.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 21:02:10 -04:00
Brennan Benson 18bbf6f209 refactor(orchestration): resolve every caller and target to one orchestration party, keyed by the Orca session id (#22555)
* refactor(orchestration): resolve a session caller at the dispatch entry and bind it by actor

WIP: entry resolver on both dispatchers, caller identity through run scope,
actor-keyed Run binding and unbind, actor writes on bind/create/assign, and
actor-aware mail ownership exclusions.

* test(orchestration): pin actor-keyed Run binding, stale-actor precedence and actor mail ownership

Keeps the non-session dispatch path synchronous so terminal and session-tab
streams reach their handler without an extra async hop.

* feat(orchestration): resolve session callers before params parse and pin every verb on both routes

A session caller need not name itself in a param that requires a caller: the
entry binds the declared caller to the session before the schema runs. Session
refusal codes pass through the RPC error map, and DB row reads added here carry
their SAFETY rationale.

* test(orchestration): pin the SSH check's pane through the caller-identity lookup

* test(orchestration): pin a Run-less session's direct check and receipt binding without a caller param

Drops the actor clause from self-dispatch detection: a creator and assignee can
only share an actor when they already share a handle or pane.

* test(orchestration): pin a session's own Run for plain and group sends and the assignee-only mail sweep

* test(orchestration): name the party-naming field population for its role

* fix(orchestration): clear a worker actor an older binary's unbind leaves, and refuse a worker without its identity

An older binary unbinds a structured worker's Run by clearing handle and pane, which
leaves the actor looking like a handle-less chat binding. The every-open repair
clears that shape for actors recorded as structured workers only, and the resolver
refuses a worker session whose worker identity is gone, so this binary never
writes the shape itself.

* refactor(orchestration): read a Run's coordinator actor through its generation, and give a party's addresses one owner

The coordinator actor now counts only at the consumer generation it was written at, so a
Run binding matches a session by that rule alone. It replaces two mechanisms for the same
fact: the rule that an actor beside a handle it did not bind with never matches, and the
open-time repair that cleared a structured worker's actor an older binary's unbind left.
Every write of an older binary that rebinds or unbinds bumps the generation, so both
shapes stop counting by themselves, including a chat's actor after a rebind then an
unbind, which neither old mechanism caught. createRun and the same-coordinator actor
correction write the generation in the statement that writes the actor. The resolver
still refuses a structured worker whose worker identity is gone; its predicate moves
next to the worker identity lookup.

addressSpellingsOf is the one owner of the addresses a party is reachable at (a
structured worker's handle and session actor). createRun, bindRun, the coordinator
unbind and the declared-caller check use it instead of hand-built sets, and each has a
test at both of a worker's addresses.

* fix(orchestration): say a released session is not running, and scope the pane-key credential claim to requests without a session

A released lease is evicted, not ended: a user turn resumes the session, so the refusal
now says it is not running right now instead of that it has ended.

The worker pane-key comment claimed the random leaf is what stops anyone who learns a
session id from acting as the worker. On the same-host socket route the session id now
names the worker with no token by design; the pane key still matters where a request
names no session (a PTY agent's, or the paired-client route, which refuses session ids).

* test(orchestration): pin the same-coordinator actor correction's generation write on a row an older binary wrote

* refactor(orchestration): resolve session callers by the bare Orca session id, typed apart from its address

Carries the Orca session id rename into caller resolution, Run binding and Dispatch
creation. The caller identity holds the bare `orcaSessionId`; the `session:<id>`
spelling is derived by formatOrcaSessionAddress wherever mail needs it.

`OrcaSessionId` and `OrcaSessionAddress` are distinct branded strings. Only
isOrcaSessionId and parseOrcaSessionAddress produce an id, and only
formatOrcaSessionAddress produces an address, so comparing the two is a type error.
The Orca session id columns on the row types carry the id type.

Every reader that compares a stored id with a mail address now compares like with
like: the active-Dispatch ownership check formats the stored id
(orcaSessionAddressSql), and the stray-mail sweep, the creator nesting lookup and the
recorded-worker check bind a parsed or typed bare id. The Run-mailbox ownership
check keeps its existing handle comparison beside the session one.

* fix(orchestration): accept a worker's ask to every address its coordinator is reachable at

A worker's preamble names its coordinator as `session:<id>` when the coordinator is a structured session, but ask only accepted `run:<id>` or the coordinator's terminal handle. A chat coordinator has no handle, and a coordinating structured worker has two addresses, so ask --to the session address was refused as dispatch_run_mismatch. The check now takes the Run's current coordinator addresses from addressSpellingsOf(runCoordinatorKey(run)).

* refactor(orchestration): require every caller-identity entry point to be handed the resolved session

The resolved session parameter was optional on resolveRunScope, resolveOrchestrationCaller, orchestrationCallerIdentity, resolveDispatchCreator and resolveDispatchCallerWorktreeId, so a method that forgot to pass it would compile and silently treat a chat's session address as a terminal handle. It is now required and typed `OrchestrationSessionCaller | undefined`, so leaving it out is a type error. Every call site already passed it; no behavior changes.

* fix(orchestration): deliver mail sent to a session address to the mailbox that session reads

A send to session:<id> was resolved like a terminal handle: no live pane, so a chat's
current Run was missed (two Runs read as ambiguous), a Run-less chat was refused though
it reads its direct mailbox, and a structured worker's session address never reached
its Dispatch. Resolve a worker's session address as its handle, a chat's by its bound
Run, and fall back to the chat's durable direct mailbox while it runs on this host.

* refactor(orchestration): resolve every party through one resolver with one mailbox address

A structured worker is reachable at its handle and at its session address, and a chat only at its
session address. Callers and targets were each compared or resolved at their own site, some against
one spelling and some against every spelling, and the sites that did neither refused or misrouted.

Add orchestration-party: resolveOrchestrationParty (and resolveOrcaSessionParty for a bare id) is
now the only place an address becomes a party. The session caller, the declared-caller check,
ask's target and inbox's filter all resolve through it, so caller and recipient resolution cannot
disagree, including on a recorded worker whose identity this host lost. Every session-to-party step
passes through canonicalOrcaSessionId, the seam later lineage canonicalization plugs into.

mailboxAddressOf replaces addressSpellingsOf: a party has one mailbox address (a worker's handle
today), and Run binding remembers and reroutes only that.

A request with no session id that declares a session address as its caller now gets the party it
names: a worker's handle, as if it had named it, or a session_caller_chat_not_declarable refusal
for a chat, which is identified only by the session id its own environment sends.

* fix(orchestration): route dispatch and mail targets through the party they name

dispatch --to a worker's session address stored that address as the assignee handle, so the worker,
which reads by its handle, never saw the Dispatch. The assignee now resolves through the party
resolver: a worker's either spelling assigns its handle and records its Orca session id. A chat
cannot receive a dispatch yet, so dispatch --to a chat is refused with
session_chat_not_dispatchable before any row is written.

Recipient routing resolves the party once and, for any session-backed party, finds its Run by its
durable binding rather than by a live pane. A structured worker that coordinates a child Run while
assigned in its parent got mail at its parent Dispatch mailbox whenever its session was evicted,
which it never reads while bound to the child Run. Terminal handles keep the same live-pane lookup.

* refactor(orchestration): delete the SQL that matched a worker's second spelling

With every caller and target resolved to one mailbox address, no writer stores mail under a
structured worker's session address: sends resolve it to the handle, a declared session caller is
rewritten to the handle or refused, replies answer a stored canonical sender, and questions,
answers, escalations, federation and legacy mail write run:, dispatch: or handle addresses (legacy
rows are legacy_direct, which these queries never read). The branches that matched that spelling
were unreachable and are removed:

- the session-address OR in activeDispatchOwnsAddressSql, back to the assignee handle alone;
- the session branches in routeForeignDirectMessagesToOwnedMailboxes and
  findActiveDispatchForDirectMessageOwner, which return to the base-branch form.

Tests that inserted such rows directly now pin the behaviour through the verbs: both spellings of a
worker land in the one mailbox it reads.

* test(orchestration): pin one canonical address per party across every caller and target

- every target param in ORCHESTRATION_TARGET_PARAM, for a worker's handle and session address and
  a chat's session address: send, ask, dispatch (a chat refused with no row) and inbox;
- a declared session-address caller on a request with no session id: a worker acts as its handle,
  a chat is refused;
- a worker that coordinates a child Run while assigned in its parent gets mail in the child Run
  with no live pane;
- no mail writer stores to_handle or from_handle as a worker's session address;
- the caller a session id resolves to and the recipient its address resolves to agree in every
  party state, and every session-to-party step goes through canonicalOrcaSessionId.

* refactor(orchestration): drop the session caller's chat-to-terminal-view handoff remnants

The structured-chat terminal handoff is gone from the base branch: a session is owned natively only.

- the lease rule no longer speaks of either owner or a handoff keeping identity;
- an in-progress owner change (new-owner-proving, recovering, manual-recovery, which the base branch
  still produces) is refused as changing owners, not as switching between chat and terminal view;
- the native to terminal-view to native identity test is deleted, and the terminal-evidence test
  no longer describes that evidence as a terminal view's.

* fix(orchestration): read a declared caller param by typed access, not Reflect.get
2026-09-25 18:01:40 -07:00
Brennan Benson 01ce5edf1a fix(claude): end a Claude chat on its root's exit even when a descendant survived (#22946)
* fix(claude): end a Claude chat on its root's exit even when a descendant survived

When Claude's own process exited but a descendant it started survived the close ladder (for example an MCP server that ignores SIGTERM and was born in the second the tree was snapshotted, which the ladder never force-kills), the adapter withheld `ended`, and both the lease release and every later start refused with "provider close unproven". The chat stayed stuck until Orca restarted.

The root is the conversation's only writer and the lease follows it, so a first-hand root exit now ends the session whatever the tree verdict. `claudeRootExitObserved` is the one place that decides it, and crash publication, close finalization and acquisition all read it. A descendant seen alive is logged, and never reported as gone.

* test(claude): drop comments that still say a live descendant holds the lease

* docs(claude): state the root-exit rule without an unqualified only-writer claim

* test(claude): drop the remaining unqualified only-writer claims from root-exit tests

* docs(runtime): say a root-exit settlement's descendants were not proven gone

* docs(claude): say exit recovery also publishes on an observed root exit

* docs(claude): an observed root exit also settles a retained exit

* docs(claude): say an observed root exit is dropped by its own settlement, not the release
2026-09-25 18:00:48 -07:00
Brennan BensonandClaude 6627c6503c refactor(agent-session): keep which conversation each chat tab shows in one host table (#22709)
* refactor(agent-session): keep which conversation each chat tab shows in one host table

The host now persists one table in the agent-session store, from chat tab id to
the conversation that tab currently shows. It replaces both the visible-session
list and the per-record surfaceTabId, so there is one answer to "which chats have
a tab, and under what id".

- /clear moves the tab's entry from the old conversation to its replacement in
  the same transaction that commits the clear. No record carries a copied tab
  id, so a tab id names one conversation by construction, and the lookup from a
  conversation to its tab returns at most one id.
- A create that reserved a tab id claims it in its reservation transaction;
  uniqueness is a key check on the table. A create that fails releases its
  claim, and hiding a chat frees its id.
- Showing a chat with no entry gives it today's id, structured-agent-session-<sid>,
  unless a cleared chat's tab kept that id; then it gets a fresh one.
- A close that does not land puts the tab back under the id it had.
- Stores written by older builds are seeded on read from the visible list and,
  for chats that have one, the record's surfaceTabId. That field is no longer
  part of the record type, is never written, and is read only by this seed when
  the file has no table. The visible list is still written, derived from the
  table, for older builds.

Tab snapshots, status keys and worker pane keys are unchanged.

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

* fix(agent-session): take a reserved chat tab id when its tab is published

A create that reserved a tab id put it in the tab table at reservation, and table membership is
visibility. A create that stopped before its tab was published (a crash, or a failure after the
provider started) left an entry that the next launch either restored as a tab nobody asked to see
again or kept forever with no way to close it. Reservation now only refuses an id another chat's
tab holds; the id is taken when the tab is published, so there is nothing to release on failure.

The create reply now reads the tab id after publishing, so an unreserved create answers with the
id its tab was given, as it did before the table, and agrees with a replay of the same create.

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

* fix(agent-session): seed a chat cleared before the upgrade under its first tab id

A chat cleared on an older build shows a later conversation of its /clear chain in the tab that was
opened for the first one, and clients key that tab, its read state and its status by the first
conversation's id. Seeding gave it the latest conversation's derived id instead, so the stored id
disagreed with the one clients hold and with what a /clear on this build leaves behind.

Seeding now follows the source records' committed clears back to the chain's first conversation
and gives the chat showing the chain that conversation's id. Those chats seed first, so a cleared
conversation reopened from history takes a fresh id when its own is held. A seeded chat is no
longer dropped when every candidate id is taken, and the table keeps the visible list's order.

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

* fix(agent-session): give a reopened cleared chat the same tab id at runtime and at seeding

A cleared conversation reopened from history got a random tab id at runtime but a
deterministic `-reopened` id when the table is seeded from an older store. One rule now
serves both, so a table dropped by an older build and seeded again gives that chat the
id it already had.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 17:30:27 -07:00
Jinwoo Hong 570f070f6c fix(mobile): the page's Live input on Android echoes each letter as typed (OTA phase C follow-up) (#22958)
* test(mobile): the page's live input sends a composed word's letters as they are typed

The reported shape, in a browser: `/tui` on an Android keyboard, where the `/` reached the
terminal and `tui` did not until Enter. Android keyboards hold a composing region over the Latin
word being typed, so on the page every input event mid-word carries `isComposing: true`, and the
preedit mirror holds reported preedit with no settle timer. Native Android reports no range, so
there each ASCII keystroke is sent as it is typed.

Driven through Chromium's own IME path with an Android WebView user agent: the letters after the
slash must arrive one by one, and a correction on commit must still erase and retype. The iPhone
case is the guard that a composition off Android stays held. The hook case says the same thing
without a browser.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the page on Android reports no composing range, as native Android does

`handleLiveInputChange` passed `nativeEvent.isComposing` straight to the preedit mirror. On the page
that event is the DOM's, and an Android WebView marks the keyboard's composing region, which Samsung
and other Latin keyboards keep over every word. The mirror treats a reported range as preedit that
is not text yet, holds all of it with no settle timer, and Chromium fires no input event after
compositionend, so the word sat in the field until the next keystroke or Enter.

Native Android reports no range at all, and its fallback holds only a trailing non-ASCII run. The
page on Android now reports the same: undefined. ASCII echoes on each key, a Hangul or kana run
still settles on the timer, and a correction on commit still erases and retypes. iOS, where the
range is the text system's marked text, is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the Android page among the platforms that report no composing range

The mirror's fallback note listed only React Native Android; the page on Android now reports no
range too. The reader's docblock becomes the one line it needs.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 20:24:54 -04:00
Brennan BensonandClaude add99c908b fix(native-chat): send typed question answers as structured answers, not an option id (#22793)
* fix(native-chat): send typed question answers as structured answers, not an option id

A typed "Other" answer was packed into the `optionId` of
agentSession.respondToQuestion, a field capped at 1024 characters, so a
long answer failed with "Invalid option id" and never reached the agent.

respondToQuestion now carries per-question `answers` in their own field,
bounded like a typed answer, and a host advertises
agent-session.question-answers.v1 when it takes them. Clients fall back to
the packed option id for older hosts. The host reads either form once into
a typed response, records the structured answers on the resolution (and
keeps the packed form older clients read), and the Claude and Codex
adapters build their reply from the typed answers before the journal
commits, so an answer the agent cannot take is refused rather than
recorded unanswered.

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

* fix(native-chat): hold one answer per single-select question card

Typing an answer deselects a picked option, and picking an option leaves the typed
text in the field without sending it, so the card never shows two answers while
sending one. Multi-select still sends picked options and typed text together.

* fix(native-chat): keep keyboard tabbing from re-choosing a typed answer; accept untrimmed question ids

Clicking or typing in the answer field chooses the typed answer; focus alone
no longer does, so tabbing to Submit keeps the option the user picked.
A question id is matched exactly by the host, so the wire no longer rejects
agent-written ids with edge spaces, which older builds accepted.

* fix(native-chat): choose the typed answer on click so a disabled or scrolled field cannot

* test(native-chat): cover pointer events on a disabled answer field

* refactor(native-chat): record the typed answer as a choice in the question card

Choosing the typed answer is now an entry in the question's selection, set by typing
or clicking the field and replaced by picking an option, instead of being inferred
from an empty selection. Unpicking an option no longer silently chooses kept text.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 15:54:46 -07:00
github-actions[bot] 8c1379313a Update README downloads badge 2026-09-25 22:14:01 +00:00
mmarabelandNeil 7d2c399329 fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID (#22516)
* fix(web): keep Remote Web loading over plain HTTP without crypto.randomUUID

Browsers hide crypto.randomUUID outside secure contexts, so Remote Web over
http://<lan-or-tailnet-ip> threw while importing the store and never painted.
createAgentStatusAuthorityId now takes its UUID source (renderer passes
createBrowserUuid, main passes node:crypto randomUUID), and the other
unguarded renderer calls go through createBrowserUuid.

* refactor(renderer): route remaining randomUUID fallbacks through createBrowserUuid

Replaces five hand-rolled crypto?.randomUUID?.() fallbacks (including a copy
of the browser-uuid fallback in mint-stable-pane-id) with createBrowserUuid,
and adds an oxlint no-restricted-properties rule so renderer code cannot call
randomUUID directly again.

* refactor(shared): move the non-secure-context UUID generator into src/shared

The white screen came from src/shared, so the fix belongs there. src/shared had
three hand-rolled copies of the same randomUUID-then-getRandomValues-then-Math.random
ladder (nested-repo-telemetry, project-groups, setup-agent-sequencing) because there
was nothing in that layer to import; createBrowserUuid lived one directory over in
the renderer.

createNonSecureContextUuid() now holds the single implementation, @/lib/browser-uuid
re-exports it under the renderer's existing name so no renderer import site changes,
and the three duplicates call it.

That also lets createAgentStatusAuthorityId go back to one argument. The injected
randomUuid source was justified as keeping browser APIs out of shared code, but this
generator is runtime-agnostic — it works unchanged in Node. Injecting it bought no
layering and made the safe choice a parameter every future caller had to get right,
unguarded: a caller could pass () => globalThis.crypto.randomUUID() and restore the
white screen with lint and tests green.

* fix(lint): ban crypto.randomUUID in src/shared and scope the escape hatch

vite.web.config.ts compiles src/shared straight into the web bundle, but the new
randomUUID ban only covered src/renderer/src — so the exact module that white-screened
the app sat outside the guard it shipped with, and the regression could come back with
a green lint. The override now covers src/shared/**/*.ts too; it costs zero diagnostics
because the duplicates it would have flagged are gone. `import { randomUUID } from
'node:crypto'` is untouched, so main-only shared modules keep working.

Both blanket "off" overrides are gone. no-restricted-properties is keyed by property
name, so the moment a second property joins the renderer block those overrides would
have silently exempted it — in the one file that is the escape hatch, and in every test
in the repo. Tests are where people copy patterns from, so they stay covered; the four
real uses carry line-scoped disables with a reason.

* fix(terminal): keep render-desync capture ids inside main's 120-char cap

createCaptureId builds `${Date.now()}-${panePart}-${nonce}`. A real paneKey is
`${tabId}:${leafId}` — two UUIDs, 73 chars after sanitizing — so with a 36-char UUID
nonce the id is 124 chars and main rejects it with 'Invalid render-desync capture id'.
persistHealedReference swallows that into console.error, so it shows up as diagnostics
that silently never appear.

This was already broken on the desktop app, where randomUUID is available; routing the
non-secure path through the same generator would have made it unconditional, including
on the plain-HTTP web client this branch exists to repair.

Bound the pane part rather than the nonce: keep the trailing 40 chars, which is the
whole leaf id (the identifying half, unique on its own) and drop the tab-id prefix, so
ids stay unique and traceable at 91 chars. The 120-char contract now lives in
src/shared next to the IPC args, imported by both sides, so the renderer cannot mint an
id main will reject without the test noticing.

* test(web): cover the whole store graph and the Vault token without randomUUID

The reported stack was the store chunk, not two named modules, so the repro test now
evaluates the store root under the stubbed non-secure crypto. Any new import-time
secure-context call anywhere in that graph fails here, not just the one this branch
removed.

Also ports the request-token regression from #20465, the one piece of coverage the
competing branches for this bug contributed that this one lacked. Both cases fail with
"randomUUID is not a function" when their production change is reverted.

* test(web): restore the real crypto.randomUUID after the non-secure Vault case

randomUUID lives on Crypto.prototype, so stubbing it as an own property of
globalThis.crypto left the restore branch with an undefined descriptor and a
leaked own `randomUUID: undefined`. Swap the whole crypto own property instead,
through one shared stub the repro suite already needed.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-09-25 15:01:30 -07:00
Brennan Benson 2796a3ac15 fix(claude): prove a stopped chat's child processes gone when they exit with it (#22918)
* fix(claude): prove a stopped chat's child processes gone when they exit with it

Stopping a Claude chat snapshots its child processes, closes Claude, and then verifies each child is gone before the stop counts as proven. The verifier only accepted a child as gone after that child had appeared in one of its own process-table reads. When Claude exits gracefully it takes its short-lived children with it before the first read, so none of them was ever seen again. Every read confirmed them absent, and the verdict was still "unverifiable" after the full 3.5 s window. Measured live: 37 complete reads, target absent from all, verdict unverifiable, on every idle stop.

The snapshot is itself a table read that saw each child alive, so it now counts as the first sighting. An absence counts only from a read that started after the child was last seen, which keeps what the old rule protected against: a shared or in-flight read begun before the snapshot cannot list a child forked since. Two such absences prove a child gone. Live, the same stop now proves the tree gone in about 150 ms.

The daemon's terminal shutdown uses the same verifier and gets the same rule. Test reads that reused one capture stamped with the snapshot's own time now stamp each read when it starts, as real scans do.

* fix(claude): keep the latest sighting and count the final read as an absence

A matching read that started earlier but resolved later could move a target's
last sighting back and let an older absence count; the sighting now only moves
forward. The read after the deadline now records its absences the same way the
polling loop does, so a second qualifying absence there proves the target gone.
2026-09-25 14:57:31 -07:00
841503152c fix(runtime-environments): don't crash when a server removed via the CLI still responds (#22517)
* fix(runtime-environments): don't crash when a server removed via the CLI still responds

orca environment rm edits the environment store behind the running app, so the
next ok response on a live socket called markEnvironmentUsed, which threw
'Unknown environment' out of an unguarded socket callback. Main-process callers
now use markEnvironmentUsedIfPresent, which skips a missing environment and
keeps every other store error; the status owner pauses shared control instead
of re-establishing it for a removed server.

* fix(runtime-environments): guard usage bookkeeping at the main-process boundary

Keep one strict store contract and move the leniency to the caller that cannot
report a failure to anyone.

- Revert markEnvironmentUsedIfPresent: drawing the line around one error string
  left corrupt, unreadable and oversized store files still fatal on the same
  unguarded socket callback.
- Add recordRuntimeEnvironmentUsage, a named main-process boundary that says
  lastUsedAt is advisory and swallows every store failure. Route only the three
  sites with no observer through it (subscription onResponse in transport- and
  support-routing, and the status owner's verified hook, where a throw skips
  settleWaiters and hangs refresh callers). Awaited request paths stay strict.
- Guard onResponse/onBinary in the subscription frame router the way the sibling
  request router already guards validateStatus, so no consumer throw can reach
  the ws 'message' emitter and become main_uncaught_exception.
- Drop the status-owner `capable && present` gate: pauseStandingRetry no-ops
  while subscriptions exist, and removal teardown belongs to #21048's watcher.

Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>

* test(runtime-environments): cover the real socket path a consumer throw escapes

The existing tests invoke the captured onResponse directly, which never touches
the surface that actually kills the app. Drive a real WebSocket server through
subscribeRemoteRuntimeRequest so the throw travels ws 'message' -> handleFrame
-> consumer; without the frame-router guard vitest reports it as an unhandled
error, which is main_uncaught_exception in production.

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: mmarabel <mmarabel@users.noreply.github.com>
2026-09-25 14:48:54 -07:00
c220d92c03 fix(codex): Codex 0.157+ starts in Orca-managed homes instead of failing with SUN_LEN (#22878)
* fix(codex): turn off Codex daemon auto-start in homes whose socket path exceeds sun_path

Codex >= 0.157 auto-starts a background app-server daemon and connects to
<CODEX_HOME>/app-server-control/app-server-control.sock. Orca's managed homes
under userData make that path longer than sun_path (104 bytes on macOS, 108 on
Linux/Windows), so every interactive codex in an Orca terminal failed with
'path must be shorter than SUN_LEN'. The config mirror now writes a marked
[features] daemon_auto_start = false into only those homes, removes it when the
home fits, and never promotes it into ~/.codex.

* fix(codex): address review of the daemon socket guard

- A runtime config.toml holding only Orca's daemon override no longer reads as a
  config-sync stall, so users without ~/.codex/config.toml get no false
  "missing" warning in the accounts pane.
- The legacy shared-home refresh re-applies the guard, so retained pre-rollout
  panes keep daemon auto-start off after a system-default launch.
- Warn once when an inline `features = {...}` or `[[features]]` blocks the
  override instead of failing silently.
- Rename the upsert's TUI-specific internals now that it serves any table.

* fix(codex): apply the daemon socket guard even when the settings mirror stalls

When the settings write-back or mirror refused (unreadable baseline, failed
write to ~/.codex, unreadable source), the whole pass returned before the
daemon guard was applied. A home whose config.toml predates the guard then
kept failing with SUN_LEN on every launch for as long as the stall lasted.
The guard now lands on those paths too; the mirror itself is unchanged.

* fix(codex): guard managed account homes when ~/.codex/config.toml is missing

* test(codex): keep reset-credit ownership checks scoped to the retry, not service construction

* test(codex): build the account mirror test without a type cast

* fix(codex): keep blocking WSL ownership checks off the no-config guard pass

Guarding account homes with no ~/.codex/config.toml ran the WSL ownership
check, a synchronous wsl.exe call per account, at startup before the window
opens and on every account switch. WSL homes are guarded by WSL launch prep,
so that pass now covers host homes only.

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-09-25 14:29:08 -07:00
Jinwoo Hong 64704daac5 feat(feature-tips): one-time tip for agent session search (#22923)
* feat(feature-tips): one-time tip for agent session search

Session search is only discoverable from Settings. Add a feature tip that
existing users see once, which turns search on, shows the first index
build's progress in place, and opens the sidebar search once it is ready.
A toast says when a build left running in the background finishes.

Also share the two-column tip layout across the voice, Cmd+J and session
search dialogs, and move the CLI tip dialog into its own file.

* refactor(feature-tips): simplify the session search tip after review

- Watch for a background finish only when this tip closes mid-index; closing
  any other tip no longer arms a stray "ready" toast.
- The hook detects the close itself, so dialogClosed/reset and the onStatus
  callback on useSessionSearchStatus are gone.
- Table-driven dialog copy, reuse FeatureTipActions, and share the eyebrow
  badge and settings link across the voice, Cmd+J and session search tips.
- One getPendingFeatureTips for the startup gate and the modal.
- Demo: a phase timing table and hoisted header props.
- e2e helpers mark the new tip seen too.

* fix(feature-tips): retire the session search tip once the user has switched search

Turning session search on or off in Settings, or enabling it from the
sidebar, now marks the tip seen, the way the Voice switch does, so a user
who turned search on and later off is not pitched it again.

* test(ai-vault): give the legacy-filter store mock markFeatureTipsSeen

Also mark the tip seen only after the sidebar's enable actually saves.
2026-09-25 17:14:15 -04:00
Brennan Benson acf8e679ea feat(native-chat): Claude sessions write their subagents into the host status store (#22536)
* refactor(native-chat): the host hands out client delivery's status subscriptions as they are

subscribeStatus and subscribeTurnCompletions wrapped client delivery's bound
methods in forwarding lambdas; they are now the same members, the way
waitForSendSettlement already is. The host is at its size limit, and the
next channel it hands out needs the line.

* feat(native-chat): Claude sessions write their subagents into the host status store

The Claude background-task tracker queues child-work evidence at each decision it
already makes (start, update, progress, terminal frame, roster replacement, turn
end, session end), plus the two facts its legacy row ignores: a foreground child's
progress and a foreground spawn call's result. The adapter drains that evidence
after the journal handled the frame and the parent row was republished, and the
host folds it into one record per child in its canonical store.

Nothing reads the records yet; the strip and sidebar keep their current sources.

* test(native-chat): pin the Claude child-work evidence and the host reduction of it

* test(native-chat): prove every hop from a Claude frame to the host's child record

The adapter delivers evidence after the frame's journal rows and the parent's
republished row; the frame script keeps the parent state today reads while the
records add outcome and activity; the runtime hands the evidence to the status sink
under the session's own address; both entry points wire the sink to the ingest.

* test(native-chat): read an optional task list as optional in the producer script

* test(native-chat): an address whose publish threw carries no child work

* test(agent-status): a foreign record differs from ours by producer alone

* feat(native-chat): a foreground Claude child's own tool call is what its record says it is doing

A child's tool traffic reaches the parent stream only for a foreground child. Read
after the journal handled the frame, the child's newest call still awaiting a result
becomes its open operation, previewed the way a hook-reported row previews its own
tool; the result closes it. The open call is derived from the journal's own
bookkeeping, not held a second time.

* fix(native-chat): a Claude child restarted under a new spawn call keeps reporting to its record

A task that ended and starts again stays hidden from the legacy row until a roster
lists it, so the tracker held no run for it: the new run's progress reached nothing
and a foreground re-run's own spawn result settled nothing. The run is now held
beside the live map, where the legacy row never reads it, until a roster hands it
back or it ends. A parity test pins the record's run count to the journal roster's
attempt on a new spawn call, the one event both count.

* refactor(native-chat): the Claude child-tool queries and translator contract get their own homes

The translator's child-tool queries move into claude-child-tool-queries.ts and its contract
type into claude-journal-translator-contract.ts. Brings the translator back under the size
limit.

* refactor(native-chat): Claude child evidence carries only its own edge's facts

Admission now keeps what a child's record already knows: labels, model,
owner, residency, the last message within an invocation, and a token count
that never shrinks. The evidence side copied all of those forward itself, a
second owner of the same rule. It now sends only what this edge observed,
and a task's token count comes from the frame that reported it.

* refactor(native-chat): Claude child evidence hands admission its raw labels

Admission now folds provider text to one line and drops a malformed fact
instead of refusing the record, so the evidence side no longer folds labels
itself. The description keeps admission's longer bound.

* fix(agent-status): admission alone decides a settled child's second ending

The reconciliation returned before admission whenever a record had already
settled with a definite outcome. That dropped the evidence an `unknown` ending
carries (its last message and tokens), which admission's refine-only rule keeps,
so that rule never ran for the structured producers.

The latch goes. Admission keeps the definite outcome, lands the late evidence,
and refuses a conflicting definite ending as `stale-invocation`, which the host
ingest already counts as the fence doing its job, not a fault.

Pinned through the real Claude producer and the host's own ingest.

* perf(agent-status): keep child records off the status hot paths

Child records made every store write and every status notification scale with the
whole store. Each Claude child progress frame cost about 2 ms with 5 chats holding
~200 child records (about 14 ms at ~1,400), and every status change on any lane
re-parsed every child record just to list parent rows.

- The store derives each frozen record's key once instead of re-parsing it on every
  mutation's validation and every alias lookup.
- Settled history is trimmed only when a batch settles something.
- Parent listing and the structured row's revision stamp read the parents and the
  revision directly instead of building a full snapshot.

A progress frame now costs about 0.3 ms at the same size, and listing parent rows no
longer depends on how many child records the store holds.

* fix(native-chat): an errored Claude spawn result no longer decides how the child ended

Interrupting a foreground Claude agent while it runs a tool delivers the spawn call's errored
result before the child's own killed/stopped frames. The spawn result settled the record
`failed` first, and admission then refused the later `cancelled` as a conflicting ending, so an
interrupted child read as a failure.

An errored spawn result now settles the child `unknown`; the child's own terminal frame refines
it to `cancelled` or `failed`. A successful spawn result still settles `succeeded`. The test
replays both frame orders the real CLI produced when interrupted.

* test(native-chat): pin a Claude foreground child's real finishing order

The real CLI ends a foreground agent with its own completed update, then a notification
carrying the final summary and usage, and only then the spawn call's result. Existing tests
modeled the spawn result arriving first, so nothing checked that the notification's summary
and tokens still land on a record the update already settled.

* perf(agent-status): a store write costs what it touches, not the whole store

With child records on the host, every mutation copied all five store maps and re-validated
every record, and reads scanned every child and alias. A parent status publish cost about
10 ms with 4,000 child records in the store, and a child update about 13 ms.

- A mutation writes into drafts over the committed maps and lands in place; a refused one
  is dropped with nothing to undo. The drafts keep the exact map order a copy would have.
- Only what a mutation touched is re-validated: touched parents, children, aliases, facts
  and tombstones, plus every alias of a touched child and whatever a removed parent owned.
  The full validation stays for snapshot restore.
- The snapshot byte budget is a running total instead of a re-measure.
- Children by parent, facts by parent, aliases by child, aliases by identity and retired
  aliases are indexed, so reads return stored records without scanning or re-parsing.
- The memoized alias identity and tombstone-key checks are gone: indexes derive them once.

A parent publish now costs about 0.015 ms and a child update about 0.06 ms at 40, 1,000 and
4,000 children alike. A seeded fuzz holds the store to the copy-and-validate-everything
path decision for decision, snapshot for snapshot and read for read, and a replica fed the
envelopes ends identical.

* fix(native-chat): a Claude child ends only on its own terminal frame

The child records were fed from the legacy background-task tracker's display decisions, so
they inherited rules that are not truth: a turn ending swept foreground children, a roster
omitting a background child settled it, a foreground spawn call's result ended the child,
and a new background start after any roster produced no record. Captured from the real CLI,
an agent moved to the background keeps its own shell running for 40 s after the parent's
turn ends, and that shell was settled `unknown` at the parent's `result`. Replayed with the
spawn result ahead of the roster, the same agent settled as a false success and was then
revived as a spurious second run.

A new decoder reads the task frames directly. `task_started` opens a child (a start for an
ended task id is a restart, the way messaging a finished agent resumes it), progress and a
live `task_updated` update it, and a terminal `task_updated` or `task_notification` ends it.
Rosters, turn ends and spawn results say nothing about a child. Every child in every capture
gets its own terminal frame, so no evidenced ending is lost. The notification's `tool_use_id`
names the run that ended (captured on a resumed agent's second run), so an ending from a run
that is already over no longer ends the current one; a run id the record never saw still
ends it, so nothing strands.

The tracker, its settled-task retention and the frame readers are back to exactly what main
has: the aggregate-roster split and the restart holding map are deleted, and the legacy row is
unchanged by construction.

* fix(agent-status): a session's end settles its live children instead of erasing them

When a structured session ended, the reducer removed every child record it held, finished
or not, so a reader could no longer tell how the session's work had ended. Now a child still
live when its session ends settles `unknown` (nothing reported how it ended), and a child that
had already ended keeps its outcome. The records still die with their parent: closing or
releasing the session drops the parent row, and the store drops its children with it. A
child's own outcome arriving after the session ended still refines the `unknown`.

The `inventory` and `turn-ended` edges, and the rules that settled children on a roster
omission or at a turn boundary, are deleted: no producer sends them any more. A restart is
now its own flag on a live edge, which is what a producer reports when a finished child
starts again under the same run handle.

* test(native-chat): replay the real Claude CLI's frame orders into a real host

Scrubbed cuts of five Claude CLI 2.1.280 stream-json captures (ids, paths and prompts replaced,
frame order and relative clock kept), replayed through the adapter into a hook server:

- an agent moved to the background keeps its own shell live past the parent's turn, and the
  shell settles at its own notification's time;
- the same capture with the spawn result ahead of the move ends the agent once, from its own
  notification, with no second run;
- a roster that omits a background child without its own ending leaves it live;
- a session that ends settles what still runs `unknown` and keeps every record;
- messaging a finished background agent opens its second run, which ends from its own frame;
- interrupts in both captured orders end `cancelled`, and a finished foreground agent keeps
  its summary and usage.

* test(agent-status): hold the store's running indexes and byte total to a rebuild

The copying-store fuzz never reaches the snapshot byte budget, so a drift in
the running byte total (or any index the public reads do not surface) passed
it. After every fuzzed step, including refusals, compare every index with one
rebuilt from the committed maps.
2026-09-25 12:50:50 -07:00
Jinjing f59ee6c859 Simplify .map().flat() to .flatMap() in onboarding tests (#22917)
Replace the two-method chain with the equivalent idiomatic flatMap method for clearer, more concise code.
2026-09-25 12:41:04 -07:00
Shahar Mor 8fb13edd9f fix(mobile): keep terminal input working when reopening worktrees (#22505) 2026-09-25 12:09:21 -07:00
Brennan Benson 01ed4b92f7 fix(desktop): release a native chat when you leave it, so its idle clock can start (#22801) 2026-09-25 11:44:28 -07:00
Jinjing c1edd1d97d chore(i18n): translate 85 new keys to es/fr/ja/ko/zh (#22746)
* Add translations for chat resume, Git toolchain, and notebook support

* Fix translation terminology in French and Korean locales

- Standardize Korean terminology from "restart" to "resume" for chat
  resume functionality
- Clarify French error message for conflicting Orca windows/terminals
- Fix Korean context translation (문맥 → 컨텍스트)

* fix jupyter notebook translation
2026-09-25 11:43:09 -07:00
Brennan Benson 9643e16fdb fix(native-chat): keep an idle chat alive while its subagents or background commands run (#22794) 2026-09-25 11:42:16 -07:00
Jinjing 56dfddc297 Show close button for single terminal panes (#22770)
* Show close button for single terminal panes

Single-pane terminals previously had no close affordance; now display "Close tab" button while multi-pane terminals show "Close Pane". Pinned tabs omit the close button. Refactored terminal-unified-tab-lookup to include tab pinned state alongside chat view fields.

* Show close button for titled single terminal panes

For split panes, the X button remains remove-title only. For single panes
with titles (including agent terminals that acquire runtime titles), a
close tab button is needed to close the pane.
2026-09-25 11:41:56 -07:00
Brennan Benson 8009939381 fix(claude): let a Claude chat start again after its root exited with unverifiable descendants (#22802)
* fix(claude): let a chat stopped with unverifiable descendants start again

When the idle release clock stopped a Claude chat whose root process exited but whose descendants could not be verified, the host released the lease and forgot the session, as designed. The adapter, though, kept its session indexed as not yet closed. Every later start of that chat first tried to close the stale session again, got the same verdict, and was refused with "provider close unproven". The chat then read as "Could not load conversation" until the app restarted.

A proven root exit or processless close now finalizes the adapter session like a proven close: it persists the resume point, emits the end, and drops the entry, and it still reports the verdict to the caller. Only a genuinely unknown exit stays indexed for a retry.

* fix(claude): let a chat whose crash left unverifiable descendants start again

The same refusal had a second emitter. A Claude root that crashed with unverifiable descendants stays recorded as an exit, and the next start of the chat re-checked that exit and refused on the root-exit verdict, although that verdict is what let the host release the lease. The start now proceeds and settles the recorded exit; only an exit this host cannot vouch for still blocks it.

* fix(claude): let a start over a live session whose close saw the root exit go ahead

Closing the previous session finalizes it before reporting the root-exit verdict, so the start
that closed it must not fail on that verdict, the same as a retained root exit.

* fix(native-chat): read a root-exit stop the same way at every host stop site

Idle eviction already treated a stop whose provider root was seen to exit as a stop, but handing a chat to the terminal and recovering from a journal write failure treated the same verdict as a failure. Now that the Claude adapter finalizes the session before reporting that verdict, those two paths left the host holding a child the adapter had already ended: the first hand-off failed with "provider close unproven" and sends failed until the chat was evicted.

One function now reads a stop's result for eviction, hand-off, sink-failure recovery and the adapter's own start-over-a-live-session path.

* fix(claude): publish a crash whose root exited even when its descendants went unverified

A Claude crash was published to the host only once its whole process tree was proven gone, or when it failed during startup. A crash whose root was seen to exit but whose descendants could not be verified was held back, so the chat stayed marked live: sends failed with the crash message and nothing restarted Claude until the idle clock stopped the chat. The owner already releases the lease on a first-hand root exit, so the crash now publishes like a proven one and the host reconciles and recovers at once.

* test(claude): prove an open chat restarts after a crash whose descendants went unverified

The crash-publication test only asserted the lease was released, so nothing showed the restart the fix exists for. It now holds the chat as an open surface does and asserts Claude comes back on the saved session with a live lease.

* test(claude): settle the in-flight send and deliver the next one across a root-exit crash restart

The restart test now also covers the sends around the crash: a message Claude took but never echoed settles as exited before acknowledgement instead of waiting forever, and a send after the restart is accepted by the new process.
2026-09-25 10:55:08 -07:00
Brennan BensonandClaude d443320af2 refactor(native-chat): remove the unused terminal handoff (#22783)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

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

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

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

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

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

* test(native-chat): type the stalled snapshot drain without a cast

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

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

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

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

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

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

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

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

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

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

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

* test(native-chat): type the stand-in roster decoder without a cast

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

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 10:17:36 -07:00
github-actions[bot] 646e9a5b02 Update README downloads badge 2026-09-25 12:38:07 +00:00
Jinwoo Hong c9d421f3c1 fix(mobile): the page's terminal shows its last rows above the command dock (OTA phase C follow-up) (#22806)
* fix(mobile): mount the page's terminal frame with its onLayout

On the page every branch of the session content is a bare View in one
slot, so the terminal frame reused the loading View. react-native-web
observes onLayout only on a View that mounts with it, so the frame never
reported its height: every measure fell back to the page's
window.innerHeight and the PTY got rows the frame cannot show (63 vs 47
on a 1280x2856 screen), with the last rows under the command dock.

Keying the frame makes it mount fresh. The parity pin moves for the key
string.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): size the terminal document to its host, not the window

Inside the WebView the window is the terminal frame; on the page it is
the whole page, header and dock included. Every fit, pan, scroll and
overlay bound in the document read window.innerWidth/innerHeight, so a
measure without a container height sized the PTY to the page.

The document now reads one seam, viewportSize: the window by default
(native unchanged), the host element's box on the page. A census keeps
raw window size reads out of document/.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): put the terminal document's coordinates in its host

Round one moved the page document's size to its host but left its
origin at the window, so a tap below the session header mapped rows low
(row 19 for row 11), and the selection overlay and scroll indicator,
position: fixed, drew over the page.

- viewportRect replaces viewportSize: the host's box on the page, the
  window at 0,0 in the WebView. viewportPoint is the one place a client
  point meets that origin; cell mapping, mouse reports, the pinch anchor
  and edge scroll go through it, and a census holds the rest to deltas.
- observeViewport: the fit refits on a host ResizeObserver on the page,
  a window resize in the WebView.
- A hidden host measures 0x0; the fit keeps scale 1 rather than 0.
- On the page the host is the overlays' containing block.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): state what native measured in the parity note

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): read the page terminal's overlay and refit off its host

The overlay is absolute inside the host now, and the page's refit
follows the host's box, so the owed-frame case pulses the host rather
than the window.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): hold the terminal fit while its host has no width

computeFitScale answered 1 for a 0-wide host, but applyFitScale still
committed that fit as soon as the cell width was known, so a display:none
screen got a fit for no box. The attempt now leaves the fit pending until
the host reports a positive width, and observeViewport starts it again
once it does: one commit, never at scale 0.

A regression test pins edge scroll to the host's edges: with the host at
top 100 and height 600, the down band starts at client Y 660, not 560.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): skip the terminal refit while its host has no box

react-native-screens hides the session with display:none when another
screen covers it, so the host's observer reports 0x0. The refit still
clamped pan and repainted, so coming back to the session lost the pan
the user left; native never refits on navigation. The refit now does
nothing until the host has a positive width, and the next real box
refits once.

Also untangles the seam-count sentence in document-host-seams.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refit the page terminal only when its box changes

Coming back to a session takes the host from 0x0 to its old box. The
observer fired, the refit committed, and the user's pan and zoom were
reset, where native keeps them because navigation never resizes its
WebView. The fit now remembers the box it was committed for, and the
refit acts only on a positive box different from that one.

The frame tests wait on the document's own frames instead of a timer,
and the hidden-host case asserts the zero-width read happened.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): refit a shown terminal when a fit was asked for while hidden

The zero-width gate drops a fit requested while the host is hidden, and
the same-box skip then kept the stale fit when the host came back at its
old size: 80 columns at 390 px stayed at the 55-column scale, 0.945
instead of 0.65. Any fit request now forgets the last fitted box, and so
does a text-scale change that could not resize a hidden grid. A plain
hide and show asks for no fit, so it still keeps pan and zoom.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-25 06:03:37 -04:00
NeilandDeryFerd 46079aa20b fix(lint): include .mts and .cts in line-limit checks (#22413)
Co-authored-by: DeryFerd <DeryFerd@users.noreply.github.com>
2026-09-25 03:01:52 -07:00
745cde69cd fix(feedback): send text-only report when screenshots exceed the upload limit (#22508)
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
2026-09-25 02:49:52 -07:00
Neil 69839c253e feat(zcode): explain a ZCode build that has no terminal UI (#22730)
* feat(zcode): explain a ZCode build that has no terminal UI

ZCode ships one agent runtime behind two front ends. The desktop app bundles it
without `@zcode/tui`, because it draws its own window in Electron. Put that
bundle on PATH as `zcode` and it answers `--version`, runs `-p` headlessly, and
passes `zcode doctor` — so Orca detects it, launches it, and installs hooks
against it, all successfully. Only the interactive session fails, leaving a bare
Node stack trace in the pane that reads as a broken Orca integration.

Watch a freshly launched ZCode pane's first output and replace that with an
explanation: Orca's hooks are fine, this `zcode` just cannot open a session,
install one that ships the TUI.

The rule keys on Node's own module-resolution error rather than on the healthy
build's "TUI requires an interactive terminal." message, because ZCode localizes
the latter (`TUI 需要交互式终端。` in zh-CN) and matching it would miss every
non-English user. Node's error is not translated and names the package.

Scoped so it costs a healthy pane nothing: it runs only for a pane Orca launched
as `zcode`, and only over the first 8 KiB, because a module-resolution failure
happens before the runtime renders anything.

Evidence: `src/main/runtime/__fixtures__/zcode-missing-tui.txt`, a recorded PTY
capture of the desktop bundle refusing to start, per
docs/reference/agent-pty-transcript-capture.md.

Reported-by: JWu527

* refactor(zcode): ask the CLI if it can open a session instead of watching for the failure

The stream watcher this replaces never fired. Before/after screenshots were
identical and instrumentation showed the hook never ran, so the sidecar was
both misplaced and racing a failure that lands ~440ms after spawn.

Replace it with a direct question, answered once per run and cached.

Reading zai-org/ZCode shows why running it is the only way to ask, and why the
answer is unambiguous. `--version` and `doctor` are byte-identical in shape
between a build that has the terminal UI and one that does not, because the TUI
is only ever touched on the `tui` command path. There, `runTuiCommand` calls
`loadTuiRuntime()` before anything else, and `runTui` checks for a TTY only
after that module is already loaded. So with stdin at EOF:

  - no TUI  -> fails in the loader  -> Node's module-resolution error
  - has TUI -> loads, then declines -> "TUI requires an interactive terminal."

The module error is therefore present exactly when the terminal UI is absent.
All three shipping shapes land correctly: an npm/node-bundle install resolves
`@zcode/tui` as a real package (esbuild marks it external, so it is never
inlined), a SEA build always carries it as embedded assets, and the desktop
app's bundled runtime carries neither.

Verified against both real builds on this machine rather than a mock: the
desktop bundle answers `missing-tui`, a CLI built from source answers
`interactive`, and a command that does not exist answers `unknown` — the probe
fails open so an unrelated spawn failure never accuses a working CLI.

* feat(zcode): warn at launch when the installed zcode cannot open a session

Wires the capability probe to the one place a ZCode launch is first known:
terminal tab creation, which runs before the pane connects, so the explanation
reaches the screen alongside the failure rather than after it.

- main exposes the cached probe over `preflight:zcodeInteractiveCapability`,
  beside the other "what can the installed CLIs do" answers
- the web preload stub answers `unknown`, because a paired client has no
  business deciding anything about the host's CLI install
- the renderer notice is advisory: a probe that cannot run never blocks a launch

Verified in the running app against the real desktop bundle: creating a ZCode
workspace now shows "This ZCode build has no terminal UI" next to the stack
trace, where before the trace stood alone.
2026-09-25 02:28:40 -07:00
Neil 90801e2deb feat(agents): add first-class ZCode harness (#22464)
* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
2026-09-25 02:17:51 -07:00
Brennan BensonandClaude f5d2ce5de7 fix(native-chat): keep the background-task strip above a pending prompt card (#22779)
A pending approval or question card replaces the composer, but it rendered
above the status block, so the running agents/shells strip dropped below the
card. Render the cards in the composer's slot instead.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 00:38:45 -07:00
Brennan Benson 67fc894c8e refactor(orchestration): give structured sessions an orchestration actor column (#22522)
* refactor(orchestration): give structured sessions an orchestration actor column

Adds nullable session:<id> actor columns to runs (coordinator) and
dispatch_contexts (assignee, creator) at schema v42, a shared codec, a
fill for rows that provably belong to a structured worker, and a
coordinator mail-address cache that remembers a handle-less coordinator
by its actor address.

* test(orchestration): pin the actor columns, their fill, cache and v40/v41 upgrade paths

* refactor(orchestration): fill structured-worker actors from one open-time call site

* fix(orchestration): refuse terminal handles as session actors and clear the assignee actor on reassignment

* test(orchestration): read the current schema version from its constant in the delivery downgrade contract

The contract asserted user_version 41 after old code reopens a database
current code wrote, so the v42 bump failed it. Assert SCHEMA_VERSION so
the next bump cannot strand it; the pre-v41 pin and its v40 stamp stay.

* fix(orchestration): count a Run's coordinator actor only at the generation it was written at

A binary without the actor column rebinds and unbinds a Run by rewriting its handle and pane,
which it cannot clear the actor beside. A rebind followed by an unbind leaves a row identical
to a live chat binding. Both writes bump consumer_generation, which every binary already
maintains, so the actor now carries the generation it was written at
(coordinator_actor_generation, set in the same statement) and counts only while the two are
equal. The coordinator cache, its triggers and the open-time fill read the actor through one
rule in run-coordinator-actor; the fill also replaces an actor an older generation left behind.

Still schema v42 (unreleased): the column joins migrate-v42 and the v42 skew-probe entries, so
a database stamped v42 without it replays the chain.

* refactor(orchestration): drop the unused coordinator-actor index and bare-id normalizer

Nothing in this stack looks a Run up by coordinator_actor: callers load the Run and compare its
current actor, so idx_runs_coordinator_actor would ship in every database with no reader. v42 is
unreleased, so it leaves the migration rather than needing a later drop. normalizeOrchestrationActor
had no caller outside its tests; bare session ids enter through sessionOrchestrationActor, and the
handle-refusal cases stay covered there and in parseOrchestrationActor.

* fix(orchestration): keep the coordinator-actor index the caller lookup needs

The next step finds a caller's Runs with one statement that ORs a pane-leaf
match with `coordinator_actor = ?`. SQLite splits that OR across two indexes
only when both sides have one; without idx_runs_coordinator_actor the plan
falls back to scanning every Run on each lookup. v42 is unreleased, so the
index returns to migrate-v42 rather than needing a later schema step.

* refactor(orchestration): store the Orca session id instead of an "actor"

"Actor" read as a new concept when the columns only ever named a structured
session. Rename them to what they hold: coordinator_orca_session_id (with its
_generation), assignee_orca_session_id and creator_orca_session_id, plus the
matching indexes, still added by migrate-v42 since v42 has not shipped.

The columns now store the bare Orca session id rather than session:<id>. The
session:<id> mail address is derived from ORCA_SESSION_ADDRESS_PREFIX where
mail needs it: the coordinator address triggers and the cache refill share
one SQL builder. isOrcaSessionId keeps refusing terminal-handle-shaped ids, and
the generation rule and backfill evidence rules are unchanged. A dev database
stamped v42 with the earlier *_actor columns replays the chain and gains the
new ones.

* fix(orchestration): remember every address a Run coordinator has, not the handle first

The v42 coordinator triggers and the on-open refill stored one address,
COALESCE(handle, session address), so a structured worker coordinator was
remembered by its handle only. Remember each address the coordinator has,
its handle and its current session address, each where present, so this
cache follows the same rule as bindRun and no precedence is persisted.

* docs(orchestration): define the Orca session id without a variable this change does not add

The shared codec's comment named ORCA_AGENT_SESSION_ID, which nothing in this
change defines, and ran one line past the wrap. It now says the stored id is
the one the agent is addressed by (a /clear'd chat's lineage root), as the
column comments do, and that PTY agents have none today rather than never.
migrate-v42's note stated the lineage rule twice; it is folded into one sentence.
2026-09-25 00:35:06 -07:00
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