Commit Graph
11683 Commits
Author SHA1 Message Date
Jinwoo Hong c4e58fac56 fix(mobile): restart the streamed browser pane on every return to the app (#22694)
* fix(mobile): restart the streamed browser pane on every return to the app

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fails with the compositionend listener removed.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A lowercase m read as minutes on the context card.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #22324

* Fix double-toggle of symlink directories during rename

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

---------

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

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

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

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

Fixes STA-8200

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

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

Refs STA-8200

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

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

Refs STA-8200

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

Refs STA-8200

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

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

Refs STA-8200

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

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

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

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

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

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

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

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

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

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

The doc said the Claude hook lane's rows match Codex and that every lane feeds a
child's wait into the fold. Neither holds: Claude keeps the wait in one slot the
next main agent event overwrites, the structured lane turns a child's prompt
into the main agent's own attention, and Codex drops its roster on a root Stop
when it tracks no child transcripts. The parity story now drives the structured
lane with the input it actually receives.
2026-09-23 22:09:07 -07:00
Brennan Benson 7a4f080086 revert: #18790 (orchestration incarnation reap fallback and bundled Freebuff agent) (#22601)
This reverts commit 0677271709.

#18790 was merged as one squash commit that carried two unrelated changes:
a process-incarnation fallback for reaping leaked orchestration worker
terminals, and an unannounced "Freebuff" third-party agent (catalog entry,
icon, locale strings, README rows). The Freebuff agent was never meant to
ship, so the whole PR is reverted; the reap fix should be re-submitted on
its own.

Until that re-land, a worker whose durable terminal handle goes stale is
again reported missing on release/stop instead of being re-found through
its process incarnation, so its terminal can leak on Remote Server.

The mobile session page closure pin moves 4218 -> 4219: the revert drops
the freebuff icon #22119 pinned (-1), and #22452 had already added two
src/shared modules without re-pinning (+2).
2026-09-23 21:39:28 -07:00
Brennan BensonandClaude 3ea15dd0a2 fix(native-chat): keep chats that failed to resume in the status bar and say what to do (#22448)
* fix(native-chat): keep chats that failed to resume in the status bar and say what to do

After a restart, a chat whose resume did not carry on was reported only by a
four-second toast that named nothing, and the status bar entry vanished because
the reattach had already spent the offer.

The host now files the outcome as a durable `failed` entry in the recovery
capsule, with the refusal code and the prompt the offer quoted, and returns it
from the restart-resume RPCs. The renderer shows a "N chats failed to resume"
status bar entry, a count-only toast with Show and Dismiss, and keeps the resume
dialog open with a status icon per row and a "To resume" line whose action is
chosen from the reason. A failure dies on dismiss, on a successful retry, on the
user's own send in that chat, or with the marker's 24h expiry.

* fix(native-chat): keep the resume dialog unchanged and add failed chats as rows

The failure view had replaced the resume dialog's title, checkboxes, preference
box, and footer. The dialog is back to main's layout. A chat an earlier resume
could not carry on is now an ordinary selectable row there, plus a status icon
whose tooltip carries the reason, a dismiss control, and a "To resume" line.
Selecting it and pressing Resume retries it; it is pre-selected only when a
retry can succeed. Resumed chats leave the list as before.

Also stubs the new failure listing on the cross-version wire host fixture, which
the restart-resume RPC now reaches.

* refactor(native-chat): release a failed-resume record where a send is admitted

Keeps the host file at main's size, and only releases the record for a send the
controller actually lets through.

* fix(native-chat): note a failed restart resume in the chat and derive when it is settled

The chat itself now says when Orca could not continue it after a restart,
with an error (refused) or warning (unconfirmed) status row, so the failure
survives the toast, a dismissed record, and another restart.

A recorded failure is current only while the chat's newest user message is
the one it had when the failure was filed. Listing re-derives that from the
journal and prunes superseded records, replacing the in-memory set and the
hook on every send.

Failures move to their own optional top-level key in the recovery file, so an
older build that rejects unknown entry states keeps reading its offers. A
retried failure stays a failure through a rollback or a lapsed lease instead
of returning as a pending offer, and the toast's Dismiss names only the chats
the host listed as failed.

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

* test(native-chat): check the older reader against a filed failure before any retry

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

* fix(native-chat): file a failed restart resume against the chat as its attempt ended

A failure was filed against the chat's newest user message read at settlement, after every chat in
the action had finished. The chat's note asks the user to send a message, and one sent while other
chats were still being continued became part of the filed state, so the failure stayed listed after
the user had done what it asked. Each chat's newest message is now observed as its own attempt ends.

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

* fix(native-chat): let a reply that made a chat ineligible retire its failure

When a restart resume reached a chat the user had already replied in, the
attempt was refused as no longer eligible, but the failure was filed against
that very reply. It then stayed listed as "finished on its own" until the user
sent yet another message. An ineligible chat is no longer observed at the
attempt, so its failure falls back to the reserved marker and the reply that
made it ineligible supersedes it at the next listing.

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

* fix(native-chat): offer Retry on a failed resume only when a retry would run

When the agent refused Orca's "continue" message with its own reason, the
failed row fell to the generic guidance, which offers Retry and pre-selects
the chat in the resume dialog. The refused message is already the chat's
newest user message, so a retry is never eligible: it did nothing and the
same "couldn't be resumed" toast came back.

The host now reports whether a retry would run, derived at list time from
the same predicate the retry applies to the failure's marker. Where it would
not, the row offers Open chat and Dismiss and is not pre-selected. An older
host omits the flag and the reason alone decides, as before.

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

* fix(native-chat): file only restart failures the user must act on

A chat that stopped being resumable between listing and acting (it
finished on its own or is waiting on the user) was filed as a failure
with no note in the chat. It now just spends the offer.

A failed reattach now writes the same in-chat note as a refused
continuation, so every filed failure explains itself in the chat.

An unconfirmed continuation's failure retires once the chat shows the
continuation's own message opened the newest turn.

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

* fix(native-chat): clear a failed resume from the status bar once its chat moves on

While a failed resume is listed, the restart store watches the host's
status feed; when a failed chat's status or latest prompt changes after
the list was read, it re-reads the host once. The host still decides
whether the failure stands. Nothing is watched while nothing failed.

The failure toast now counts only the requested chats the host still
lists as failed, keeping the old count for a host that sends no list.

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

* test(native-chat): pin that an unjournaled continuation never retires its unconfirmed failure

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

* fix(native-chat): stop telling the user to send a message into a chat Orca couldn't reconnect

A failed reattach, or a continuation refused because another window or terminal owns the
session, now leaves a note saying Orca couldn't reconnect the chat instead of advising a send
that would meet the same refusal. The restart list keeps the reason-specific advice.

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

* fix(native-chat): keep the failed-chat re-read from undoing an action or missing a reply

The status-bar re-read no longer runs while a resume or dismiss is in flight, and its answer is
dropped if one settled meanwhile, so a dismissed failure cannot come back. A change to a failed
chat already seen always triggers it, whatever the host's timestamp says. A chat whose unconfirmed
continuation the host already retired is now reported as resumed instead of saying nothing.

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

* test(native-chat): pin that no failed-chat re-read runs under a resume in flight

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

* test(native-chat): typecheck the unjournaled-continuation case against a nullable marker

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

* docs(native-chat): drop the stale "no arguments" note on the restart-offer params

The dismiss call now names sessions, so the older comment contradicted the schema below it.

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

* fix(native-chat): count unconfirmed resumes apart from refused ones in the action toast

The post-action toast said "N chats couldn't be resumed" for chats whose
continuation may well have gone out, while the list and the chat itself say
Orca couldn't confirm it. That wording invites a duplicate "continue" send.
Unconfirmed chats now get their own count, classified by the outcome the
host filed, so the toast matches the row it points to.

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

* fix(native-chat): stop offering Resume on a failure the host says cannot retry

A failed row the host marks unretryable could still be ticked, sending a resume
that could only fail again; its checkbox is now disabled and it never joins the
action. An older host that omits the flag keeps today's selectable row.

The status bar no longer calls a chat "failed to resume" when the host only
couldn't confirm the resume, matching the dialog's own wording, and the mixed
toast's second line now says "other" so it cannot read as the same chat.

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

* fix(native-chat): skip unreadable failure records instead of rejecting the capsule

A failure record this build cannot parse (a newer outcome, say) made the
whole recovery file unreadable, so a downgraded build listed no restart
offers and could not record new teardowns. Failures are advisory: parse
each one on its own and drop what does not parse.

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

* test(native-chat): pin that a failure record with an unreadable marker is skipped

The skip-unreadable-failure test only covered an unknown outcome, so going back to the throwing
marker parser for failure records still passed. A failure record usually outlives its offer
entry, so a newer marker shape can appear only there.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 20:45:07 -07:00
Jinwoo Hong 8ba7f829ac feat(ipynb): run notebook cells in a persistent Jupyter kernel (#22581)
* feat(ipynb): run notebook cells in a persistent Jupyter kernel

Replaces the fresh-process runner (which silently re-ran every earlier cell)
with the user's own ipykernel, driven by a small bundled Python bridge over
line-framed JSON. One kernel per open notebook: started on first Run, shut
down when its tab closes or Orca exits (stdin EOF), and the kernel's own
parent poller reaps it if the bridge dies.

The header gains a kernel pill (workspace .venv/.conda recommended, PATH
interpreters, Browse), Interrupt/Restart/Run all/Clear all, and a one-time
missing-ipykernel dialog with Install. Outputs stream live per cell and are
written into the document when the run finishes.

* test(ipynb): cover the stalled-interrupt restart offer

* refactor(ipynb): disable the kernel pill while settling; merge its classes with cn

* refactor(ipynb): tie kernels to their renderer document and simplify the run flow

- Main keys kernels per renderer, so a reload, renderer crash or closed
  window shuts them down, and two windows never share one notebook kernel.
  One close-driven cleanup replaces the separate exit and start-failure
  deletions.
- The first run uses the nearest workspace env, else the first Python on
  PATH; the picker no longer opens itself, so its open state stays in the
  toolbar. Closing the picker brings the missing-ipykernel dialog back
  instead of dropping the queue, which also keeps a Browse pick's run.
- Discovery marks the kernel starting, so a second run during it queues
  instead of starting a second kernel, and a tab closed mid-discovery no
  longer leaks one.
- Running an nbformat 4.4 notebook gives its cells ids (upgrading to 4.5),
  so moving a cell mid-run cannot misroute its output.
- The death notice drops stderr from before the kernel was ready (the
  unencrypted-TCP warning).
- SSH and non-Python runs toast instead of writing a notice into the cell.
- Windows conda envs are named after their folder.

* fix(ipynb): install ipykernel into envs without pip

uv-created venvs ship without pip, so Install failed with 'No module named
pip' there. When pip is missing, bootstrap it with the stdlib's ensurepip
and retry. The install moves beside the other interpreter probes, and the
copyable install command comes from one helper.

* fix(ipynb): address PR review comments on stream errors, old jupyter_client and the Windows venv hint

- Swallow stdout/stderr stream errors on the bridge child, as spawnProcess
  requires, so a broken pipe cannot crash main.
- The bridge exits (reporting the death) even when cleanup_resources is
  missing (jupyter_client < 6.1.5) or raises.
- The install-failure hint suggests `py -m venv .venv` on Windows.

* feat(ipynb): add Cancel to the missing-ipykernel dialog

It does what Esc does: drops the cells waiting on the kernel.

* fix(ipynb): recover from a rejected kernel start; quote the install command per shell

- Discovery moves into start, so one catch turns a rejected
  listPythonEnvironments or startKernel into the usual failed start: the
  session returns to off with the error in the cell, instead of sticking
  at starting.
- The copyable ipykernel command quotes the interpreter only when its path
  has whitespace, prefixing PowerShell's call operator on Windows. Install
  itself still spawns without a shell.

* fix(ipynb): always shell-quote the copyable ipykernel install command

Quote the interpreter path for every path, not only ones with whitespace,
so paths with shell metacharacters like & copy as a working command.
Single quotes are literal in POSIX shells and PowerShell; embedded quotes
are escaped per shell, and PowerShell keeps its & call operator.
2026-09-23 23:38:33 -04:00
Jinwoo Hong 128e97ffca fix(relay): drain a same-cap cell over 5 minutes, not 2 (#22584)
* fix(relay): drain a same-cap cell over 5 minutes, not 2

The 2026-09-23 c27 roll drained 2,145 hosts over the 2-minute window,
about 18 re-dials/s, while the director re-places roughly 8/s through
its single-slot sticky lane. The overflow queued behind slow
re-placements and timed out, so /v1/assign returned 503 fleet-wide for
about 5 minutes. 5 minutes is the cell's maximum pace window and keeps
the remaining 2,650-host cells near lane capacity. The transition wait
already outlasts a 5-minute window (17 min).

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* test(relay): pin the same-cap drain window contract at 5 minutes

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): keep the drain wait at lease plus the 5-minute window

The transition wait after a drain was set to the 15-minute migration
lease plus the pace window. Widening the window to 5 minutes without
moving the wait left 12 minutes for a migration that can hold for 15,
so a late-window migration would time the wave out into rollback.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-09-23 23:38:15 -04:00
Neil 795b64b9a6 docs(tui-agent-config): correct the OpenCode readiness-budget rationale (#22593)
The comment merged with #22546 claimed ConPTY never forwards DECSET 2004 and
that the signal therefore cannot fire on Windows. Verification on two real
Windows hosts refuted that: the sequence arrives in order on both ConPTY
backends, and the readiness signal fired in every run.

The budget was the actual problem — opencode does not enable bracketed paste
until ~4.8s and its composer is not ready until ~10s, so the 8s default expired
first and the draft was pasted blind. Same fix, accurate reason.

Note the claim that seeded this: terminal-agent-paste-bracketing.ts says 2004
"can be lost by remote replay or ConPTY", which is careful and not contradicted
here; the absolutism was mine.
2026-09-23 20:29:00 -07:00
Brennan Benson 5e3effc32f fix(native-chat): show every user message on the message rail, not just loaded ones (#22558)
* fix(native-chat): show every user message on the message rail, not just loaded ones

The message rail was built only from transcript rows the renderer had
loaded, so any prompt above the loaded page had no tick, and the rail lost
ticks when a long live session trimmed its retained window.

The host now answers `agentSession.conversationOutline`: every user message
in a structured session's journal (item id, creation sequence, a preview
cut to 200 characters, image count) plus the journal position it is current
through. It is derived from the reduced journal on each request with the
same projection the transcript runs, so an entry's id and preview are what
the loaded row shows. The reply is bounded like a history page: previews
shorten, then drop, and only then do the oldest entries go.

The renderer asks only while the pane is visible and older history is
unloaded, uses outline entries only for messages older than its loaded
window (the window is authoritative for the rest), and falls back to loaded
messages while the outline is stale (epoch change, or the window trimmed
past what it covers). Selecting a tick with no row pages older history in
until the row exists, then uses the existing rail jump.

The method is negotiated with `agent-session.conversation-outline.v1`; a
client never calls a host that does not advertise it, and any failure
leaves the rail on loaded messages.

* fix(native-chat): keep a rail jump from the bottom from re-arming follow and cancelling itself

A rail jump started by a reader following the end stopped a few pixels
above the bottom instead of reaching the message. Paging older history in
for a jump always leaves the reader following at the very end, so jumps to
unloaded messages hit it every time; a jump to a loaded message from the
end did too.

The jump scrolls smoothly, and only its landing is marked as the
application's own scroll. Its first frames move a pixel or two, still
inside the band where a reader event re-arms follow, so the list read the
jump leaving the end as the reader arriving at it. The next frame, just
outside the band, then read as the reader taking over and rebased the view
with an instant write, which cancels the smooth scroll.

Re-arming follow now needs the reader to be arriving at the end: an
unmarked event that moved the view up never reattaches a detached reader.

* test(native-chat): check a rail jump left the end before reading where it landed

* fix(native-chat): keep the rail's message list still while a press selects an item

With the whole conversation in the rail, its hover list overflows and opens
scrolled to the message being read. Clicking an older item did nothing:
pressing it focuses it, which turns the hover preview interactive, and that
switch re-ran the effect that scrolls the lit row into view and focuses it.
The list moved under the pointer between press and release, so the click
landed on the list instead of the item, and focus jumped to the lit row.

Revealing the lit row now follows the list opening (and its rows shifting),
not the switch between hover and interactive. Entering interactive moves
focus into the list only when focus is not already on one of its items.

* perf(native-chat): keep the rail's outline entries stable while a trimmed window slides

A long live session holds a head-trimmed window, so every new row moved the
oldest-loaded edge and rebuilt the outline view even when no user message
crossed it. The rail then re-merged, re-rendered and re-read the scroll
geometry on each new row. The view is now reused while the set of entries
older than the edge is unchanged.

* fix(native-chat): let a rail jump wait out an older page already loading

Scrolling to the top of the loaded window asks for the next older page. A rail
jump made while that page was in flight asked again, got the lane's immediate
no-op return, read it as a page with no progress, and dropped the click. The
jump now waits for the in-flight page to land before deciding.

* fix(native-chat): reattach follow when content shrinking clamps a reader onto the end

The rule that stops a smooth scroll leaving the end from re-arming follow
compared offsets, so it also refused a reader whose offset dropped because
settled content folded away beneath them and the browser clamped them onto
the end. They sat at the bottom without following, and the next reply grew
out of view. Re-arming now requires closing on the end rather than moving
down, which still rejects a scroll leaving it.

* fix(native-chat): keep the rail hooks' ref writes out of render

Both hooks wrote a ref while rendering, which React may replay or discard.
The rail's structural-sharing baseline is now recorded after commit, and the
history jump calls the lane's page loader from its effect instead of through
a render-updated ref.

* fix(native-chat): let the latest rail pick win over a jump still paging

A jump to an unloaded message keeps paging older history until it lands. A
pick made meanwhile lost to it: a loaded message scrolled into view, then the
earlier jump finished and pulled the reader away; another unloaded message was
ignored. Picking a loaded message now cancels the paging jump, and picking an
unloaded one retargets it without asking for a second page.

* fix(native-chat): step a rail history jump with a functional update

The step that requests the next page wrote the pending jump from the value
its effect closed over, so a pick or cancel queued since that commit would
be overwritten.

* refactor(native-chat): run a rail history jump as one abortable awaited loop

The jump through unloaded history was an effect-driven state machine that
guessed "no progress" from the message list's identity and could not be
cancelled by anything but another rail pick. A diff reveal, "Jump to latest"
or the reader scrolling left it paging, and when its page landed it pulled
the reader away; a history read that kept failing during a live turn could
repeat back to back.

Loading an older page now reports how it ended, and a second request while
a page is in flight joins it instead of being refused. The jump is an
awaited loop that reads the rail from a commit after each page, stops on
anything but a page that moved the window, and is aborted by any other
navigation, reader input (wheel, touch, scroll keys, scrollbar), a session
switch or unmount. The latest pick wins.

* fix(native-chat): derive the rail outline from the transcript's own projection

The host built the outline from user items alone, while the transcript
orders every message by when it was observed, folds tool results into the
turn above and then drops harness turns. An imported user row carrying a
tool result beside harness text therefore got a rail tick previewing the
harness text, and clicking it paged history for a row that never draws.

The transcript's order-fold-strip projection now lives in one shared
function. The renderer's list projection wraps it with its own tail-row
order, and the host runs it over the whole journal and keeps the user rows
that draw content, so the outline lists the same messages in the same order.

* fix(native-chat): retry a failed rail outline read a few times

A failed outline read left the rail on loaded messages until a new gap
opened, the pane was shown again or the epoch changed. The client now
rejects a failed read (a host without the outline still resolves to
nothing, without being called), and the rail retries up to three times
with doubling backoff.

* test(native-chat): give the rendered-transcript fixture the older-page result contract

* perf(native-chat): sort the shared transcript projection without a spread copy

* fix(native-chat): let a wheel over the rail cancel a rail jump still paging

The rail forwards its wheel to the transcript, so a reader scrolling there is
scrolling the transcript. That wheel never reached the scroller's reader-input
handlers, so the jump kept paging and later pulled the reader to its target.

* fix(native-chat): keep the rail's list open while a picked message pages in

Picking a message that is not loaded yet can take several pages of older
history. The list closed on the pick, so its busy item was never seen and
the click looked ignored. The list now stays open with that item pulsing
until the jump lands or is abandoned, and stops revealing the lit row
meanwhile so the rows do not move under the pointer.

* fix(native-chat): keep the shared transcript projection loadable on mobile

The projection moved to src/shared, which mobile's Hermes engine also loads,
and switching its sort to toSorted broke the Hermes compatibility guard.
Sort a copy made with Array.from instead, as other shared code does.
2026-09-23 20:26:54 -07:00
5802b54579 fix(rate-limits): read OpenCode Go usage with the Go API key (#22551)
* fix(rate-limits): read OpenCode Go usage with the account API key

Since OpenCode's console migration (upstream fe51b0b19a, "fix(console):
restrict legacy access to Black"), an account with no Black subscription
is redirected from the legacy console to /console/login, so Orca's
cookie-based workspace lookup returns nothing and the Go bar stays empty.

Fetch usage from GET https://opencode.ai/zen/go/v1/usage instead, which
authenticates with `Authorization: Bearer <key>` and needs no console
session. The key resolves in order: Orca settings override,
OPENCODE_API_KEY, then whatever OpenCode itself stored on /connect --
auth.json for 1.x, the credential table for 2.x. The cookie path stays
as the fallback so Black/legacy accounts keep working.

A 403 EntitlementError now reads as "no OpenCode Go subscription" in the
status bar instead of a generic refresh failure (#22257's reporter was
misled by exactly that).

* fix(rate-limits): prefer OpenCode's stored Go key over OPENCODE_API_KEY

OpenCode applies the key saved on /connect after the environment, so the
stored key is the one its own Go requests use. OPENCODE_API_KEY is also
the Zen provider's variable, so ranking it first could read a key that
OpenCode itself is not using for Go.

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

* fix(rate-limits): name the API key when OpenCode Go usage lands on sign-in

A redirected usage request arrives as a 200 sign-in page because Electron
follows redirects; report it as a rejected key instead of a parse failure.
The cookie path's empty workspace lookup is what non-Black accounts now
hit after the console migration, so its message points at the API key
rather than only the workspace override.

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

* chore(i18n): add the OpenCode Go API key strings to the English catalog

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

* docs(rate-limits): stop calling the credential table an OpenCode 2 marker

Verified on two real Windows hosts running OpenCode 1.18.16: the `credential`
table exists there too (empty, same columns), so its presence does not identify
a 2.x install. Neither host had an `auth.json` at all.

The resolution already probes both stores on every version, so only the comments
were wrong. Says so now, and records that a 2.x install which never ran the
legacy import has no `auth.json` either — which is why both tiers exist.

* refactor(shared): move GhosttyImportPreview out of global-settings-types

Adding `opencodeGoApiKey` pushed global-settings-types.ts one line past the
300-line ceiling, failing `oxlint` in CI. AGENTS.md forbids a max-lines
suppression, so split instead: the Ghostty import preview is a distinct concern
that never belonged in the settings-shape file.

Re-exported from the original module so no importer changes. 293 code lines now.

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 20:09:02 -07:00
Neil 57bf732a42 test(opencode): cover the opencode2 host-env branch and stop inheriting ORCA_OPENCODE_AGENT (#22547)
* test(opencode): pin per-major OpenCode overlay selection on WSL and the relay

OpenCode 2 dies with "Duplicate plugin ID" when two plugin files share an id,
so which overlay a guest or remote pane is pointed at decides whether the agent
starts. Nothing failed if that selection regressed:

- the WSL spawn path never asserted which major it asks the guest relay for,
  and the shared pty-ipc mock had no openCode2HookService at all, so no test
  could reach the opencode2 branch of buildPtyHostEnv;
- requestGuestOpenCodeOverlayDir had no coverage for the v2 guest dir;
- PluginOverlayManager had no case for a remote config root that still holds
  the other major's stale Orca plugin.

Tests only; no behavior change. Each new case was mutation-checked against the
production line it guards.

* test(opencode): stop the plugin contract test inheriting ORCA_OPENCODE_AGENT

The generated plugin self-disables when ORCA_OPENCODE_AGENT names a different
major, and the contract test saved and restored that variable without ever
setting it. Run from a shell that has it — which is any shell inside an Orca
OpenCode pane, i.e. how this repo is usually developed — the plugin returned an
empty hook set and the contract failed for the wrong reason.

Delete it in beforeEach, the way the opencode2 setup test already pins it.
Verified the file passes with the variable set to either major and unset;
before this it failed for two of the three.
2026-09-23 20:08:34 -07:00
Neil b0ae7d18a0 fix(opencode2): resolve subagent session lineage so child work stops taking over the pane (#22444)
OpenCode 2's plugin adapter unwraps a single-property `{ data }` success schema,
so `ctx.session.get` resolves to the bare session record. The shared lineage
lookup only accepts `result?.data?.id === sessionID`, and OpenCode 2 has no
`session.list` fallback, so `resolveRootSessionID` returned null for every
session and `childState` was permanently null.

With unknown lineage `canFailOpen` is true for attention events, so a subagent's
`permission.asked`/`question.asked` fell through and pinned an un-evictable
blocker keyed to the child's own session id — publishing a subagent as if it
were a root. Observed in hook posts: SessionBusy for a child session id whose
`session_v2` row carries a parent.

Envelope the result in the OC2 client shim so the shared lineage module works
unchanged; OpenCode 1 already receives enveloped results and is untouched.

Also adds `opencode2` to the double-Escape interrupt list, extracted into one
shared helper so the server inference and renderer gate cannot drift. A single
Escape was inferring an interrupt, and Escape is how the Subagents dock closes.

7 of 11 new lineage tests fail without the shim.
2026-09-23 20:08:01 -07:00
Brennan BensonandClaude c4dfd9deef fix(claude): resume a native chat from its real latest message (#22395)
* fix(claude): resume a native chat from its real latest message

Claude's last-prompt marker names the chain tip, which is often a stop-hook
summary or attachment row that --resume-session-at rejects. The branch proof now
resolves the marker to the latest main-chain message, and a plain resume
re-derives its point from the transcript instead of trusting the stored cursor,
resuming by session id alone when the transcript cannot vouch for one. An
acquisition release now reads the transcript tail like close and exit do.

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

* fix(claude): advance the durable resume point at every turn end

A completed turn now writes the live main-chain message uuid onto the owner's
head provider-handle link in place, so a host that dies before its close path
runs still resumes from its last completed turn and the chain does not grow per
turn. The write is serialized per session, only logged on failure, and close and
exit persist after it settles.

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

* fix(claude): retry a failed turn-end resume write and ignore results after exit

A turn-end write that failed was never retried when the next turn ended at the
same point, because the memo of the last attempted leaf outlived the failure.
A result frame delivered after the child's exit could also start a write that
landed behind the exit path's transcript-derived cursor, moving the durable
point backwards. The failed leaf is now forgotten so the next turn end retries
it, and a turn-end write only runs while the session is still the published
live owner.

The session-id fallback on a plain reopen and a failed durable write on the
unexpected-exit path now log why, instead of leaving no trail.

* fix(claude): resume a native chat by session id and stop predicting Claude's marker

A plain reopen now passes only the session id, so Claude continues from the
real end of its own conversation. Orca's saved leaf is its own record of the
last completed turn, taken from the live stream at each turn end. It is
bookkeeping (the reconciliation anchor), never a resume argument.

- Launch resolution resumes by id and checks only the session id; the prior
  head leaf is carried into the publication link.
- Remove the launch-time transcript re-derivation.
- Close, unexpected exit, and acquisition release no longer read the
  transcript. They wait for any in-flight turn-end write, then persist the
  last completed turn, so a crash mid-turn never saves a half-turn prompt.
- The delivery-reconciliation window walks from the file's last main-chain
  transcript row to the anchor instead of Claude's lagging marker, so a
  prompt Claude saved just before a crash reconciles as accepted.
- Revert the transcript branch graph to main; the terminal handoff readers
  keep their semantics.
- Report Claude rewind as unsupported. Its marker-based proof can never pass
  on the real binary, and no app screen calls it. Remove the Claude rewind
  launch, proof, and recovery path. A pending Claude rewind left by an older
  build is settled as refused on the next attach, which resumes by id; a
  failed settlement is logged and never blocks the chat.

* fix(claude): drop the stale resume-cursor wording from the restart-resume note

The restart path resumes Claude by session id alone now; the module comment
still described the old resume-at cursor.

* refactor(claude): prove the file-tail resume tip inside the branch graph

The reconciliation readers proved the transcript tip by re-parsing every
line and feeding a synthetic last-prompt row through the graph, doubling
parse cost on every reopen and coupling the tail path to the marker's
JSON shape. The graph now takes tip: 'file-tail' and tracks the last
main-chain row from its own parse; marker mode is unchanged and the
no-eligible-tail fallback keeps the exact marker semantics.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-23 20:00:13 -07:00
Neil b7a4fee700 fix(agents): stop claiming an unconfirmed OpenCode handoff succeeded (#22546)
* fix(opencode): stop claiming a handoff prompt was delivered when it was written blind

"Continue in New Session…" to OpenCode reported success even when the prompt
never reached the TUI (#22479). The paste-after-ready helper falls back to a
blind write when the composer-ready signal never arrives and only the agent
process is known to exist; that write was indistinguishable from a real
delivery, so the continuation showed its success toast.

- pasteDraftWhenAgentReady / pasteDraftToAgentPtyWhenReady report the blind
  fallback via onUnconfirmedDelivery, plumbed to launchAgentInNewTab as
  onPromptDeliveryUnconfirmed.
- The session continuation hedges instead of claiming success, and both the
  failure and the hedged notice offer "Copy prompt".
- OpenCode gets Codex's 20s composer budget. Both are quiet-window-less
  signals anchored on DECSET 2004, which ConPTY never forwards, so on Windows
  that budget is the settle delay before the blind paste.

* test(runtime): retarget the 8s startup budget test off OpenCode

The main-runtime startup-draft budget test used `opencode` as its stand-in for
"an agent without an override", which this branch invalidates by giving OpenCode
20s. It failed with "expected vi.fn() to not be called at all, but actually been
called 1 times" — the readiness signal now legitimately arrives inside budget.

Point it at `claude`, which still takes the 8s default, and add a companion
pinning OpenCode's 20s: a readiness signal at t+10s, past the old default, must
now deliver the draft. Removing the override makes that companion fail.
2026-09-23 19:47:47 -07:00
Jinwoo Hong 4064653740 refactor(browser): remove the unused screenshot-prep visibility helper (#22526) 2026-09-23 21:55:57 -04:00
Jinwoo Hong f2ac9f29b2 fix(browser): let pixel capture hold its own page drawn, without the desktop window (#22534)
* fix(browser): let pixel capture hold its own page drawn, without the desktop window

Screenshots were the last browser commands that still borrowed the desktop
window: they took the per-page automation-visibility lease, which waits for
two desktop-window animation frames (capped at 2 s) and never arrives when the
window is minimized or throttled. Only pixel capture actually needs a page
drawn — input, scripts, layout, the accessibility tree and PDF all work on a
hidden page.

Capture now takes a main-owned paint hold: a synchronous, per-page ref-count
that tells the renderer one way (no reply awaited) to keep the page drawn and
keeps the desktop renderer unthrottled while held. Both Orca's full-page
capture and the agent-browser helper's screenshots take it in
cdp-screenshot.ts and retry on a bounded schedule until the page answers with
a frame; a CDP error fails fast.

Deleted: the queue's needsPaint lease, the executeJavaScript acquire path and
its two racing 2 s timeouts and late-token cleanup, the renderer's rAF wait and
window bridge, the capture commands' own leases, the fixed 300/500 ms settle
waits, and the global one-screenshot-at-a-time lock.

Rebased onto main after #22528 landed; content identical to the reviewed
branch head 7b390ed6a8.

* fix(browser): probe for a frame instead of repeating the full capture

Retrying a capture resent the caller's full request, so on an already
drawn tall page (a full-page capture takes ~0.5 s) the 250 ms retry
started a second full beyond-viewport capture while the first was still
running. Measured on Electron 43: any later request makes a held page
produce a frame, and that frame answers every pending capture with a
full, correct image. So the capture is sent once and 1x1 probes follow
until it answers; their results are ignored.

Also report a detached debugger as detached rather than destroyed, and
give the layout-metrics timeout its own "did not respond" message, since
that request doesn't need a drawn page.
2026-09-23 21:17:46 -04:00
Neil 8352752e54 fix(opencode): give the opencode2 status plugin a distinct id (#22544)
* fix(opencode): fail-open plugin setup and distinct opencode2 plugin id

setup() threw when OpenCode 2 probed it without a full context, which the
TUI reported as an 'orca-opencode-status' plugin failure. Both plugin files
also shared one id while living in the same config dir.

* fix(opencode): drop unused oxlint-disable in setup fail-open test

* test(opencode): pin distinct plugin ids for the shared global config dir

Orca installs both family plugins into one global plugins dir, so a shared
plugin id makes OpenCode 2 fail the later one with 'Duplicate plugin ID'.
2026-09-23 17:51:00 -07:00
Brennan Benson 80f5aae0f9 feat(agent-status): publish the main agent's own state beside the combined row state (#22452)
* feat(agent-status): publish the lead agent's own state beside the combined row state

Every status producer folded the main agent's state together with live child
work into one `state`, so a lead that had finished while a subagent still ran
read `working` and its own state was lost. The row now also carries
`lead: { state, outcome?, stateStartedAt }`, admitted by the one payload
normalizer on the relay wire, IPC and disk, and published from the Claude hook
lane, the structured host ingest and renderer bridge, Grok (now on the shared
fold) and Codex (own combine kept). The persisted child-only boundary flag is
derived from `lead` plus child evidence and no longer written; old rows map
onto `lead` at hydrate. Combined `state` and `workingMode` are unchanged for
every reader; a cross-lane parity table pins that, with the cancelled-turn
watch-loop story recorded as a known divergence.

* fix(agent-status): make Orca's inferred interrupt the primary source of a Claude lead cancellation

Current Claude Code sends no hook at all on a cancel and no is_interrupt on
Stop, so the cancellation enters the lead record from the server's inferred
interrupt and rides into the next real Stop; is_interrupt on a turn boundary
stays as the secondary source for builds that send it. Comments, the store
reference and the parity table say so; no suppression changes.

* docs(agent-status): the child-only boundary comment now describes the persisted shell fact

The old sentence said a hydrated row no longer carries the shell fact, which is
the opposite of the mechanism: claudeRunningNonAgentTask is persisted precisely
so hydration can read it, and only a pre-lead row lacks it — reading as
shell-free, the same assertion its legacy flag made at write time.

* rename the lead fact to mainAgent: the main agent's own state

* docs(agent-status): the inferred cancel comes from Ctrl+C, not Esc

* fix(agent-status): an inferred interrupt keeps an already settled main agent, and the row verdict docs name its inferred source

* fix(agent-status): a child-induced wait publishes the main agent state it displaced

* fix(agent-status): decide child-held Claude rows from the saved main agent fact

Restart seeds the Claude main agent from the row's saved mainAgent whenever it
settled and no shell held the row, instead of re-deriving a child-only shape.
OSC cannot settle or repaint a row child agents hold open, including a row
waiting on a child's permission prompt. A sticky child permission prompt still
records the main agent's own progress, and OSC repaints and inferred answers
keep the shell fact beside the main agent they preserve.

* fix(agent-status): keep a finished turn's main agent verdict and clock with that turn

A Claude SessionStart restarts the main agent's clock instead of inheriting the
previous session's last Stop. A Grok idle prompt or session end, and a late
Codex root Stop after an inferred cancel, restate the same finished turn, so
they keep its recorded verdict; only a new turn clears it.

* test(agent-status): publish the Grok verdict restatement past the late-event window

* docs(agent-status): describe hydrate seeding and the OSC refusal from the saved main agent fact

* fix(agent-status): push a held child permission row when its main agent changes

* fix(agent-status): keep the shell fact on a held child permission row so restart does not settle it

* docs(agent-status): note the held child permission row carries the shell fact and is pushed

* fix(agent-status): pair the Claude shell fact with the main agent at the one row-build point

Every non-hook rewrite (terminal-title repaint, inferred answer, held child
permission) had to re-carry the shell fact beside `mainAgent`, and each one that
forgot let a restart settle a row while a shell still ran. The row builder now
pairs the fact once: a listener event restates it, any other write keeps it only
while `mainAgent` is unchanged. Restart seeds a settled main agent only when the
row says no shell ran, and legacy child-only rows map to that explicitly.

A held child permission now also accepts the main agent event's background
evidence, as it already accepts its `mainAgent`, so the child's drain no longer
settles a row a shell still holds. The renderer keeps a previous `mainAgent`
only for writers that never carry one, so a hook row without it matches the
host snapshot.

* test(agent-status): pin that restart never seeds a main agent from a row silent about its shell

* docs(agent-status): the row builder pairs the shell fact with the main agent, and restart seeds only on an explicit no-shell

* test(agent-status): name the legacy-row case parameter for what it holds

* docs(agent-status): name which rows carry the main agent fact
2026-09-23 17:45:50 -07:00
Brennan Benson 7216d5af25 test(runtime): isolate per-repo worktree scan expiry from the shared timer queue (#22575)
The TTL case was advancing every fake timer, so a scan an earlier test
had left scheduled was counted as this repo's rescan.
2026-09-23 17:40:46 -07:00
Neil d4386763d5 fix(opencode-usage): merge a migrated session's two rows per column (#22550)
A session that lived through OpenCode 2's V1 import has a row in both
`session` and `session_v2`, and neither is complete. #22391 resolved the
pair by ranking whole rows on one number — total token count, ties to
`session_v2` — which let that number decide everything else on the row.

Three consequences, each reproduced against that PR's own fixtures:

- A recorded cost could be zeroed. `session_v2` wins on tokens while
  carrying `cost = 0`, and row parsing maps a zero cost to `null`, so a
  legacy row's $12.50 disappeared. Cost is re-derived by the same lossy
  reduce as the tokens, but only the tokens were guarded.
- The token comparison decided metadata. A legacy row with more tokens
  supplied a stale pre-migration directory, and a legacy row with a NULL
  model erased the model `session_v2` had — 23 of 234 shared ids on a
  real migrated database have a model only on the v2 side.
- Winner-takes-all is per row, so a legacy row holding the input tokens
  and a v2 row holding the cache reads reported one of them as zero.

Metadata now comes from the generation OpenCode still writes, with older
generations filling only its NULLs; usage columns take a per-column MAX.
Both rows aggregate the same assistant messages, and the import can only
drop messages, never invent them, so each column's MAX is a tighter lower
bound on the truth than either row and can never exceed it.

The relation stays exactly one row per id — the highest-priority
generation holding it — every column stays `columnExists`-guarded with a
SQL fallback, and a database with a single session table builds the same
SQL it did before.

Cache schema version 4 -> 5 so existing caches rescan.
2026-09-23 17:39:26 -07:00
Brennan Benson 800d33e5c9 feat: name runtime machines (#22094)
* feat: name runtime machines

* fix: preserve pairing address optionality

* fix(cli): keep host and environment listings local

Listing paired servers read each one's machine name by dialing it, so both listings made a network
round trip per server and waited out a timeout on any that were offline. They answer from this
machine's own pairing store; `orca host name --environment <name>` reads one server's name.

* fix(settings): caption the machine name paired devices actually receive

The caption read the runtime's published name once, when the pane opened, so saving an override
left it naming the old computer while phones already showed the new one. It now re-reads whenever
the saved override changes; the settings write lands in the main process before the store publishes
it, so that read already sees the new name. The name is interpolated rather than baked into the
fallback, and the caption, label and placeholder are in the English catalog.

* refactor(settings): normalize the machine name in one place

The trim and length rules for `machineName` were spelled out separately at the
renderer IPC (trim + 255), the settings load path (trim only, no cap), the RPC
schema (zod trim + 255) and the runtime reader (trim). A hand-edited or legacy
profile could therefore load a longer name than any writer accepts.

`src/shared/machine-name.ts` now owns `MACHINE_NAME_MAX_LENGTH` and
`normalizeMachineName`, and every writer and the load path use it. The RPC
schema keeps rejecting over-long names but derives its cap from the constant,
and the runtime settings controller normalizes an RPC write before storing it.

* fix(runtime): detect the machine name once and label handoffs with it

Every runtime constructed in a process (the app, plus each one a test builds)
ran its own `scutil` lookup. The friendly name is a property of the host, so the
lookup is now a single shared promise; construction still never blocks on it,
and a rejected lookup can no longer surface as an unhandled rejection.

The structured-chat handoff banner ("Agent is open in terminal on X") named this
host with the bare `os.hostname()` while paired devices saw the published name.
The transport now reads the same `RuntimeMachineName`, through a getter so a
rename in Settings is reflected without rebuilding the transport.

* fix(cli): print the name the runtime publishes and keep its envelope

`orca host name --name X` printed `undefined`: `settings.update` replies with
`{ settings }`, but the handler read a bare `machineName` off the reply, and the
test fixture mirrored the wrong shape so it passed. After a write the command
now re-reads `status.get` and prints what the runtime publishes, so a blank
`--name` prints the detected name it returned to rather than an empty string.

The read path wrapped a possibly routed answer in a local envelope, stamping
`_meta.runtimeId: "local"` on a reply from another server. It now returns the
`status.get` envelope itself, and an unreachable runtime is reported as the
usual error instead of an invented "unknown" name.

`environment list` had gained machine-name and platform columns that no caller
populated, so every row printed "platform unknown"; the columns are removed.

* refactor(settings): give the machine name field its own component

The caption under the field re-read runtime status every time the saved value
changed, relying on a comment about write ordering to show the new name. A saved
override already is what paired devices see, so the hook now derives the caption
from it and asks the runtime only for the detected name; a stale status read can
no longer show the previous name.

`MobileMachineNameField` owns the store read, the published-name hook and the
debounced input, so `MobilePairingSetupSection` returns to its prop shape and
the pass-through `MobilePanePairingOutput` wrapper is gone. Paired-device
revocation moves into `useMobilePairedDeviceRevocation`, which keeps
`MobilePane` within its line budget with an extraction that carries behavior.

The web client mounts this pane too, but its settings store kept the name
locally where nothing published it. `machineName` now rides the existing
runtime-backed settings sync so the field renames the paired runtime.

* refactor(settings): normalize the machine name at the store boundary

Every writer (desktop IPC, web RPC, CLI) reaches the store through
updateSettings, which already normalizes the other free-text settings
there. Trim and bound the machine name in that one place instead of at
two upstream edges, so a future main-process writer is covered too.

* test(settings): pin machine-name routing and detection, and make the field searchable

The shared machine-name lookup test spawned the real `scutil` twice and compared the answers, so a
slow runner could time one spawn out to the hostname and fail. It now mocks the subprocess, proves
the hostname answers until the one shared lookup lands, and that a second runtime does not spawn
again.

`host name` is no longer pinned local, but only the explicit `--environment` route was covered; an
ambient `ORCA_ENVIRONMENT` now has its own test so the pin cannot silently grow back.

The Machine name field is added to the Mobile pane's search catalog at the tail, keeping every
existing row's tie-break index.

* fix(runtime): wait for the machine-name lookup before publishing status

A status read answered in the first few milliseconds after launch published the bare
hostname because the friendly-name lookup had not landed yet, and a caption fetched in
that window never corrected itself. RuntimeMachineName now exposes the settled lookup
as a promise, and both status publishers (the status.get RPC and the desktop
runtime:getStatus IPC) await it before reading. Construction, listen, and every other
method stay unblocked; the worst case is one wait of at most a second on the first read.

* fix(cli): refuse to rename a runtime that does not publish a machine name

An older Orca runtime rejects the unknown settings field with a bare invalid_params, so
'orca host name --name' routed at one failed with no explanation. The runtime that does
not publish machineName on status cannot store one either, so the CLI reads status first
and refuses with incompatible_runtime and a message that says to update that host,
before writing anything.

* fix(ipc): introduce this desktop to remote hosts by its machine name

When this desktop connected to a remote workspace host it announced itself under a
hostname captured once at module load, so a renamed machine kept its old name on every
other device's connected-clients list. The client name is now read at send time from
the runtime's machine name (the configured override, else the detected one), passed in
where the remote workspace handlers are registered, so a rename reaches the next
presence frame without a relaunch.

* fix(runtime): keep the machine-name lookup under the status probe budget

Status publishers now wait for the one-time name lookup, and `orca status`
probes them with a one-second budget. scutil answers in milliseconds, so a
half-second cap keeps a stalled lookup from making a healthy runtime read as
"starting" while still preferring the friendly name.

* refactor(web): drop the unreachable machine-name write path

The Mobile settings section is desktop-only, so the paired web client can
never render the field. Forwarding the name through the web settings sync was
dead code, and against an older host the strict update contract would have
rejected it while the local mirror kept the value. Remove it until a web
surface exists.

* chore(i18n): translate the machine-name strings and document paired-server rows

Add the Machine name field and its Settings search entry to the five non-English
catalogs, explain in the host list spec why paired-server rows report an unknown
platform, and drop a stale timeout figure from a test comment.

* refactor(settings): make the machine name a machine-wide setting with a General home

The name other devices and hosts list this computer under is not a mobile
setting. Rename MobileMachineNameField to MachineNameField, give it a per-mount
id, and put its primary home in Settings > General under "This computer". The
Mobile pane keeps the same field. One shared search entry feeds General, the
Mobile pane, and the copy now says "other devices and hosts" in all six locales.

The web client has no machine of its own to name and its settings mirror cannot
persist one, so the field renders nothing there and General omits the section.

* feat(mobile): name this computer in the Orca Mobile pairing step

The "Pair this computer" step now shows the same machine name field above the
connection choice and code, so a user pairing a phone from the sidebar page can
name the computer right there.

* feat(settings): name this host when sharing it with other devices

Share this host produces the access link other devices use to reach this
machine, so it mounts the machine name field first. The pane's search entry
takes the shared machine-name keywords so a search lands there.

* feat(sidebar): name this desktop when adding a remote host

This desktop introduces itself to a new SSH host or remote server under its
machine name, so the Add Remote Host dialog mounts the field once, between the
header and the host fields, in both modes. Submit logic is unchanged.

* feat(settings): name this computer in the SSH pane add form

The SSH pane's add form mounts the machine name field above the host fields.
Editing a saved host leaves it out; that host already met this computer.

* fix(mobile): drop the empty machine-name grid row on the web client

The pairing step wrapped MachineNameField in its own grid-area div. On the
web client the field renders nothing, so the wrapper left an empty row and
an extra row gap between the copy and the connection options. The field now
takes a className for its root, so the grid slot disappears with it.

* fix(settings): let Enter in the machine name field submit its form like sibling inputs

The field intercepted Enter to blur and commit instead of submitting the enclosing
SSH add form. The draft is already flushed on blur and on unmount, and the name is
read from the store whenever a peer asks, so nothing is lost when the form submits
first. Enter now behaves like the neighbouring inputs; the test proves the submit
fires and the name still commits when the form closes.

* fix(mobile): keep the machine name inside the pairing copy cell

A dedicated grid row stayed in the template on the web client, where the field
renders nothing, adding an empty track and a second row gap between the copy and
the connection options. The field now sits at the end of the copy cell with the
same 18px rhythm, so an absent field leaves nothing behind.

* fix(runtime): retry a failed machine-name lookup instead of latching the hostname

On a loaded Mac the scutil lookup missed its 500 ms cap during app boot, and
because the fallback was memoized for the process, every status read and the
Settings caption showed the bare hostname for the rest of the session.

The lookup now gets a 5 s timeout, a failed attempt (timeout, spawn error,
non-zero exit, empty output) clears the shared memo so a later ready() retries
after a 30 s interval, and status publishers wait only up to a 750 ms publish
budget before answering with what read() has now. A friendly name and the
non-darwin hostname stay final.

* refactor(settings): show the machine name only where other devices join this computer

The Add Remote Host dialog, the SSH pane add form, and General all describe
another machine, so a field about this computer's own name read as a third
kind of label there. The field now mounts only where other devices pair with
or connect to this computer: the Mobile pane, the Orca Mobile pairing step,
and Remote Servers > Share this host.
2026-09-23 17:30:14 -07:00
Brennan Benson 98e5ea3d5f refactor(tabs): delete the terminal tab's dead adopted-session field (#22557)
* refactor(tabs): delete the terminal tab's dead adopted-session field and every branch that read it

* refactor(tabs): drop the stale adopted-agent comment and pin legacy load on a chat terminal

The comment above the terminal chat-eligibility agent fallback described the
removed adopted-session agent fallback. The legacy load test now puts the
retired key on a chat-mode terminal tab, the only shape that ever carried it.
2026-09-23 17:29:11 -07:00
Jinwoo Hong 89817ad2b4 test(mobile): repin the recording corpus to main's tip after #22570 (#22576)
#22570 pinned its own branch commit, which the squash left off main; the corpus now pins main at 37820f9683, the tree its fenced paths match. Every golden changes only its baseline line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 20:25:42 -04:00
FumingPower adc0c67f75 docs: remove duplicate Muse badge from README (#22497) 2026-09-23 17:23:29 -07:00
Jinwoo Hong 37820f9683 feat(mobile): the page owns its safe area, like a native screen (OTA phase C follow-up) (#22570)
* feat(mobile): the page owns its safe area, like a native screen

The shell reserved both system-bar strips outside the WebView and painted
them bgBase, so every page screen showed a flat band above its header,
sheet scrims stopped short of the status bar, and the dock floated above
the gesture bar.

For a page that declares `safe-area-insets` in `ready.accepts`, the shell
now draws the WebView edge-to-edge and keeps only the keyboard strip off
it. `init` carries the insets the view sits under (bottom 0 while the
keyboard ends the view, top 0 under the update banner), and a move is
re-sent over the existing route-update `init`. An older page keeps the
reserved strips, since it has no reader for the insets.

On the page, a root layout (`app/_layout.web.tsx`) feeds those insets to
react-native-safe-area-context below ExpoRoot's env()-measuring provider.
It also replaces expo-router's DefaultNavigator, an all-edges SafeAreaView
that padded a second time.

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

* test(mobile-web): re-measure the page pins for the root layout

The page's route tree gained `./_layout.tsx` (its web sibling of the
native root), so every pin that counts the tree moved:

- Script sweep re-measured by building `routes.slice(0, n)` for each n.
  It reads 69 scripts at 16 routes, which matches the real build. The
  asset-ceiling crossing moves from 31 routes to 32.
- Route closures now enter through both layouts. `entryNames` gains
  `[dir]` because `app/_layout` and `app/h/_layout` share a name.
- Session closure pin 4216 -> 4219. The added modules are
  bridge-safe-area-insets, page-safe-area-provider and _layout.web.
- The web-overrides allowlist names `app/_layout.web.tsx`.

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

* fix(mobile): read safe-area ownership from the page-document state

Review fixes on the page-owns-safe-area change.

- Ownership is page-document state now. `page-ready` carries `accepts`
  beside `reports`, the patch sets `pageOwnsSafeArea` from
  `safe-area-insets`, and the session hook projects it like
  `backClaimed`. The screen's own per-session copy is gone.
- Insets moves re-send `init` only to a page that declared
  `safe-area-insets`. A page that took route updates but not insets was
  sent a useless `init` on every keyboard show and hide.
- The banner wrapper is gone. The root pads the status bar strip while
  the banner shows.
- The shell session defaults the insets inline, with no predicate that
  mutated its argument.
- The provider is folded into its single caller, `app/_layout.web.tsx`.
  The session closure pin reads 4218 (local 1032).
- The screen tests share their module mocks, and the safe-area cases
  move to a suite of their own: owned page, banner, iOS and Android
  keyboard, and an older page that gets no re-init.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 20:08:00 -04:00
Brennan Benson 4bab736f90 fix(native-chat): dock the task strip on the goal tab (#22530)
* fix(native-chat): dock the task strip on the goal tab

The background-task strip was the full width of the message box, so it
did not share an edge with the narrower goal tab underneath it. When a
goal is showing, the strip now uses that tab's width and keeps a square
bottom, and the goal tab's top stays square so the two sit on each other.

* fix(native-chat): derive the task strip and goal tab seam from adjacency

The strip and goal tab were each told by the chat session whether the
other was showing, through two flags that had to match what actually
rendered. The session also re-derived the goal tab's own visibility rule
to compute one of them.

Now each bar styles its side of the seam from the DOM: the strip takes the
goal tab's width and drops its bottom corners and shadow when the goal tab
is its next sibling, and the goal tab drops its top border and corners
when the strip comes right before it. The flags and the duplicated goal
visibility check are gone, so the seam cannot disagree with what renders,
and anything placed between the two bars falls back to the separate look.
2026-09-23 16:49:31 -07:00
Jinwoo Hong 63866c1e27 fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) (#22569)
* fix(mobile-web): drop the UA focus ring from page text inputs

Chromium rings every focused text field (:focus-visible); no native TextInput
paints one. A zero-specificity rule in its own inline block beside the Expo
root reset removes it for every page input; buttons keep the browser's ring.

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

* fix(mobile-web): draw page hairlines one device pixel thick

react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device
pixels on a 480 dpi phone; native draws one. A build shim replaces that one
assignment with React Native's own formula (roundToNearestPixel(0.4), else
1/ratio), so every page hairline matches native without touching components.

A rendered check at a real device scale (Playwright's emulated scale floors
borders to CSS px, which no phone does) measures both parity fixes.

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

* fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build

The page's dependencies require react-native, so esbuild resolves every
importer to react-native-web's dist/cjs build, which the previous filter did
not match: the shipped bundle still assigned hairlineWidth=1. The filter now
matches both builds, the rendered check requires the package the way the page
does, and a builder test reads every hairlineWidth assignment in the bundle.

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

* fix(mobile-web): draw page hairlines at a width WebKit paints too

1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints
nothing (0.3333px at a scale of 3), so the iOS shell would have lost every
hairline. The shim now uses native's device-pixel count plus half a pixel;
both engines floor a border to whole device pixels, so each paints what React
Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered
parity check now runs in WebKit at a device scale of 3 as well as Chromium.

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

* test(mobile-web): check the parity style in the existing root-reset build

Drops a second full build that read one HTML string, plus two assertions
that tested the constant against itself.

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

* fix(mobile-web): round the page hairline up to the 1/64 px layout step

Half a pixel over native's count kept borders at one device pixel but made
the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows
at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit
stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's
device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3).
The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:48:45 -04:00
Jinwoo Hong 5b6a857e41 fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam

Fill-mode sheets called Keyboard.metrics() directly, which react-native-web
does not implement, so opening one on the page threw and the shell
re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose
native half seeds from metrics() and carries the event duration, and whose
web half answers from the window (duration 0). The fill/content-sized seed
rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps
Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait.

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

* test(config): retire the drawer's exemption from the page keyboard census

The bottom drawer now reads the keyboard seam, so no module in the
source-control or review closures names react-native-web's Keyboard stub.
The census also flags Keyboard.metrics, which the stub lacks.

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

* refactor(mobile): give the drawer an imperative keyboard pair from the seam

The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight
beside its hooks. The drawer's effect is back to its original shape with
only its Keyboard calls swapped for the pair, and useSoftKeyboard is back
to {height, visible} with no metrics() seed. Seeding every consumer opened
an iOS window between willHide and didHide where metrics() still reads
open. The web pair answers from visualViewport, so it stays silent inside
the shell and lifts sheets in a plain mobile browser.

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

* fix(mobile): start the web keyboard subscription from the current strip

A keyboard already covering the page when subscribeSoftKeyboard attached
never produced onHide when it closed, so the occlusion hook and a seeded
fill sheet stayed lifted. Outside the shell only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:46:52 -04:00
Brennan Benson 069dc8a1d8 feat(agent-launch): let a caller reserve the chat session, and start terminal launches with the session picks (#22523)
* feat(agent-launch): let a caller reserve the chat session and carry session picks to a terminal launch

* fix(agent-launch): keep a caller-minted session id named for its agent, and mint the fallback the same way

* test(mobile): model the older host from the launch fields, not the refined schema

* docs(agent-launch): describe the reserved session id as conversation identity, not placement

The caller mints the session id so it knows which conversation it
started; tab placement is not keyed on it. Also puts the terminal
surface's doc comment back on createTerminalSurface.

* docs(agent-launch): say a terminal launch reads the session picks on the wire contract

The `sessionOptions` field doc still said a terminal launch ignores them, which this branch changed.

* fix(agent-launch): check a reserved session id's token after the agent name, not the whole id

A hyphenated agent name failed the one-token check, so any session id for such an agent was
refused at the wire, while every other agent without a chat has its id ignored on the terminal.
2026-09-23 16:45:28 -07:00
Neil 12a7ffdc64 test(runtime): stop worker-recovery retries from scanning inside later tests (#22567)
The legacy worker terminal recovery retry re-arms itself on a 1s..30s backoff
for as long as a dispatch stays deferred. Nothing in the runtime suite ever
resolves one, so a single test armed a loop that kept re-running recovery --
and the worktree scans it issues -- through the shared `listWorktrees` stub for
the rest of the file's run, landing inside whichever test was executing when the
timer fired. That is what made `lineage-and-scan-cache-part-03`'s per-repo TTL
test see 4 scans instead of 3 only under CI load.

Give the controller a `cancelAllRetries`, track controllers while they have a
timer armed, and cancel them from the shared runtime test lifecycle reset.

Measured over the whole 1299-test file with an afterEach probe: 25 stray
post-test `listWorktrees` calls before, 0 after.
2026-09-23 16:44:01 -07:00
Neilandfancivez 6847390c0a fix(ai-vault-search): own buffered transcript rows before they pin the parent (#22563)
Capped search rows are slices of the transcript. Own each row with the shared copier before buffering it, and keep the heap regression from #22378.

The sidebar test harness now includes the worktree listing map the toolchain banner reads, so that rerender no longer crashes.

Co-authored-by: fancivez <fancivez@gmail.com>
2026-09-23 16:02:53 -07:00
Jinwoo Hong 7f203ad5aa fix(browser): only pixel-capturing commands wait for the page to be drawn (#22528)
* WIP

* WIP2

* fix(browser): only pixel-capturing commands wait for the page to be drawn

Every targeted browser command used to take an automation-visibility lease,
which waits for two desktop-window animation frames (capped at 2 s). When the
desktop window is minimized or throttled, that wait always hits the cap, so
each phone tap or agent command stalled for up to 2 s (STA-8024).

Input, page JavaScript, layout and accessibility snapshots all work on a
hidden page; only pixel capture needs it drawn. The lease is now opt-in via
`needsPaint` on the two commands that can capture pixels through this path
(`exec` passthrough and `pdf`); `ensureVisible` is removed. Screenshots keep
managing their own lease.

(Commits 24821ae and 07b4a09 carry this change under WIP messages.)

* refactor(browser): drop the session-recreate option only no-lease commands used

Only exec and pdf take the automation-visibility lease now, and both create an
agent-browser session, so the lease's re-registration path always recreates
the session. Remove the `recreate` option from restartSessionForTarget and the
options passthrough that fed it. Also fix the keypress test comment that still
counted the lease's extra page lookup, and note that screenshots lease inside
the screenshot lock.

* fix(browser): resolve a command's page when it runs, not when it is queued

Enqueue resolved the page's guest webContents and created its session up
front. Before this PR every command re-resolved after its lease, which hid
that; now a command queued behind exec/pdf, whose lease can re-register the
page with a new guest webContents, would run against the old one.

Enqueue now only picks the page (and so the queue). The webContents lookup
and session setup move into the queued job and run when the command starts,
after the lease for exec/pdf.

The lease path's refresh collapses into that step. Resolving after the lease
already sees a re-registration that happens during the lease, so the only
thing left from the refresh is the case where the page's existing session is
bound to an older guest: that session is restarted on the new one (keeping
intercept routes) and the active-tab pointers move with it. That check now
covers every session-backed command, not only leased ones.

Admission is still checked at enqueue and again when the job starts.

* refactor(browser): drop the session-restart reconcile no page can reach

Every renderer re-registration of a page with a different guest goes through
browser:registerGuest, which calls onProcessSwap in the same tick. That
destroys the page's session and rejects its queued commands, so by the time a
queued command runs, no session can still be bound to an older guest. The
reconcile in the queue and restartSessionForTarget, its only caller, are
removed along with the two tests that called it directly.

A queued command now resolves the page's current guest, ensures its session,
and runs. The re-registration tests now report the swap through
onProcessSwap, as production does: commands queued behind the swapping lease
are rejected with browser_tab_closed, the leased pdf runs on the new guest,
and intercept routes survive the swap.
2026-09-23 17:32:04 -04:00
Brennan Benson 60bd1dfdea feat(native-chat): one shell-environment setting for every structured chat (#22387)
* feat(native-chat): one shell-environment setting for every structured chat

Structured Codex chats started from the login-shell environment, while
structured Claude chats started from Orca's own process environment, so a
variable exported in .zshrc reached one and not the other. Both now start
from the same base, chosen by a new setting:

- on (default): the whole login-shell environment, as a terminal gets
- off: Orca's environment plus PATH, locale, SSH_AUTH_SOCK, and the
  variable names the user lists

The setting is re-read each time a chat starts or resumes. It is shown
only when Chat UI, the Chat UI default view, and structured native chat
are all on. Terminal-backed chat is unchanged.

* fix(native-chat): normalize the shell-environment settings when a profile loads

A hand-edited settings file could store the variable list as something other
than an array, and the structured runtime called `.filter` on it per launch, so
a malformed value failed every structured chat create and resume, and the
settings pane render. Normalize both keys where the profile loads, the same way
the other array settings are, through one shared normalizer the runtime policy
also uses. Also pin that an uncommitted name draft survives an unrelated
settings re-render.

* fix(native-chat): keep the pinned account as the only source of a structured chat's Claude home

The session record owns which Claude home a structured chat uses, and the
acquisition pin (claudeConfigDirEnvPatch) is the only emitter of
CLAUDE_CONFIG_DIR, compared against what the child would otherwise inherit.
With the login-shell snapshot as the inherited base, a CLAUDE_CONFIG_DIR
exported only in a shell rc flipped that comparison and produced an explicit
pin to the CLI default home, which moves the CLI off its default Keychain item.

Drop the inherited CLAUDE_CONFIG_DIR in the Claude launch resolver before the
pin runs, as Codex already does for an inherited CODEX_HOME. A configured
per-agent overlay still passes through, since the record already honors it.

* fix(native-chat): drop Orca's own CLAUDE_CONFIG_DIR from a structured Claude child too

The process spawner merges Orca's process env under the launch env, so a
CLAUDE_CONFIG_DIR exported to Orca itself reached the child around the launch
resolver's drop and unseen by the account pin. One helper now strips it from
both inherited bases. Also declare the two shell-environment settings on the
runtime store contract and add the six new strings to every locale catalog.

* feat(native-chat): add shell variables one at a time with a removable list

* fix(native-chat): return focus to the name input after removing a shell variable

* fix(native-chat): use a neutral placeholder for the shell variable input

The empty input showed a grey HTTPS_PROXY as its placeholder, which reads as a
saved value, especially right after that exact entry is removed from the list.
Use "Variable name" instead, in every locale catalog.
2026-09-23 14:29:20 -07:00
Jinwoo Hong 9cdbc0c128 fix(mobile): keep the shell's window insets out of the page WebView (OTA phase C follow-up) (#22549)
* fix(mobile-web): stop the page declaring viewport-fit=cover

The shell already pads the WebView out of the status and navigation bars. With
viewport-fit=cover, Android's edge-to-edge WebView still reports the window's
bar insets through env(safe-area-inset-*), which react-native-safe-area-context
on web reads, so every page-side SafeAreaView padded a full bar a second time.
Without it env() reads 0 and the shell's pad is the only one.

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

* fix(mobile): keep the shell's window insets out of the page WebView

WebView M144+ forwards the window's systemBars and displayCutout insets to
CSS env(safe-area-inset-*) for every WebView, and Chromium applies them
regardless of viewport-fit. The shell already pads the WebView out of both
bars, so every page-side SafeAreaView (expo-router's DefaultNavigator and the
session header) padded a bar a second time. M139+ likewise resizes the visual
viewport for ime(), which the shell has already done by shortening the WebView.

The WebView now sees those three types zeroed, per Android's "zeroing" approach
(not CONSUMED, so later changes still reach it). A listener replaces the
WebView's own onApplyWindowInsets, so the zeroed set is passed back into it.

iOS needs nothing: the WKWebView uses contentInsetAdjustmentBehavior = .never
inside the padded shell and reports zero insets.

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

* docs(mobile-web): say why the page declares no viewport-fit

The earlier comment claimed dropping viewport-fit=cover makes env() read 0 on
Android; Chromium's WebView applies the safe area regardless of viewport-fit.
The page simply never asks to extend under the bars, and the shell owns the
safe area.

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

* refactor(mobile): keep the page inset zeroing private to the shell view

The transformation has no honest JVM test (the builder runs as SDK 0 there
and drops every inset type), so it moves into MobileWebShellView.kt as
private members instead of standing alone. The listener comment now covers
both the P-R listener and the S+ onApplyWindowInsets path it replaces, and
the page document's comment says the env() zeroing is Android's; on iOS the
padded WKWebView reports none.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 17:13:23 -04:00
Neil f1eb1913a6 fix(opencode2): block the pane on every session-owned form (#22548)
#22399 admitted an OpenCode 2 form.created as a pane blocker only when
metadata.kind === "question". On v2.0.15 that is an allow-list on a field
with no contract: packages/schema/src/form.ts declares Metadata as an open
Schema.Record and metadata itself as optional, and the public
POST /api/session/:sessionID/form endpoint lets any client raise a real
blocking form on a real session with no metadata. Orca dropped those, so
the pane painted no blocker while OpenCode waited forever.

Invert the default. Every form whose owner is a real session blocks;
only a form owned by the "global" MCP-elicitation sentinel is dropped,
because that owner is not a session and never goes idle, so its blocker
could not be retired. That also restores websearch.provider as a blocker:
it carries the real context.sessionID, session idle retires it, and while
it is pending the agent is genuinely stalled on the user.

Resolution is unchanged: clearAttentionForResolution keys on the exact
form id plus source session, so a resolution for a dropped form matches
nothing and cannot retire a live blocker.
2026-09-23 13:52:41 -07:00
Jinwoo Hong 1866a796fd fix: explain Xcode-blocked Git once in the sidebar and rescan on return (#22552)
* fix: explain Xcode-blocked Git once in the sidebar and rescan on return

* refactor: simplify Xcode toolchain banner to one classifier and a stateless rescan

* refactor: fold banner copy into one table and cover SSH/runtime gating
2026-09-23 16:51:23 -04:00
Jinjing 90f0c5c8ae test(file-search): pin request-key listings against the real runtime shape (#22312)
* test(file-search): pin request-key listings against the real runtime shape

* test(file-search): pin intermediate renders and late remote answers

Strengthen the stale-answer guard to assert every intermediate render reads as loading (null), add a late-answer drop case, make the tab-entry loading pin non-vacuous, and correct the e2e comment for local listings.

* test(file-search): keep only the non-duplicate runtime-listing pins

Drop the remote projection cases already covered at the hook level, collapse
the classifier integration to the loading pin, drop the local-only rapid-edit
e2e, and fix the brittle README absent-file assertion that failed CI.
2026-09-23 13:35:47 -07:00