Commit Graph
11603 Commits
Author SHA1 Message Date
Neil 63f4dac3cf chore(search): drop bundled-ripgrep reference doc; assert full packaging layout parity 2026-09-23 00:10:20 -07:00
Neil 263a63b9eb fix(search): address bundled ripgrep review findings
- Key the SSH ripgrep cache on the binary's content hash; a package bump is the only update step
- glibc verifier: read arch tokens below the slice root and accept static ELFs (arm64 release blocker)
- Ship ripgrep/PCRE2/musl license notices; bundle rg with orcad
- Packaged builds never spawn a bare rg; report fd pressure as transient
- SSH: install rg before sweep/GC, size-validate installs, back off instead of disabling on launch failure
- Scope Dependabot to @vscode/ripgrep-universal; revert unrelated lockfile churn
2026-09-22 22:19:01 -07:00
Neil 7a63515ca0 feat(search): bundle ripgrep for local, WSL, and SSH search
Ship @vscode/ripgrep-universal's prebuilt rg for all six relay platforms in
every desktop artifact. Local and WSL searches spawn the bundled binary and
drop the git ls-files / git grep fallbacks; SSH deploys upload the remote's
binary once per ripgrep version and the relay prefers it over PATH rg.
2026-09-22 21:25:38 -07:00
Jinwoo Hongandlifeodyssey 9af6a3d798 fix(cli): report a denied runtime connection instead of a dead Orca (#22341)
* fix(cli): report a denied runtime connection instead of a dead Orca

Inside Codex's macOS Seatbelt sandbox, connect() on the runtime socket fails
with EPERM. The CLI dropped the errno and reported "Could not connect ...
Restart Orca", appended "Orca is not running. Run 'orca open' first.", and
`orca status` answered ok:true with `starting` (its pid probe also gets EPERM).
An agent following that advice restarts a healthy app, which cannot help.

EPERM/EACCES on the metadata read or the socket/pipe connect now fails with a
CLI-local `runtime_access_denied` error: ok:false, non-zero exit,
operation/systemCode/processState:"unverifiable"/retryable:false and nextSteps
that say to re-run with escalated permissions and not restart. CODEX_SANDBOX
only picks the wording. `orca open` stops before launching.

The status pid probe is unchanged: a refused or missing socket proves the
caller reached the endpoint, so a later EPERM probe is another uid and keeps
#20098's `starting`. Missing, refused, stale-pid and timeout paths are
unchanged.

Adapted from the diagnosis and tests in #20487 (and #19605, #13583).

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>

* docs(skills): tell agents runtime_access_denied means escalate, not restart

The shared CLI-resolution block told every bundled skill to run `orca open`
when a command says Orca is not running. Add the counterpart for the new
access-denied code so sandboxed agents re-run with escalated permissions
instead of launching or restarting Orca. Regenerated stubs and manifest.

* refactor(cli): classify only a denied runtime connect, with a leaner error

A denied metadata read was never observed under a sandbox, and it turned an
unreadable user-data path (the Linux launch contract's root-owned HOME) into
runtime_access_denied instead of "Orca is not running". Keep metadata reads as
on main and classify only the socket/pipe connect.

One helper now maps a socket errno to the error or null; the error data keeps
only systemCode and nextSteps. Tests drop cases already pinned by status.test.ts.

* fix(cli): give not-running advice when a denied socket belongs to a dead Orca

A crashed Orca leaves its metadata and socket file behind, and a sandbox denies
the connect with EPERM before the CLI can see ECONNREFUSED. The sandbox still
reports ESRCH for a gone pid, so a denied connect now probes the metadata pid
and falls through to the ordinary unavailable path when the pid is proven gone.
isProcessRunning moves to its own module so transport and status share it.

* refactor(cli): inline the runtime_access_denied code like other CLI error codes

---------

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>
2026-09-22 22:46:17 -04:00
Neil 83dd047fd9 fix(explorer): find files by name in large local workspaces (#22369)
* fix(explorer): search local workspaces by file name across every file

The Explorer name filter only searched remote workspaces directly; local
workspaces still filtered the first 20,001 listed files, so files beyond
that cap never matched in large repos. Local name queries now rank the
whole workspace on the host, and fall back to an uncapped git listing
when ripgrep is not installed.

* fix(explorer): filter capped local listings on the host with the Explorer word rule

Replaces the Quick Open fuzzy top-32 routing, which dropped multi-word
matches and capped visible results. The Explorer keeps its instant
renderer-side filter; only when the local listing hits its cap does it
re-list on the host with the same word rule applied before the cap.

* fix(explorer): keep capped matches when host name filtering fails

- Fall back to the capped listing (and stop re-listing) if the host scan fails
- Keep primary matches when the ignored-file pass fails during a filtered scan
- Key host scans on normalized filter words; reset capped state per filter session
- Bound nameFilter size at the IPC boundary; drop the double readdir walk

* fix(explorer): match name filters without locale-dependent lowercasing

* fix(explorer): avoid render-time ref writes in the host name filter fallback
2026-09-22 19:39:52 -07:00
NeilandAdrien De oliveira ebed0964a2 feat(agents): add first-class Muse Code harness (#22216)
* feat(agents): add first-class Muse Code harness

Add Muse as a supervised Orca agent across desktop, mobile, session history, source control, local hooks, SSH, WSL, and native Windows. Preserve user settings, support Muse 1.3 hook environment allowlists, and recognize versioned foreground processes. Include question, waiting, completion, resume, and readiness coverage.

Co-authored-by: homesh-dev <300847526+homesh-dev@users.noreply.github.com>

Co-authored-by: jeffhuen <32542276+jeffhuen@users.noreply.github.com>

Co-authored-by: John Cusack <johncusackccm@gmail.com>

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>

* test(agents): cover Muse remote hook registration

* test(agents): cover Muse hook and source-control contracts

* test(agents): exclude Muse hook metadata from script mode check

* test(agents): keep Muse skill picker coverage stable

* test(ai-vault): include Muse in every-agent fixture

* test(mobile): repin Muse agent icon closure

* fix(muse): detect questions and approvals from structured Muse signals

Muse 1.3 fires no hook for request_user_input, so a pending question left
the pane "working". Its internal reminder subagents also post hooks with
their own session ids (even after Stop), which surfaced "tool failed" rows
and flipped finished panes back to working.

- Read pending questions from Muse's session log
  (user_input_prompt_requested/settled) via the existing transcript poll,
  now generalized from Codex subagents to Muse on main and relay.
- Drop child-session hooks (SubagentStart ids, or turn_id === session_id).
- Treat Notification permission_prompt as the approval wait; PermissionRequest
  also fires for auto-approved calls, so it only caches the approval card.
- Ignore Notification copy as the prompt; poll replays are not new prompts
  or turn boundaries.
- Allowlist USERPROFILE so Windows cmd AutoRun doesn't fail every hook.

* perf(muse): parse only question events from the session log

Most Muse session-log lines are large model/tool records. Filter raw lines
by the user_input_prompt_ marker before JSON.parse via an optional
readJsonlCursor line filter.

* fix(muse): unwrap batched log records and scope questions to the live turn

Review follow-ups: question events inside retained_frame batches were
skipped, and a question left open by a crash or interrupt stayed pending
for the pane's life. Share the history scanner's retained_frame unwrapper,
and only report a pending question whose run_id matches the hook turn_id.

* refactor(muse): drop type assertion in retained_frame unwrap

* fix(agent-hooks): satisfy exhaustive-switch lint in transcript poll policy

---------

Co-authored-by: Adrien De oliveira <75085839+adriendeoliveira@users.noreply.github.com>
2026-09-22 19:13:11 -07:00
Jinwoo HongandDavid Bebawy 7c46a69049 feat(telemetry): report the macOS daemon's code identity on adoption and folder-denial events (#22171)
* feat(daemon): import the macOS process code-identity probe from PR #21826

Takes `daemon-mac-code-identity.ts` and its test verbatim from David Bebawy's
community PR #21826 (stablyai/orca). The probe asks Security.framework, via
`codesign --display --verbose=1 +<pid>`, where a live process's code lives on
disk — the question Node cannot answer, and the one that decides whether tccd
can still resolve a running daemon's code identity after an app update.

Imported unchanged here so the adaptation that follows is reviewable as a diff
against the author's original.

Co-authored-by: David Bebawy <david.ayad2@gmail.com>

* feat(telemetry): report the daemon pid's macOS code identity on the two adoption events

Community PR #21826 argues that macOS terminal daemons lose Documents/Desktop/
Downloads access after an update because the daemon's own executable is
unlinked — Squirrel parks the outgoing bundle under a ShipIt staging directory
and later deletes it — so tccd can no longer map the daemon pid to on-disk
code. Today's `spawner_path_class` and `tcc_attribution` read the binary that
forked the daemon, which an in-place update deletes and recreates, so neither
can see that state.

This adds the detector as a measurement only. `code_identity` rides on
`daemon_adopted` and `daemon_pty_cwd_denied`, the two events that already
describe an adopted daemon, so denied daemons can be cross-tabbed against
healthy ones. Nothing reads the verdict: no replacement, no notice, no UI.

The probe is David Bebawy's, narrowed from a path-carrying union to the closed
enum the wire allows, and memoised per pid so one codesign spawn answers for a
whole daemon generation. Off macOS, or with no pid, it reports `probe-failed`,
which keeps both schemas strict and non-optional.

Co-authored-by: David Bebawy <david.ayad2@gmail.com>

* fix(telemetry): read the daemon's code identity fresh on every adoption event

The probe memoised its verdict per pid and never expired it, so
`daemon_pty_cwd_denied` reported whatever the probe saw at adoption rather than
what was true at the denial. That breaks the measurement in both directions: a
transient codesign failure during startup pinned `probe-failed` for the rest of
the run, and the `parked` to `unresolvable` transition became invisible.
Squirrel leaves the parked bundle in place until the next update, which can be
days, so a daemon adopted as `parked` and denied as `unresolvable` is the exact
crossover this study exists to catch, and the cache hid it.

Now every ask runs its own codesign. Only concurrent asks about the same pid
share a probe, and that entry is cleared as soon as it settles, so nothing
survives to be reported later. Both events are rare enough that one spawn each
is not worth a cache.

* fix(telemetry): drop the dead existence check from the code-identity probe

The classifier stat'd the path codesign displayed and called a missing one
unresolvable. That path is unreachable: once the executable is unlinked,
`codesign --display` prints no `Executable=` line at all and exits 1 with
"No such file or directory", which the fallback below already classifies as
unresolvable. Verified directly on Darwin 25.5 against a signed binary deleted
out from under a running pid.

All the branch actually covered was the window between codesign reading the
path and this process stat'ing it, and it paid for that with a synchronous
stat on the main thread.

* fix(telemetry): never classify a timed-out codesign probe as a verdict

`runProcess` kills the child at the deadline and reports `timedOut`, but the
runner type dropped that field, so a codesign killed mid-display could still
have printed an `Executable=` line and been read as `resolved` or `parked`.
A half-written display proves nothing about where the daemon's code lives.

The runner result now carries `timedOut`, and a timed-out probe returns
`probe-failed` before the output is looked at.

* docs(telemetry): state what each code-identity verdict actually asserts

A reviewer read `resolved` as a claim that the executable sits inside the
installed app and asked for that to be validated. It is not that claim, and we
are not making it: proving containment needs the pid record's spawner path, and
deciding anything from where the code lives is #21826's proposed behaviour
rather than this measurement.

The enum doc now spells out all four verdicts in the terms the probe can
actually support, and says plainly why `resolved` stops at "exists and is not
parked". A matching note sits beside the parked-path pattern.

* docs(telemetry): stop asserting how long a parked bundle survives

The probe's rationale claimed Squirrel keeps the parked bundle "until the next
update". A reviewer claimed the opposite, that it is deleted at the end of the
same install. Neither holds up against this Mac's ShipIt log: the install moves
the outgoing bundle to a TMPDIR ShipIt directory and logs no removal of it at
all, and the one "Couldn't remove owned bundle" line names the incoming
download staging copy, not the parked one. Every parked bundle from the last
two days is nevertheless gone now.

So the rationale in the probe doc, the enum doc, and the reprobe test comment
now assert only what is established: the outgoing bundle is moved aside at
install and disappears later on a schedule we have not pinned down. That is
already enough to justify the design, since one pid's verdict can change
within an app run, which is exactly why every ask reads fresh.

* feat(telemetry): report readable TCC-gated spawns as the code-identity control

`daemon_pty_cwd_denied` gives code_identity's hit rate on denials, but a
readable spawn emitted nothing, so an `unresolvable` adoption with no denial
could not be told apart from a user who never opened a terminal in Documents,
Desktop, or Downloads. The false-positive rate that gates #21826's
auto-replacement was unmeasurable.

`daemon_pty_cwd_readable` now fires when a daemon reads a TCC-gated cwd, once
per daemon and folder class per app run, with the same origin properties as
the denial event. The read-out becomes a 2x2 of code_identity against
readable/denied on protected-folder spawns. Fire-and-forget on the spawn path
like the denial emit, and no app-side directory read.

* refactor(telemetry): one emitter and schema for both cwd verdicts, no dedupe state

The once-per-daemon dedupe on `daemon_pty_cwd_readable` was keyed before the
probe ran, so a daemon first seen readable while `parked` never reported again
once it turned `unresolvable` — the one cell that would count most against
#21826. It also counted per daemon while denials count per spawn, so the 2x2
mixed units.

Readable now reports every spawn, like denied, and both events share one
emitter (`trackDaemonPtyCwdVerdict`) and one schema. The TCC-folder gate lives
in the verdict branch. The origin fields are one shape spread into both
schemas. The codesign probe calls `runProcess` directly and tests mock it,
replacing a test-only runner parameter. The repeated "never cached" rationale
is now said once.

* fix(telemetry): rename the shared origin schema fields for the anti-slop gate

no-shape-in-symbol-names rejects daemonOriginShape; the fields are event props.

---------

Co-authored-by: David Bebawy <david.ayad2@gmail.com>
2026-09-22 22:10:51 -04:00
Brennan Benson 1b85be67d8 feat(native-chat): notify on every settled structured turn (#22105)
* feat(native-chat): notify on every settled structured turn

A structured chat that finished while you were elsewhere lit the sidebar
but never raised an OS notification, and a notification that did arrive
for one could not open the chat it came from.

Unread and delivery now come out of the single resolveAgentAttention
decision the terminal lane already uses: the structured dispatcher calls
applyAgentAttention instead of applyAgentAttentionUnread, so the same
policy that decides what to light also decides what to deliver, through
the same sound and blocked-permission tail.

Every settled turn notifies, as the CLI lane does. Success says
"finished"; failure and cancellation say "stopped" through the shipped
agentInterrupted flag rather than a second vocabulary. A turn whose
outcome the host never stated stays unknown and lights nothing.

The host now dedupes mobile fan-out by event identity (scope, session,
turn) beside the existing per-workspace burst cooldown, so a completion
two windows both saw reaches the phone once while each window still
decides its own banner. Clicking a structured notification reveals the
chat tab: its pane key's leaf is synthetic, so focusTerminal would hunt
a split-layout leaf that does not exist.

* fix(notifications): spend each mobile gate only when it actually notifies

Two review findings on the structured-chat notification lane, both real.

The mobile event gate consumed its reservation before the per-workspace
burst cooldown ran. Two chats in one workspace share that cooldown key,
so the second chat's completion could burn its event key and then lose
the cooldown to the first chat — never announced, yet permanently marked
as announced, so a later window dispatching it could no longer reach the
phone. The gate now peeks first and records the event at dispatch, which
also keeps a known duplicate from burning the cooldown slot.

A notification id is minted from the status row's stateStartedAt, and the
row re-projects that field as the turn settles: the working episode's
start moves into stateHistory and the settled start takes its place. A
banner raised in the window before that re-projection therefore carried
an id acknowledgement never rebuilt, leaving it on screen for good.
Acknowledgement now collects ids for the row's left episodes too — the
same episodes the unread check beside it already scanned, so the two
halves finally read the same turns. Lane-neutral: the terminal lane
mints its ids the same way and had the same gap.

* fix(notifications): drop the mobile event gate and reveal chats in folder workspaces

The per-event mobile dedupe defended against one completion being
dispatched by several Orca windows. Only one renderer mounts the
structured attention bridge, the completion feed is live-only with no
replay, and any in-process duplicate lands inside the existing 5s
per-workspace burst cooldown, which already collapses mobile and
desktop alike. The gate never acted on a real sequence, so the wire
field, the shared ledger and its tests go; mobile delivery is back to
main's behavior.

A folder workspace id ("folder:<id>") has no "repoId::" prefix, so the
click binding was skipped and clicking a chat notification there did
nothing. The chat route selects its workspace itself through
ui:focusEditorTab, so it now binds without a repoId; the terminal
route is unchanged.

* fix(notifications): retire the banner ids actually dispatched, not ids rebuilt from a moved row

A banner's id is minted from the status row's stateStartedAt at dispatch, and that field moves
afterwards: a completion can outrun the settled re-projection, and a settled structured row is
re-stamped with no history entry by any later journal row (a cancel appends a status note after
the turn settles). Rebuilding ids from the row's episodes at acknowledgement missed the second
case and fanned out up to 21 mobile dismissals per pane for ids never raised.

The shared delivery tail now records each dispatched id per subject; acknowledgement retires
those plus the current-row rebuild it always had. The acknowledgement collector is back to
main's single-field form.

* refactor(notifications): retire announced notifications by subject in main

Main now records, per pane, the ids it actually announced (a desktop banner
shown or a phone alert sent) and an acknowledgement passes the acknowledged
pane keys so main retires all of them. This replaces the renderer-side record
of dispatched ids: main is where the announcement happens, so it records only
real announcements, including phone alerts whose desktop banner focus
suppressed. The id rebuilt from the current row stays as the fallback after
a restart empties the in-memory record.
2026-09-22 18:35:18 -07:00
Brennan Benson 0b6f60ac3b fix(worktrees): a listing that predates a create can no longer retire the new workspace (#22311)
* fix(worktrees): a listing that predates a create can no longer retire the new workspace

Creating a workspace while another one was being deleted could land the user on the
empty Landing screen with the new workspace's Claude launch cancelled. A worktree
listing whose scan began before the create finished (the prepared checkout is hidden
from listings while it is still locked) was applied after the create returned, and the
renderer read the new worktree's absence from that authoritative listing as a deletion:
it purged the workspace's tabs, tombstoned the pending structured launch (so the host
session was retired the moment it published) and cleared the active selection.

Renderer: each provider invocation records the local create sequence when it begins
(shared by every caller that coalesces onto it). The merge no longer drops or retires a
worktree this client finished creating after that point, and the change-event diff
purge defers to what the merge actually dropped. Hydrated rows keep being reaped.

Host: a scan a worktree mutation overtook is published as non-authoritative instead of
authoritative, and the create paths invalidate scans as soon as the new worktree is
listable rather than only at the end of the create.

* fix(worktrees): an overtaken scan re-derives the catalog, and the create fence rides every listing

A scan a worktree mutation overtook was published as non-authoritative with a
failure reason, which lit the sidebar's scan-failed indicator, hid external
worktree cards and emptied the visibility dialog after a routine create/delete
overlap, and its early return skipped the abort, currency and dual-owner guards
every other branch honours. The listing now re-scans a bounded number of times
instead (the invalidation already dropped the cache, so it scans afresh or joins
a sibling's scan) and past the bound answers non-authoritative through the same
guard chain, with no failure reason.

The renderer's create-sequence inception is now captured where the provider
invocation starts and released only when it settles, so a waiter released early
cannot reset it for a later joiner, and the direct-SSH scheduler's refresh --
the only listing on hosts with no scan cache -- now carries it too. A re-created
worktree id is re-inserted at the back of the create-record eviction order so
the bound holds.

* fix(worktrees): the host re-runs a scan a worktree change overtook, for cached and SSH listings alike; the renderer create fence is removed

* fix(worktrees): the runtime listing re-runs a scan a worktree change overtook, and a headless host bumps the generation it witnesses

* test(runtime): the change-invalidation suite lists through the public runtime method instead of casting to a protected one

* fix(runtime): the runtime's own worktree change event bumps the scan generation its listing witnesses

A headless host has no window notifier, so a delete (and every other change the runtime announces) never moved the generation the runtime listing re-runs against; a listing the delete overtook published the removed row as authoritative until the next refresh. The bump now rides the runtime's change event, ahead of the send, as the desktop notifier already does.

* fix(worktrees): a listing that spends its re-scan bound logs the churn on the host

Past the bound the listing answered without a trace, so continuous create/delete churn could not be diagnosed from a host log. One warning names the repo and the number of overtaken scans; a caller that withdrew stays silent.
2026-09-22 18:17:19 -07:00
github-actions[bot] 60d12329b2 Update README downloads badge 2026-09-23 00:58:25 +00:00
Jinwoo Hong 4f814c1dd4 fix(browser): scope floating browser shortcuts to their own panel (#22361)
* fix(browser): scope floating browser chrome shortcuts to their own panel

The floating panel rendered its browser with the default 'focused' scope, so
a focused split browser and the floating browser both answered chrome chords.
The floating browser now answers only chords from inside its own overlay, and
a focused split yields chords whose target sits inside the floating panel.

* test(e2e): pin floating browser shortcut scope

* test(e2e): reuse split shortcut fixtures in the floating scope spec
2026-09-22 20:33:53 -04:00
Jinwoo HongandClaude Opus 5.5 2155673da3 fix(usage): price Codex long context per request, not per aggregate (#22360)
* fix(usage): price Codex long context per request, not per aggregate

OpenAI bills a request whose prompt exceeds 272K input tokens at the
long-context rates for that whole request. Orca applied 272K as a
marginal threshold to daily, per-range and per-session token totals,
so ordinary days of short requests were billed mostly at long-context
rates and the summary disagreed with the model breakdown.

Classify each token_count at parse time and carry long-context token
counts through every rollup; the estimate bills that subset at the
long-context rates and the rest at base. Bump the cache schema to 6 so
existing caches rescan and pick up the split.

* fix(usage): never class a total-only Codex delta as long context

A token_count with only running totals yields the increase since the
previous total, which can span several short requests; classing that
aggregate as one >272K request would bill short requests at long-context
rates. Only a last_token_usage record is one response. Also note that
OpenAI's GPT-5.4/5.5 pages say "full session" where 5.6/6 say "full
request"; the per-request reading is the stated assumption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:33:18 -04:00
Jinwoo Hong 11083ac4d3 fix(mobile): the page's auth-failed banner offers Re-pair again (#22363)
#22283 dropped all three banner actions on the page, on the claim that
/pair-scan sits outside the page's route root so Re-pair cannot work there.
It does work: the host screen's router is the route handoff, which posts a
target the page does not serve to the shell (route-handoff.web.ts:207), and
on the emulator the tap opened the native scan screen and Back returned to
the same page document.

The page's sibling now renders Re-pair as native does, wired to the same
onRepair, plus a muted line for the two it still cannot honour: "Reconnect
or remove this host from the Orca app." forceReconnect stays null on the
page and removal keeps refusing; native renders its three actions as
before. The doc comments and the web-overrides reason are corrected, and
the reason's drifted citations re-resolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:24:13 -04:00
Jinwoo Hong dfff3915c4 fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split (#22340)
* fix(browser): scope back/forward/reload/zoom/grab shortcuts to the originating split

With two browser panes visible in a split, Back, Forward, Reload, Hard
Reload, page zoom and Focus Address Bar fired in every visible pane. Main
forwarded these guest chords without the page id, and each split's active
pane subscribed. The renderer-side listeners for the same chords were also
window-wide per pane, so a key pressed in the toolbar (or in a terminal in
another split) reached every active browser pane.

Guest-forwarded chords now carry the originating browserPageId; preload
admits only well-formed payloads and each pane ignores ids that aren't its
own. Toolbar-path listeners use the same focused-split scope Find already
uses. The streamed remote pane's history chord moves onto that scoped hook.

Cmd/Ctrl+C grab (STA-3319) gets the same scope and no longer arms while a
text selection exists outside the browser pane, so copying from the native
chat transcript works again.

* refactor(browser): simplify split shortcut scoping per review

Drop the preload payload admission (main and preload ship together), fold
the three inline scope checks into browserChromeShortcutOwnsEvent, and
replace the outside-overlay selection check with a plain live-selection
rule so Cmd+C copies from surfaces that do not move split focus.

* refactor(browser): share one zoom command type and tidy shortcut comments

BrowserPageZoomEventDetail and BrowserPageZoomCommand were the same shape;
keep one in shared/browser-page-zoom.ts and route guest and local zoom
through a single handler.

* refactor(browser): narrow the zoom event with instanceof instead of a cast

* test(e2e): pin split-scoped browser shortcuts

Two browser splits (and a terminal beside a browser) now prove that Back,
Forward, Reload, Hard Reload, page zoom, Focus Address Bar, and the element
grab chord act only on the split that sent them, from both the guest page and
the browser toolbar. A native chat selection proves Cmd/Ctrl+C copies instead
of arming grab. Split fixtures move to a shared helper so both specs reuse them.
2026-09-22 20:14:19 -04:00
Jinwoo Hong d11b3e226d fix(mobile): page Back with a dirty markdown draft opens the unsaved-drafts prompt (#22362)
The session's Markdown actions registered BackHandler natively only, so on
the page an unsaved draft left the key unclaimed and the shell's pop dropped
the edits without the prompt. The hook now claims through useBackClaim on
both platforms: always natively, where leaveSession replaces to the host at
the root instead of exiting the app, and on the page only while a draft is
dirty, since an unclaimed press there is already the shell's own leave.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 20:09:56 -04:00
Jinwoo Hong 3f9582bc90 fix(mobile): removing a host deletes its page cache through the one process store (#22352)
* fix(mobile): removing a host deletes its page cache through the one process store

Host removal never deleted the removed host's page generation cache, so it sat on disk
until four other hosts were activated and a re-pair could reopen the old tree. Every
caller also minted its own GenerationStore with its own queue, so a removal's index or
update-failure-log write could drop the mounted session's write landing inside it.

The store is now one per process (processGenerationStore, with a reset-for-tests seam);
the shell runtime, host removal and Troubleshoot all share it, and removal deletes the
host's cache after the metadata commits, fire-and-forget beside the failure forget.

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

* test(mobile): the cold-start store per mount is the test's, not production's

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:57:19 -04:00
Jinwoo Hong 0e6862cbcc fix(mobile): the page offers no control whose only effect is a re-dial it cannot make (#22326)
* fix(mobile): a Retry that can only re-dial is not offered where nothing dials

Six failed-load screens share one Retry shape: re-dial a host that is not
connected, otherwise re-read. On the page the re-dial is inert
(`client-context.web.tsx:55`) and each screen's load already re-runs when the
shell's client reconnects, so in the disconnected state that Retry did
nothing at all. `connectionRetryAction` makes the decision once and answers
null when a re-dial is needed and none exists; agent history, the file
explorer root, the file preview, git history, the source-control status gate
and the diff review render no Retry for null.

The explorer's per-folder Retry keeps its control: it queues the folder, and
the queue drains on the next `connected` whoever brought it back.

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

* fix(mobile): the page offers no re-dial, so no header offers one

`forceReconnect` on the page was `() => Promise.resolve()`: the shell owns
the connection and nothing in the document can re-dial it. The host header's
Reconnect and the session header's "tap to retry" were wired to it and did
nothing there. The context member is now nullable and the page's provider
hands out null, so the compiler found every caller: both headers render no
reconnect affordance for null, and the session status keeps the verdict
label without promising a tap.

Native providers and the recording adapters still pass a function, so
nothing a phone renders changes. The session route's host-JSX parity hash
moves for the header's extra null check; the page test doubles that stubbed
the old inert re-dial now stub null.

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

* docs(mobile): the auth-failed banner cites the page's re-dial as null

Three comments and the banner's override reason still said the page's
`forceReconnect` was an inert `() => Promise.resolve()`, and cited
`client-context.web.tsx` lines the previous commit moved. They now say null
and point at the lines that hold it.

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

* test(mobile): the session closure gains the page's Retry decision

`connection-retry-action.ts` is the one module the Retry fix adds to the
session route's page closure, reached through the explorer, source control
and git history it docks. Measured on this head with all five generators run
first, and diffed against the pre-change closure: one local module added,
none removed.

Session route closure 4207 -> 4208 modules, local 1021 -> 1022.

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

* docs(mobile): the capability probe belongs on the page, and says why

The push fence excluded `runtime-capability-probe.ts` because the session
route and the host screen run it. The session half holds, and the probe
works there: `status.get` carries no client identity and makes no write, the
shell forwards it like any non-`native.` request, and the desktop's mobile
allowlist admits it. The host-screen half no longer does:
`codex-reset-credit-capability.ts` is reached only from `accounts.tsx`, which
the bundle carries and the page hands to the native screen.

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

* test(mobile): keep the session retry test's cast under its disable line

The formatter wrapped the cast onto the line after the disable comment,
which left it uncovered. The cast now sits on its own line directly below
the SAFETY note.

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

* test(mobile): the agent-history Retry test mocks the pathname the handoff reads

Main's page route handoff now subscribes to `usePathname` (#22300), and the
Retry suite this branch added mounts that handoff with an `expo-router` mock
that lacked it. Same one-line addition main made to the back-handoff suite.

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

* test(mobile): pin the reload each hidden page Retry relies on

Hiding a Retry on the page rests on the screen's load re-running when the
shell's client reconnects, because nothing on the page re-dials. Only the
explorer's folder drain pinned that. Each other site now has a case that
starts unreachable with no Retry and asserts the load goes out on the
client and state the reconnect delivers: agent history (status.get), file
preview (the preview read), diff review (the snapshot load), git history
(git.history) and source-control status (git.status, in the loaders suite
because the panel test mocks the state hook).

Each goes red when the `client`/`connState` dependencies it guards are
removed; for source control that is both `loadStatus` and the
`loadBranchCompare` it depends on.

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

* test(mobile): one import of the transport types in the source-control loaders test

CI's native code-quality audit denies the duplicate-import warning the reload pin added.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 19:41:38 -04:00
Jinwoo HongandClaude Opus 5.5 e16d9ca597 fix(usage): price GPT-6 Sol/Luna, Opus 5.5 and Fable 5.1, and correct GPT-5.6 rates (#22350)
* fix(usage): price GPT-6 Sol/Luna, Opus 5.5 and Fable 5.1, and correct GPT-5.6 rates

gpt-6-sol and gpt-6-luna had no pricing entry, so their tokens were left
out of the Codex cost total. claude-opus-5-5 and claude-fable-5-1 matched
their major's pattern and were billed at Opus 5 ($5/$25) and Fable 5 ($1
cache read) rates. The GPT-5.6 Sol/Terra/Luna rates no longer matched
OpenAI's published pricing (Luna was 5x high).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(usage): price gpt-5.2-pro, accept (max)/(ultra) tiers, tighten point-release matching

gpt-5.2-pro matched the gpt-5.2 prefix and billed at $1.75/$14 instead of
its $21/$168. Codex's `max` and `ultra` reasoning tiers in parenthesized
form left the model unpriced. The new fable-5-1/opus-5-5 patterns also
accepted a trailing letter, so `claude-fable-5-1m` read as Fable 5.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 19:33:56 -04:00
Brennan Benson 7cb4380179 fix(sidebar): hang the subagent chevron in the card gutter (#22331)
A top-level agent with subagents drew its expand chevron inline before the
status dot, pushing the dot, icon, and title right of every other agent row.
The chevron now hangs in the card's left gutter so all top-level rows share
one status column, and leaf rows no longer reserve a spacer.
2026-09-22 15:48:05 -07:00
Jinwoo Hong 7240368726 feat(mobile): a failed hybrid-shell update is recorded on the device and shown in Troubleshoot (#22321)
* feat(mobile): name why a bundle fetch refused what arrived

The fetch threw plain errors whose only content was prose naming asset
paths and hashes, so a caller could not keep the cause without keeping
the prose. Each refusal now carries a code beside the unchanged message.

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

* feat(mobile): record why a hybrid shell update failed, on the device

A release build forwards no console output to logcat, so a refused or
failed page update left the fallback banner and nothing else. Every exit
from a failed update read now emits a record-update-failure effect: the
cause as a closed code (never an error message), the generation offered
and the one on disk, and what went on screen instead. The runner stamps
host id and time and the generation store appends it to a bounded log in
the cache root, five per host and twenty in all, oldest evicted first.

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

* feat(mobile): forget a removed host's recorded update failures

Removal clears the host's entries from the shell's update-failure log
after the metadata commit, unawaited and best-effort: it is evidence
about a host that is gone and never a reason to hold the removal.

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

* feat(mobile): show recorded update failures in Troubleshoot

A "Workspace updates" section lists the newest recorded failure of each
paired host: the reason, the generation offered, and what the shell
showed instead. It renders nothing until a failure has been recorded and
mounts only where the hybrid shell can run.

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

* test(mobile): type the update-failure row doubles without casts

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

* fix(mobile): forget a host's update failures once a newer generation commits

The Troubleshoot row reads "Last update from Host N failed", which stops
being true the moment a later update from that host lands. The activated
step for the build this flow downloaded now emits forget-update-failures
for the host. A cache open, an offline open and a same-build hit activate
a build the flow never requested, so they leave the record alone.

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

* test(mobile): re-pin the session closure for the shared journal producer #22299 added

main at 9ece273056 measures 4212 modules against a pin of 4211: the native-chat fix
added src/shared/agent-session-journal-producer.ts, which three shared modules on the
session route import, and its PR touched nothing under mobile/ so the mobile job never
ran. Re-measured on this merged head and the joiner read off the closure list.

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

* fix(mobile): forget update failures on a download's activation, not a build-id match

The fetch reads the manifest again and commits the build that read named,
so a host that moved between the session's read and the fetch's committed
a build other than requestedBuildId, the forget was skipped, and the stale
"last update failed" line outlived the update that should clear it. The
activating state now says where it came from, download or cache, and the
forget follows a download's activation.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 18:35:51 -04:00
AtejiMan c2d9d12b1f fix(cli): describe Linear write support (#21830)
Fixes #21829
2026-09-22 15:03:17 -07:00
Brennan Benson 9ece273056 fix(native-chat): journal rows name the agent that produced them (#22299)
* feat(native-chat): carry producer linkage on every journal row

A journal is the durable record of one agent SESSION, and a session that runs
subagents journals their rows into the same timeline with nothing on the row
saying which agent wrote it. Add that: a per-row linkage bundle naming the
producing agent, its parent, the provider's raw parent reference as provenance,
the kind of work, and which run of the agent produced the row.

The bundle rides the row BASE, not the body: two nested prompt shapes are
strict, so an unknown key on a body makes the whole row parse as malformed. It
is deliberately not a schema-version bump either — an unknown `v` makes a row
unreadable and latches the host read-only, while an unknown key is ignored, so
an older host reads a stamped row and behaves exactly as it does today.

One reader predicate interprets absence, by presence and not by truthiness: an
id that failed to resolve is still an id, and a truthy test would read it as
root and put the child's content back on the parent. The parent-facing status
scans — thinking, the running tool call, the latest assistant line and the
quoted prompt — now skip rows a subagent produced. The transcript is left
unscoped on purpose: it shows every agent's output.

No producer stamps anything yet; this is the carrier and the reader.

* fix(claude): attribute a subagent's journal rows to the subagent

The Claude translator already parsed `parent_tool_use_id` on every envelope and
threw it away. It now resolves that reference to the producing agent's canonical
task id — never to the reference itself, which names the tool CALL and is
re-minted on every resume, so a row stamped with it would split one child into
two the moment it resumed. The raw reference is kept beside it as provenance.

Resolution is its own module rather than more roster: the roster maintains the
spawn-group row a user reads, while this answers, for one frame's parent
reference, whether the rows it produces are the session's own agent's, some
child's, or nobody's yet. It reads the alias table directly to tell "an
announcement named this spawn call" from "this id is simply unknown", which
comparing the canonical id against the raw one cannot do when the two match.

Where the identity is not final the row waits rather than guessing. A top-level
spawn whose `task_started` has not landed is the one case that can still
resolve, so its rows are held — bounded at 64, oldest written first — and
released when the announcement arrives or when nothing can name the producer any
more. Nothing is dropped and nothing is written as the parent's.

A release that announces no tasks at all is decided immediately instead of held:
nothing stable is ever reachable for its children, and an id that rotates is
worse than no id because it is silently wrong rather than visibly absent. Those
rows read as the session's own, exactly as they do today. Holding them instead
would strand every row that REVISES an earlier one — a tool result would leave
its tool row reading "running" for the rest of the turn.

The attempt counter moves in exactly one place, the existing reactivation branch
where a new spawn alias reopens an entry, and is gated on that observed alias
change rather than on the counter, so a late duplicate cannot advance a settled
run. The first run carries no attempt at all.

The group row keeps its own module's write path, now named there, because it is
the one row written from a child's frame that is deliberately the parent's.

* test(native-chat): pin producer linkage end to end, and fix the harnesses first

Four harnesses in this area silently discarded the append options they were
handed, so every assertion about attribution would have passed against
`undefined`. Two are fixed here — the journal double behind the real deferred
sink, and the Claude subagent translator's sink — and each records the options
beside its existing call log rather than on it, so the assertions about call
order stay about call order.

The read side is pinned first, because linkage correct in the store and never
read by the projection is the way this ships looking finished and fixing
nothing. The three defects are asserted through the live-turn and projection
readers: a parent no longer reads as thinking because its child is reasoning, no
longer shows its child's running tool, and no longer quotes its child's prose or
prompt. The opposite direction is pinned too — the transcript still renders the
child's output, and a parent's own line is never suppressed.

Also covered: a resumed child keeps one identity while its spawn call id
rotates; a child row arriving before its announcement is held and then written
linked rather than dropped; a held row is written under the raw reference when
no announcement ever comes; the buffer's bound writes the oldest row rather than
losing it; an unresolvable id reads as a child rather than as the parent; a row
with no linkage reads as root; the schema version is unchanged; and a strict
prompt shape still parses, with a positive control proving that strictness is
real and is why linkage rides the row rather than a body.

* test(native-chat): give the journal double's cast its SAFETY rationale

Editing inside the object literal re-attributes the pre-existing assertion to
changed lines, and the changed-code gate requires a line-specific rationale.

* fix(claude): name the agent that spawned a nested subagent

A grandchild's rows carried an agent id but no parent, and under this
journal's semantics an absent parent is not silence — it is the claim that
the session's own agent spawned the row's producer. For a task spawned from
inside another subagent's sidechain that claim was simply false.

The frames from such a task carry exactly one handle: the nested call's tool
id. That id was journaled once already, as a tool-use block on the row of the
child that made the call, so the child is recoverable from it — but only if
something remembers which row carried it. The registry that already tracks
which tool calls reached the top-level transcript now records the sidechain
ones too, against the reference naming their owner, and the resolver follows
that reference to name a row's parent.

The reference is recorded, not an identity, and it is resolved through the
same path the owner's own rows resolve through, so a parent id always matches
the agent id the parent's rows carry however either was settled. A row now
persists only once BOTH its producer and its parent are final; a grandchild
whose child has not been announced yet waits in the same buffer, and leaves
it through the same three doors.

* test(claude): pin the streamed-text lane's attribution instead of only capturing it

The checkpoint harness was fixed to record the append options, and then nothing
asserted them: all five of its tests passed unchanged against an implementation
that resolves no producer at all, so the lane's attribution was covered by a
capture and no claim.

These assert it: a block streamed inside a child carries that child's linkage,
the session's own carries no keys at all, a checkpoint is held rather than
written while the producing agent is provisional, the flush before settlement
writes a held block under the raw reference rather than losing it or filing it
as the parent's, and a block's producer is resolved once and kept — every
checkpoint rewrites the same row, so a producer that moved would file one
agent's prose under two identities.

* refactor(journal): name the linkage row fields for their role, not their shape

The anti-slop gate rejects "Shape" in a symbol name. These are the linkage
fields a render item carries, and the name now matches the sibling helper
that builds them.

* fix(claude): hold the session's first subagent instead of filing it as the parent

A child's first frames can arrive before the `task_started` that names it, and
the resolver treated "this release has announced no task" as a settled fact
about the CLI. Before its own first announcement every session looks exactly
like that, so the FIRST subagent's pre-announcement rows were written straight
out as the session's own — the whole defect, for the first child of every
session, persisted with no backfill to repair it.

A spawn call the session forwarded at top level is positive evidence that an
announcement is still expected, so it now outranks the release check. A release
that genuinely announces nothing is unchanged: its rows reach the release
verdict at settle and still read as root, just written a little later.

Also stops a malformed owner chain that loops back from naming an agent its own
parent; the depth guard bounded that walk but could not make its answer mean
anything, and absence is the truthful claim.

* fix(claude): read a tool result as its caller's row, not a child's

Every top-level tool call is a forwarded tool id, not just a spawn, so a result
frame naming its own call as parent resolved as a child awaiting an
announcement that is never coming. The row was parked until the turn settled
and the tool sat `running` in the meantime.

A frame delivering the result of the very call it names is the caller consuming
its own output; only a spawn call ever gets a sidechain. Pins added for that and
for the first-subagent hold, and the never-announced case now asserts the row
was WRITTEN as root rather than that it carries no agent id, which an absent row
also satisfied.

* fix(journal): refuse an empty producer id, and drop a bad one without losing the row

The reader that scopes a parent's surfaces tests PRESENCE, so `agentId: ''` is
present: a row carrying it reads as a subagent's and disappears from its own
author's surfaces for good. Neither validator caught it — the wire schema
accepted any string, and the persisted-row guard type-checked nothing in the
bundle at all, against that file's own stated policy.

The wire schema now requires a non-empty id. The persisted side sanitises
instead: a bad linkage field is DROPPED and the row is kept. Rejecting there
would turn a tightened validator into a whole-store kill switch, and degrading
a row to the session's own agent is what every row said before linkage existed.

* fix(native-chat): answer the turn activity line for the session's own agent

`selectStructuredAgentTurnActivity` is a "what is this agent doing right now"
reader and was not scoped by producer. It builds a label set from every
tool-call row in the turn — a subagent's included — and both readers below it
use that set to suppress a line that repeats it. So a CHILD's tool label could
blank the PARENT's activity line: child data deciding the parent's surface.

Live, not latent: the provider-activity branch is populated in this lane, and
it consults the label set without ever consulting `providerFrame`, which is
what the status fallback loop relies on. Scoped once at the top, so both
readers share one interpretation point. Renderer and mobile share this
function, so both are covered.

* fix(journal): stop a lifecycle batch stamping one producer onto N mutations

A lifecycle-batch row carries N mutations but stamped linkage at ROW level, so
a future mixed-producer batch would silently attribute every mutation to
whoever opened it. Both callers are single-producer today, so this was latent.

The write path no longer accepts linkage for a batch, which removes the failure
mode by construction rather than guarding it. The reducer still READS linkage
off a batch row — a row may arrive from a host that writes one — and a genuinely
mixed batch would have to stamp per mutation, which nothing needs yet. Chosen
over adding a per-mutation field because that would persist a new key forever
with no writer and no reader.

* docs(journal): say why each unlinked write site is unlinked, and drop two false claims

Completes the write-site audit the PR claims. Prompt rows carry no linkage and
CANNOT: a prompt arrives through the SDK's permission callback, whose options
carry a request id and the tool awaiting approval and no parent reference of
any kind — unattributable at that site, not deliberately root. Turn rows are
deliberately root and now say so.

Two comments justified decisions by mechanisms this store does not have. The
linkage docblock cited compaction dropping a start row and a pagination
boundary; there is no compaction, and pagination is complete-or-reset. Per-row
repetition is still right, for the reason that is actually true: every reader
scans back from the tail and stops at the turn. A test carried the same false
framing. `claudeFrameParentRef` claimed to read the field by the same rule as
`isRootClaudeFrame`; it is deliberately stricter on the empty string.

* refactor(claude): write a child's rows through, then correct the attribution

Four misattribution paths shared one cause: the lane committed to an
attribution verdict at write time and could never revise it. That followed from
"the journal has no backfill", which is false — re-appending an `itemId` bumps
its revision, the reducer rebuilds linkage from the newest row, and it pins
`sequence`/`observedAt` so a correction does not move the bubble. This lane
already relied on that twice.

So the order inverts. A row whose producer is still provisional is written
immediately, stamped with the spawn call's own id, and re-attributed in place
when the announcement names it. Bookkeeping no longer gates a user's view of
what an agent said.

The hold buffer is deleted rather than left as a pass-through. Corrections are
bounded and die four ways: the announcement, turn settle, teardown, or the
bound. Passing the bound gives up on that producer WHOLESALE — correcting some
of a child's rows and not the rest splits one child across two ids, which is
worse than correcting none. A correction that would change nothing is dropped
rather than burning a revision.

The streamed lane loses its producer latch, which pinned the first verdict
permanently and is why an announcement one frame later could never reach the
row. Every checkpoint rewrites the same identity, so there is one row per block
and re-resolving can only revise it; the latch was guarding against a split
that cannot happen on this path. A block that stops streaming before its
announcement is re-attributed explicitly, since nothing else revisits it, and
the announcement is now observed BEFORE the forced flush that would otherwise
stamp it a line too early.

Also narrows the no-announcements-at-all escape so it no longer swallows a
forwarded spawn call. That escape now applies only to a sidechain id no spawn
call ever forwarded, where there is genuinely no handle to stamp.

* fix(journal): move two test doubles onto the signatures they pin

Both failed typecheck while passing at runtime, which is what a test double
gets to do: vitest never typechecks them.

The sink's lifecycle-batch double still read producer linkage off the batch
input after that input stopped carrying any, so it had no property in common
with the linkage type. The fence is now all it records, which is what the
narrowed contract actually forwards — and what the test beside it already
asserts.

The row-schema helper returned the whole six-arm `JournalRow` union while every
caller reads `body`. It now narrows to the item arm it always builds, so the
assertions read it directly rather than through a cast.

* fix(claude): resolve a tool result to its real caller, not to the session root

A nested tool row could end stuck `running` with its result content dropped.

Cause was in the result-frame attribution, not in the correction ledger. A frame
delivering the result of the call it names as parent is the CALLER consuming its
own output — but the code read "the caller" as "the session's own agent", which
is only true when the caller is the root. A call a child made is owned by that
child. Collapsing it to root both misattributed the row and made the result's
write resolve through a different reference than the call's, so the correction
owed to that row was left holding the body it had BEFORE the result landed, and
re-attribution then reverted the row.

The caller is now resolved through the registry that already records which agent
journaled a tool call, so both writes to one row resolve through the same
reference and the newest body wins.

A settled write also supersedes any correction owed to its row. One `itemId` is
legitimately written under two references — `claudeToolIdentity` is keyed on the
tool id alone — and a settled write already carries a final verdict, so an
outstanding correction could only restamp it from a reference that write did not
use. Dropped rather than re-bodied for that reason.

Adds the ledger's first unit tests, including the invariant this defect broke: a
correction changes a row's attribution and never its content.

* fix(claude): keep a correction owed when the sink refuses it

A correction went out through the plain append, which discards the queue's
admission. Under backpressure the write was refused and `retry` had already
dropped the entry, so the obligation died with nothing re-deriving it — the
failure class this work exists to refuse. It is self-feeding too: a correction
costs a commit on the same serialized writer that carries live rows, so the
burst that generates many corrections is what builds the backlog that drops
them.

It now uses the admission-returning path the sink already exposes, keeps the
entry outstanding on a refusal, and lets `abandon` try once more. A refusal
there ends it: the row keeps the spawn call's own id, which is usable, and an
obligation with no exit is worse than one that settles for less. `settle` also
reports what actually happened instead of always claiming it wrote, so publish
no longer fires for a write nobody accepted.

Also records why the live-turn scans may read the turn record before checking
the producer. A turn is the session's unit of work and no producer of a
turn-bearing body stamps linkage: Claude's turn rows carry none, Codex has no
linkage concept, the compact row passes only a fence, and the stale-turn sweep
goes through the lifecycle-batch path, which cannot carry linkage by type. The
ordering is safe by construction rather than by accident, and the comment says
so, so a future producer knows what it would break.

* fix(claude): never read a row naming a parent as the session's own

A non-null `parent_tool_use_id` names a child, always. The resolver still had
one branch that read such rows as the session's own agent's — a release that
had announced no task, where the comment claimed "there is no handle to stamp".
There is one: the reference itself. The branch was buying a false attribution to
avoid an id nothing joins on, which is the trade already reversed once for
forwarded spawn calls, and every reader of this field is a presence test.

So the branch goes, and with it the `root` arm of the verdict and the resolver's
whole dependency on whether the release announces tasks. Two states remain:
linked now, or linked now and owed a correction. The type deleted a stale test
double on sight, which is the argument for removing the arm rather than the
branch alone.

This also closes the severe half of the tool-origin eviction exposure. A spawn
id evicted from the bounded top-level set used to flip the release check on and
stamp a child's rows as the parent's; with nothing returning root that cannot
happen. What remains is a missed correction, which splits one child across two
ids — the same end state as passing the correction bound, benign in kind and
disclosed.

`isForwardedParentTool` stays where it gates PENDINGNESS. It now decides only
whether a correction is owed, never whether a row is a child's, so a stale
answer costs precision rather than correctness.
2026-09-22 13:57:46 -07:00
Brennan Benson 12040f04d0 refactor(ai-vault): read Codex's stated subagent parentage instead of a boolean (#22298)
* feat(ai-vault): read Codex's stated subagent parentage

Codex states a spawned thread's parentage in `session_meta`: the parent
thread id, the spawn depth, and the agent's nickname, role and naming
path. Add a reader that keeps all five as a typed record.

Releases disagree about where they state it. Newer ones nest the full
record under `source.subagent.thread_spawn` and copy the parent,
nickname and path onto the payload's own keys; 0.144-0.147 name only the
agent's role there and leave those copies as the sole statement of the
parent. Every field is read independently, so a release that states
three of them is not discarded for omitting the other two, and a
malformed field costs only itself.

* refactor(ai-vault): reject Codex worker transcripts on the parentage record

The scanner collapsed Codex's whole spawn record to a yes/no to decide
whether a rollout belonged in Agent Session History. The parse state now
holds the record itself and derives that decision from its presence, so
the parent thread id, depth and agent name survive the scan instead of
being thrown away at the point they are read.

Two behaviour notes. A release that states only `source: { subagent:
'review' }` is now recognised as a spawned thread; the previous check
required that key to be an object, so such a transcript would have shown
up in the user's own history on a release that states no `thread_source`
alongside it. And a transcript that states `thread_source: 'user'` is
still treated as the user's own even if a subagent source sits beside
it, unchanged from before.

* fix(ai-vault): an unreadable subagent source is not a spawn statement

Detection with no stated thread_source accepted any value under
`source.subagent` that was not undefined/null, so `subagent: false` (or 0,
or "") would have read as a spawn and hidden the user's own thread from
Agent Session History. Every release spells a subagent source as either the
spawn record or the agent's role, so readability is the gate: a value that
is neither states nothing. Letting a worker transcript through is visible
and recoverable; dropping a user's session is neither.

* refactor(ai-vault): classify a Codex thread by its source tag, not its role

`source.subagent` is an externally tagged union naming the sort of non-user
thread: a spawn record, but equally a review pass, a compaction, a memory
consolidation, or a labelled `other`. Those are siblings that exist today,
not an older spelling of the spawn record, so reading them is not a legacy
fallback and the tag is not the spawned agent's role — `agent_role` is a
field that exists only inside a spawn record.

Why this is not a tag rename. Why a thread is not the user's own and who
spawned it are two facts, and folding the first into the second made a
compaction read as an agent whose role is "compact". `kind` now carries the
union tag (with `kindLabel` for the free text `other` states) and parentage
stays the join key, so the two can disagree without either being lost. A
transcript is rejected on the classification, never on parentage — which is
also why a forked thread's lineage can never be mistaken for a spawn.

Also read the union faithfully: a tag is a bare string or a single-key
object, and a value that is neither states no tag at all. That keeps a
user's own thread visible on an unreadable value, where the previous
presence test would have hidden it. Adds the documented `agent_type` alias
of `agent_role`, and the payload-level copy of the role that the other three
spawn fields already fell back to.

* fix(ai-vault): hide the machinery Codex runs for itself, not only spawned agents

Codex's `source` is a nested union, and two of its outer tags are not the
user's thread: `subagent` (an agent it spawned, or a review or compaction it
ran) and `internal` (guardian and memory-consolidation machinery). Only the
first was ever read, so an `internal` rollout landed in Agent Session History
as if the user had started it whenever the release omitted `thread_source` —
and that field is optional, absent on 1,315 of 13,137 local rollouts.

Reading the outer tag rather than one hardcoded key also fixes the direction
of the readability rule. The outer tag is the discriminant: nothing but a
non-user source serializes under those keys, so it classifies the thread on
its own, and a kind beneath it that a later release respells no longer leaks
every worker transcript into the user's history. An unreadable value in
`source` itself still states nothing and leaves the thread visible.

Renamed to match what it decides — whether a thread is the user's own, which
was never only about subagents. Every other tag (cli, vscode, exec, mcp,
custom, unknown) is a thread the user started and is now pinned as such.

* refactor(ai-vault): read only the source tag Codex actually writes

Backs out the reader for Codex's other non-user `source` tag. It has zero
records across 13,137 local rollouts, and the threads it would name state a
`thread_source` unconditionally on the path that creates them, so they are
already classified. Reading a shape that has never been observed, for a case
already covered, is speculation — unlike the bare-string subagent tag, which
has 16 real records behind it.

The nested-union reading stays: the outer tag is still what classifies the
thread, so a kind beneath it that a later release respells cannot leak a
worker transcript into the user's history, and the tags the user's own
threads carry stay pinned as visible.

* Revert "refactor(ai-vault): read only the source tag Codex actually writes"

This reverts commit 616a2b7c7d.

Backing the `internal` tag out was right against a bolt-on reading a second
hardcoded key, but not against a reader that decodes the `source` union
generically: there, excluding it means special-casing a documented producer
variant back out of a general reader, and the transcripts it names reach the
user's history on any payload that omits `thread_source` — 1,315 of 13,137
local rollouts. Hiding it costs one entry in the tag set.
2026-09-22 13:56:20 -07:00
Brennan Benson 4c696a1e2a fix(agent-status): a structured session with live child work reads as working (#22295)
* fix(agent-status): a structured session with live child work reads as working

An idle native-chat session whose subagent was still running showed a green
check in the sidebar, the collapsed worktree pill, and worktree ps, while a
terminal Claude session in the same situation showed working. The two lanes
folded child work into the parent's status with different code: the hook
listener did, the structured lane did not.

Both lanes now share one child-work liveness vocabulary and one lead-status
fold. Live agent work makes a settled lead working; shells and monitors alone
make it monitoring. The structured lane derives liveness from the background
task list already on the wire, in both its readers, so the sidebar, the CLI,
the dashboard and mobile agree. The Claude task-kind table is one shared file
covering both the hook inventory and SDK stream names, and the renderer bridge
reuses the shared child-work projection instead of carrying its own copy.

* fix(agent-status): a blocked or out-of-contact subagent still holds its session working

Child-work liveness retired an agent-kind child on any state but working/monitoring,
while the shell beside it stayed live on everything except done/idle. A subagent
waiting on a permission prompt, or one whose host lost contact, therefore counted
for less than a backgrounded sleep and let the session read done. Both kinds now
share the settlement rule `resolveAgentChildWorkFreshness` already reads rows by:
only an explicit done/idle retires child work.

Also keep empty task labels out of the shared background-task projection candidate,
so a host that publishes `name: ''` cannot beat the child-row fallbacks.

* test(agent-status): pin the widened hook-inventory agent names, and correct two stale claims

The hook inventory now classifies through the shared kind table, which also maps the
SDK stream's `local_agent` / `local_subagent`. Nothing pinned that widening, so add
cases for all four agent names — including `teammate`, whose pane state stays `done`
under the #8825 idle-squat rule.

Two comments the fold made false:
- the teardown marker rule's comment claimed it could not disagree with what the UI
  calls working; it is deliberately lead-only, so now it says that and why;
- the agent-status store reference still described the structured row's `state` as the
  deleted `structuredAgentSessionStatusState`, and omitted the `workingMode` the ingest
  now writes.

* fix(agent-status): the state clock restarts when monitoring becomes a real turn

`stateStartedAt` carried forward whenever the prior `state` matched, which was sound
while `state` meant "a turn is running". Now that it folds in child work, an idle lead
watching a `sleep 3600` publishes `working`/`monitoring`; the user's prompt 45 minutes
later keeps `state: 'working'`, so the row inherited the watch loop's clock and read
"Working for 45m" the instant the turn began. Monitoring is its own displayed label
(`worktree-card-compact-agent-row.tsx:40`), so the continuity key is now the whole
published work identity — state AND workingMode — in both writers.

Also record two facts the code stated wrongly: the structured lane's `interrupted: false`
is inert (a projected session status has no interrupted member) rather than a decision,
and the child-work liveness rule's escape hatch is the roster's session lifetime, not a
settled state.

* fix(agent-status): a workflow is watch work, and child work dates itself

Two defects the fold introduced.

`isAgentChildWorkKind` counted `workflow` as agent work, so a structured session
whose only live task was a backgrounded `local_workflow` published a full working
spinner while the children projection — which admits `kind === 'agent'` only —
rendered nothing to expand, and the same workflow in a terminal pane showed the
monitoring badge instead. The repo already decides this: `isClaudeSubagentTask`
excludes workflows by name, and MATERIALIZED_TASK_KINDS leaves "the backgrounded
shell command and the workflow" to the non-agent owner. The predicate is now
`kind === 'agent'`, and the three sites that restated the same test route through
it, so a new kind is decided in one place instead of three that merely agree.

`evidenceObservedAt` dated every row by `summary.updatedAt`, the journal's last
activity. The journal cannot date child work: its clock stopped when the lead's
turn did, so a genuinely live roster aged past the 30-minute staleness window and
mobile's dot decayed a running session to idle. The fold now reports whether child
work alone holds the row open, and only then does the host's observation clock
stand in — keeping "a restart's republish is not new evidence" for lead turns.

* fix(agent-status): the sidebar dates child work the same way the host does

`fromChildWork` reached the host ingest but not the renderer bridge, so after ~30
minutes of live child work with no journal activity the sidebar's row aged into
staleness while `worktree ps` and mobile stayed fresh — two writers for one session
answering differently, which is the defect this PR exists to remove. For a remote
host the client's own receipt time is also the more honest clock, since the journal
stamp is the host's and is never comparable against this machine's now.
2026-09-22 13:48:07 -07:00
Brennan Benson 18696bd875 fix(native-chat): hide delete for structured history (#22106)
* fix(native-chat): hide delete for structured history

* test(native-chat): fix duplicate session action suite
2026-09-22 13:30:56 -07:00
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

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

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

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

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

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

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

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

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

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

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00
Jinwoo Hong 564f135248 fix(mobile): an accessory Enter ends the field's editing session, and the page's Enter survives a composition (#22300)
* test(config): the page's live input never submits under an open composition

The emulator's page-only defect, in a browser: an Android soft keyboard keeps a
composition open over the word being typed, so the Enter keydown carries
`isComposing: true`, which is the condition react-native-web reads to skip
`onSubmitEditing` entirely. Nothing reaches the terminal and the field keeps the
text. The probe route now mounts `useTerminalLiveInputCommit` and the command
dock's own field props, so keys enter through the browser rather than through a
handle that calls the hook directly.

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

* style(config): format the live-input render check

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

* fix(mobile): the page's live input submits under an open composition

react-native-web's keydown handler withholds `onSubmitEditing` whenever the
Enter keydown reports a composition — `nativeEvent.isComposing`, or the Android
`keyCode` 229 that stands for it — which is a soft keyboard's normal state
mid-word. Nothing reached the terminal and the field kept the text. Native
Android's editor action has no such suppression, which is why only the page
showed it.

The field now also claims `beforeinput`/`insertLineBreak`, the browser's own
end-of-line signal. react-native-web cancels every keydown it does submit on,
so that event exists only in the cases it dropped, never twice; an IME still
choosing a candidate reports `insertCompositionText` and is left alone.

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

* test(mobile): list the live-input submit binding, and repin the session closure

The `.web.ts` sibling needed its row in `web-overrides.json`, whose check lists
exactly the overrides on disk. The session route's page closure moves with it:
the callback ref and the binding it resolves to are both local, and the native
sibling stays out, which is what the pair is for.

  modules        4207 -> 4209   (+2)
  local modules  1021 -> 1023   (+2)

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

* test(mobile): the key bar's Enter chip leaves the sent text in the live field

The device trace's variant (a), which is what shots/23 was: the chip emits no
DOM key event, so react-native-web's submit handling never runs and the
accessory hook is the only thing that could end the field's editing session.
With nothing held it takes the send-now branch, which flushes nothing and
writes neither the capture state nor the field, so the text the PTY already
echoed stays put and the next keystrokes append to it.

Not a page defect: the held-text fallback only holds a trailing non-ASCII run,
so ASCII leaves nothing held on native either. The unit case is on the shared
hook for that reason.

The probe route now models what the send actions do with 'allow-raw', so the
check can see a control sent twice or not at all.

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

* test(config): the buffered command field has the live field's Enter gate too

Three cases, one red. A plain Enter sends the draft and empties the field, which
is `beginBufferedTerminalDraftSend`'s doing and stays a guard. The key bar's
Enter chip in buffered mode is a plain terminal key: the accessory hook declines
at its live-handle guard, one carriage return goes out and the draft is
untouched, also a guard.

The red one is Enter under an open composition. This field reaches its send
through `onSubmitEditing` alone, so react-native-web's keydown gate swallows it
exactly as it did for the live field, and the draft neither goes out nor leaves
the field.

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

* fix(mobile): an accessory Enter ends the field's editing session, on both fields

Two defects, one rule: after a control that ends the line, the terminal owns
the echoed text and the field's editing session is over.

The key bar's Enter chip (device trace, variant (a)) emits no DOM key event, so
only the accessory hook could end that session. Its held-text branch does,
through the flush; with nothing held it took `send-now`, which flushed nothing
and wrote neither the capture state nor the field. Not page-only: the held-text
fallback holds a trailing non-ASCII run, so ASCII leaves nothing held on native
either. `send-now` now takes the same flush when the bytes end the line, and
still defers the send to its caller so exactly one return goes out.

The buffered command field had the live field's composition gate, because it
also reaches its send through `onSubmitEditing` alone. It binds the page's
line-break signal now too, which is why the seam is named for a terminal text
field rather than for the live input.

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

* test(mobile): the submit binding holds the handler its first render was given

Found by pullfrog on #22300. The binding refreshed its handler ref only when
the callback identity changed, so a caller memoizing on an empty dependency
list was bound once and never again. The buffered command field does exactly
that: its submit closes over handleSend, a per-render function whose guard
reads client and activeHandle, both null until effects supply them, so the
page's line-break submit could never pass that guard.

The probe route now carries the same two paths the dock has — a fresh
per-render function on the field's onSubmitEditing prop, and the memoized
closure on the binding — because the working prop path is what hid this.

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

* fix(mobile): the buffered field's page submit reaches a live handleSend

Found by pullfrog on #22300. `submitBufferedDraft` was memoized on an empty
dependency list, which froze the per-render `handleSend` it calls. That guard
reads `client`, `activeHandle` and `canSend`, none of which the first render
has, so the page's line-break submit could never pass it. The field's own
`onSubmitEditing` prop kept working, which is what hid it.

The handler is per-render now, and the binding refreshes its handler ref on
every commit rather than when the callback identity changes — the ref exists so
the listener always reaches the newest handler, and it should not rest on a
caller's memoization. That second half fixes nothing on its own: a `useCallback`
with `[]` returns one function object for the life of the component, so no ref
can find a newer closure behind it. The source census is what catches that, and
it is red against the frozen handler.

The probe route is back on the dock's shape, per-render on both submit paths,
with a note saying why a route that writes its own submit cannot catch this.

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

* test(mobile): the page's buffered submit reaches a handleSend that can send

The census beside this reads one spelling of a frozen handler. This reads the
behaviour: the send-actions hook is rendered first as a session is before its
effects land, with no client and no handle, then again as it is after, and the
listener the page's binding attached has to reach the second one. Asserted on
the params that reach the client, not on a call count.

Red with the `useCallback` restored, and red with a `useMemo` in its place,
which is the point of testing the behaviour rather than the spelling.

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

* test(mobile): declare the two mock arrays instead of asserting them

`[] as Array<T>` inside the hoisted factory was a type assertion with nothing to
explain: the arrays are built here, so a checked declaration says the same thing
and the quality gate has nothing to flag.

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

* refactor(mobile): the submit binding stops claiming a cure it does not have

The handler ref refreshed on every commit rather than on the callback identity,
and the docblock called that the reason the seam exists. It is not: a caller
that freezes its closure hands this hook one function object for the life of the
component, so no ref finds a newer one, and a caller that does not freeze it
changes identity every render and refreshes the dependency anyway. Measured both
ways. The dependency is back, and the prose says only what the code does.

The rule that does hold — a bound handler must not be frozen on an empty
dependency list — is stated where it is enforced, in the wiring census, with the
behavioural check named beside it.

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

* refactor(mobile): the submit binding's latest-ref drops its dependency list

React Doctor flags the dependency twice, once per bound field: both of the
dock's field submits are rebuilt every render, because the handleSend they read
is, so `[onSubmit]` is a new value every time and there is nothing to compare.
The tree's other latest-refs are written without a list for the same reason —
use-mobile-web-shell-bridge.ts:148 is the one this follows.

The comment says what the ref does, which is mirror the newest closure after
each commit so callers may pass per-render handlers. It claims nothing about a
caller that freezes one; that rule is still the wiring census's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 15:55:36 -04:00
Jinwoo Hong b013590363 fix(push): bound the delivery claim, delete finished batches, and keep a connection for requests (#22307)
* fix(push): bound the delivery claim and stop keeping finished batches

* fix(push): bound claim scans to the notification TTL and document the queue

* test(push): boot waits out a table writer for the queue indexes; queue stays correct without them

* fix(push): share the claim lock so a previous-revision claim cannot re-lease a delivery

During a deploy overlap the previous revision's claim scans under an exclusive
push-worker-claim lock and re-reads the row without checking lease_until, so it
could overwrite a lease this revision had just committed and send twice. The
new claim now takes the same key shared: new claimers never block each other,
and the previous claim waits until their leases commit before it scans.
Droppable one release after every worker runs this revision.

* fix(push): install one queue index at boot, not three

push_batches_leased_device indexed lease_until, so every lease, renew and finish
UPDATE lost heap-only eligibility and rewrote every index. push_batches_pending_due
was unused: on a synthetic 902k-row table the candidate scan plans onto the
existing (state, due_at) and expiry indexes with or without it. The per-device
pending index stays; the head check and the busy anti-join use it. Fewer
boot-time builds also shorten the SHARE lock the first boot takes on the table.

* test(push): pin the claim's TTL scan bound and the server's worker connection cap

Removing either guard left the suite green. The claim test captures every row
the candidate scan returns and plants one row that only the TTL term excludes;
the server test drives the real worker through createPushServer and fails when
the request-connection reservation is unwired (peak 4 instead of 2).

* fix(push): renew delivery leases outside the background connection cap

Renew shared the single background slot with claim retries and prune batches,
so a heartbeat could wait long enough for a lease to lapse and the delivery to
be re-leased mid-send. It is a keyed one-row UPDATE, so request traffic cannot
starve it on the ungated pool.

* docs(push): describe the shared claim lock for mixed-revision deploys
2026-09-22 15:31:51 -04:00
Jinwoo Hong a8786c040d fix(mobile): the page never removes a host, and stops bundling push (#22283)
* fix(mobile): the page never removes a host

The page holds one host profile from `init.host` and no credential, so
`removeHost` on web resolved without doing anything and the screen reported
success for a host that was still paired. Its `.web` sibling refuses with a
typed error instead, and the auth-failed banner's Remove — the one surface
that opens the confirm — is absent on the page, because a control that can
only refuse should not be there.

Refusing is also the fence that keeps `push-registration.ts` out of the page
bundle: the native lifecycle file's import was that subsystem's only path
into a page route.

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

* test(mobile): drop the push families the page closure no longer reaches

`host-removal-lifecycle.web.ts` was `src/notifications`'s only path into a
page route, so the whole directory left the page bundle and the capability
probe left the C1 layout closure with it. The derived family set shrank by
two; the pin tables and their counts now match what the closure reaches.

The expo-notifications fence grows a second claim and loses a precondition
that had become false: `push-token.web.ts` and
`desktop-notification-channel.web.ts` are no longer in the bundle either, so
the fence is stated as the absence of the directory.

C1 20 families / 94 goldens, C2 68 / 257, C3 26 / 116, C5 25 / 125.
Session route closure 4211 -> 4207 modules.

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

* fix(mobile): the page's auth-failed banner offers no control it can honour

The banner is reachable on the page — the shell forwards the native client's
state verbatim (`bridge-host.ts:381`) and `auth-failed` is in the wire enum
(`bridge/bridge-envelope.ts:43`) — and the page can honour none of its three
actions. `forceReconnect` is `() => Promise.resolve()` there
(`client-context.web.tsx:55`, read through `host-client-hooks.ts:87`),
`/pair-scan` sits outside the page's route root of `app/h`
(`mobile-web-app-route-manifest.mjs:6`), and removal refuses. The previous
commit hid only Remove and claimed the other two still worked; they do not.

The whole action row moves into `AuthFailedBannerActions`, whose `.web`
sibling renders no control and one line naming the app. The sentence above it
is unchanged: re-pairing from the desktop is still what to do.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 12:49:25 -04:00
Brennan Benson 60c43695e5 feat(agent-launch): report the pane a terminal launch created (#22108)
* feat(agent-launch): report the pane a terminal launch created

A `term_*` handle is a main-side mapping the renderer cannot resolve
(terminal-handle-links.ts:309), so a client that draws its own tabs had no
way to name the tab it had just asked `agent.launch` to build. The runtime
already mints that pane, bakes it into the PTY's environment and hands it
to its own reveal; the surface factory then dropped it on the floor.

Carry it through as `paneKey` on the terminal outcome. Identity, not
placement: where the pane goes — which group, what order, whether it takes
focus — stays with whichever client is drawing, and nothing here rides the
wire for it. One field rather than a tabId/leafId pair, because the key
already holds both and two copies of one fact can disagree.

Absent when this launch minted no pane: a reused terminal was already
running, and a worktree-create startup terminal is built by the create,
which reports only a handle. Naming the wrong surface is worse than naming
none.

Optional on the wire and optional on the read side. Mobile parses the
receipt with a loose object and is deliberately mode-blind, so it ignores
the field; the persisted-row guard checks it when present and accepts a row
written before it existed, because a read rule stricter than the write side
turns one odd row into a refused replay.

* fix(agent-launch): retain startup terminal pane identity
2026-09-22 09:35:11 -07:00
Brennan Benson 9ae7a3de14 test(agent-launch): pin per-call-site behaviour of the launch funnel (#22116)
A regression net for the twelve production call sites of
launchAgentInNewTab, written before the agent.launch migration rewrites
the funnel's internals.

Each call site is characterized as a profile — the argument object it
builds — and fed through the REAL funnel, so the assertions are about
the observable contract rather than about internals the migration will
move. A census re-anchors every profile to its call site by source
markers, so a caller that changes what it passes fails loudly instead of
drifting out of coverage.

Dimensions pinned per call site: resolved surface (terminal /
structured / host-published), built command and argv, the
permission-bypass flag inside the arguments string, prompt transport
(argv / post-ready paste / environment), submit bit, delivery-promise
exposure, group placement, activation, tab-bar order, initial cwd, and
telemetry launch source.
2026-09-22 09:33:10 -07:00
Jinwoo Hong 3bb9a4e261 fix(mobile): keep a painted frame under the page until its first paint (#22264)
* fix(mobile): keep a painted frame under the page until its first paint

The shell tore its own frame down the moment a generation was on screen
(`MobileWebShellScreen.tsx`, the `ready` branch), and a mounted WebView draws
nothing until its document paints. What showed for the whole of the page's boot
was the surface behind it with nothing on it: 1.42 s on a cached generation,
against a one-frame budget.

The page is the only thing that knows when it has a frame, so it says so. It
declares `painted` in `ready.reports` and posts the notify after the browser has
painted its first commit; the shell holds the same neutral frame it was already
painting while it opened the generation, then fades it out. The wait is bounded
by the declaration and never by a timer: a generation served by an older desktop
declares nothing and is uncovered on `ready`, which is what every shell did
before this.

iOS painted white rather than nothing: a WKWebView is opaque by default, so the
shell's own surface never showed through. It is now transparent, as the Android
view already was.

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

* fix(mobile): hold the cover through the compositor handover

The page reports the paint its own renderer made; putting that on the app's
surface costs another frame or two. A linear fade from the report left two
frames of bare surface between the two on an emulator, which is the hole the
cover exists to close. Eased in over 220 ms, the cover keeps most of its opacity
across that handover: five reopens now show 0-21 ms of bare surface against
102-2043 ms on the build without it.

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

* fix(mobile): negotiate the paint report in both directions

The page posted `painted` whatever shell it met, and `notify` is a closed union:
every shell installed before this answered it with an error frame, once per
mount. The shell now advertises the name in `init.accepts` beside the param
clear and the client identity, and the page posts only when it was advertised.
The declaration in `ready.reports` stays unconditional, because it is an
optional field an older reader strips rather than a new opcode, and because the
first `ready` — the only one that matters for the first paint — is sent before
any `init` has arrived.

The accepts list moves into `bridge-init-frame.ts` beside the grants, which is
the module that builds the frame carrying it.

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

* test(mobile): read the cover's colour instead of asserting its shape

Two gate findings on the round-two head. The cover test reached the background
through a cast of the style prop; it now reads it through a checked narrowing,
so the test proves the shape it depends on rather than declaring it.

`use-mobile-web-shell-bridge.test.ts` stopped typechecking when the bridge args
gained `onPagePainted`: its harness is a literal, so a new required handler is a
missing property. The probe now counts paints and one case spends the counter,
which is what a handler wired only to satisfy a type would not do.

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

* refactor(mobile): move the cached-generation opening out of the reducer

`mobile-web-shell-session.ts` crossed `max-lines` after the merge: the refused-
update work and the paint handling both grew it. What comes out is one thing —
putting a generation already on disk on screen, and deciding whether this route
is one that bundle carries. It is the reducer's cache path and its refused-
update path both, and it was already three functions sitting together.

`step` goes into a module of its own because the two now share it; a copy in
each would be two spellings of one transition, and exporting it from either
would point the dependency the wrong way.

No behaviour moves: the reducer's table tests are unchanged and the page closure
is unchanged at 4,211, since neither new module is reachable from a page route.

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

* fix(mobile): drop the previous document's paint when a new one starts

A document that replaced a painted one inside the same mount inherited its
`pagePainted`, so the cover lifted before the replacement had drawn anything.
The native view already reports `loading`; the screen dropped it. It now
reaches the reducer as `document-started` and clears the page document state.

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

* test(mobile): make the declaration case call the frame policy

The case compared the name to itself and never called `shellPageFrame`, so it
passed for a policy that ignored the declaration entirely.

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

* fix(mobile): report the page's frame from the route screen, not the router

Every route screen is behind `import()`, so the wrapper above expo-router
commits with a suspense fallback while the chunk is still arriving. The paint
report hung there, which uncovered the shell's view over an empty body on a
cold chunk. It now hangs on the screen the manifest resolves, layouts excluded.

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

* fix(mobile): retire the readiness wait a replaced document armed

`document-started` cleared the page document state and left the flow alone, so
the previous document's readiness deadline passed the flow check, read
`pageReady` as false and failed a session whose replacement was still loading.
The flow moves with the document, for the reason `remounted` already moves it.

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

* fix(mobile): let a departing route screen take its paint report back

The report waits two frames, and nothing cancelled the second one, so a screen
unmounted in between still told the shell to uncover. The reporter now answers
with a take-back the wrapper returns as its cleanup, and the once-per-document
latch frees only when a report was cancelled before it landed.

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

* fix(mobile): let the screen that arrived take over a frame still owed

A screen committing inside the two frames an earlier one was owed found the
latch taken and reported nothing; the earlier screen then freed that latch on
its way out and nobody was left to lift the cover. The newest commit now
supersedes the pending report, and only a posted one spends the latch.

Covers the redirect window with a render check against the pr route, whose
target chunk is held open while the document sits on the hub's fallback.

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

* test(mobile): make the take-over case turn on the take-over

The case cancelled the first screen's frame through the cleanup path, so it
passed with the take-over deleted. It now leaves that screen mounted and reads
the clock: the frame after the replacement commits is the replacement's first,
not the one the screen behind it was still owed.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 11:43:26 -04:00
Jinwoo Hong 9da7f7edfc test(config): the session render rig waits for the reads it asserts (#22269)
The session route issues worktree.show, session.tabs.list and terminal.list from
effects that run after the commit painting 'Terminal', so reading
__orcaRenderCheckRequests where openRoute resolves is a snapshot taken before the
burst. Under a 20x CPU throttle none of the three is in it, which is the shape
the loaded CI job hit twice. waitForRecordedRequests polls the double's own log
under a 30s bound and names what never arrived.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 09:49:28 -04:00
Jinwoo Hong 66ade30576 fix(mobile): the page's live input stays above the soft keyboard (#22252)
* fix(mobile): the page's live input stays above the soft keyboard

Edge-to-edge makes the manifest's `adjustResize` inert, so the window never
shrinks for the IME and the page's `visualViewport` reads full height with the
keyboard up: the session route laid its live input row out under the keys. The
shell owns the window, so it shortens the WebView by the keyboard instead.

The session screen's own `Keyboard.addListener` pair never fired on
react-native-web, so the page also never held off the terminal refit. Both facts
now come from the platform seam, which answers them separately on the page: the
keyboard is open, and it covers nothing.

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

* test(mobile): type the session keyboard harness without a cast

The hoisted keyboard now carries the seam's own `SoftKeyboardState`, and the
mocks the screen is handed carry the types it calls them with, so nothing is
asserted into shape.

The scope the harness builds could not be spelled at all before: the hook took
the whole lifecycle model to read 28 of its fields. It now names those fields,
which the model still satisfies, and the test builds one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 09:06:45 -04:00
Jinwoo Hong 3d76c22b57 fix(mobile): a refused update serves the cached generation while the host is up (#22237)
* fix(mobile): a refused update serves the cached generation while the host is up

A newer generation that fails to fetch or stage was refused with a named
reason and then painted a wall with "Try again" over an intact generation
already on disk — the same one the offline branch opens without being asked
the moment the host goes away.

`onDownloadFailed` now branches on what is cached rather than on which side
refused: with nothing on disk the refusal is still the screen, and with a
generation on disk it is opened through the offline branch, judged by its own
route list. The refused generation is never staged, committed or persisted, and
nothing about the refusal is written, so the next launch asks the host again.

The bundle-side refusal is named as a dismissible notice above the page, on the
existing host-route banner. It says what happened and promises no retry,
because the shell schedules none.

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

* fix(mobile): judge the cached generation against the host it can reach before serving it

The fallback served a cached generation on the strength of the offline rule,
which skips the compat check because a host nobody can reach cannot have
changed. On this path the host has just answered, and an update usually exists
precisely because it moved — so bytes that were inside the protocol window when
they were written may be outside it now.

`CachedGeneration` now carries the three fields the compat verdict reads,
projected in `openCache` off the manifest stored beside the assets. That
manifest is never absent: `readActiveGeneration` answers null for a generation
whose manifest did not parse, and the read schema requires all three.

`cachedGenerationWall` lives beside `gateVerdict`, because only an `open` gate
is judged further. The other verdicts already have answers there: an absent
capability is the native-route rule, and an unreadable status leaves the same
empty list, so walling on either would be the `bundle-unavailable` wall that
file exists to keep off a host that simply did not reply.

A generation outside the window now earns the wall with its verdict, not the
download-failed screen, and nothing is deleted: the bytes are intact and a
newer host is not what makes them wrong.

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

* fix(mobile): ask the cached generation's own routes before walling it

The compat wall ran before the route question, so a cached generation that does
not carry this route — or predates route listing entirely, `routes: undefined` —
earned a terminal `bundle-incompatible` wall where the answer is `native-route`.
`onManifestRead` has always taken the other order: a route that stays native has
nothing to wall about. `openByOwnRoutes` now asks the route first and applies
the wall only on the served branch, and the update notice moved to `served`, so
a native answer carries no notice about a screen it is not showing.

The other half is the verdict the gates hold at the moment of the refusal. A
`fetching` session does not await the gates, so a refusal can land under a
verdict the flow never started on. `gateState` is now the one mapping from a
gate verdict to a screen, shared by the entry into the flow and by the fallback,
so the two cannot answer the same verdict differently: a host that stopped
serving a bundle is `native-route`, a status that went unreadable says so and
re-arms, a dial in progress or a pending status waits in `checking`, an
unreachable host keeps the offline rule and serves the cache unjudged, and
`open` is the only answer that leaves a host to judge the generation against.

That inverts two round-2 assertions that expected the cached page to be served
when the capability list had gone empty. Both were wrong for the same reason:
an empty list is the gate's question, not a verdict about a bundle.

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

* fix(mobile): announce the host route notice banner, as loudly as its tone

The banner is inserted into a screen that is already on screen, so a reader who
has moved past the top of the list never arrives at it. It carried no live
region and no role, so nothing carried it to them.

The urgency follows `tone` rather than being assertive for everything. The
failure tone is an action that did not happen — a refused worktree action, or
the shell's refused update — and interrupts with `alert` and an assertive
region. The notice tone is a bounced route, context for a list already being
read, and waits its turn politely; interrupting for that would train people to
ignore the first. No role on that arm: React Native has no `status` role, so the
polite region is the whole of the answer.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:39:21 -04:00
Jinwoo Hong 0748915ce2 fix(mobile): a refused dictation start says why (#22256)
The composer had two dictation failure handlers, one policy written twice.
`onError` routed a setup-required refusal to the dictation setup sheet;
`startDictation`'s own catch — the only path a refused `speech.dictation.start`
takes — did not, so a desktop whose voice settings are off or whose model dir is
empty answered the tap with its internal code as a toast.

Both entry points now call one `reportDictationFailure`, so the sheet opens for
`voice_dictation_disabled`, `voice_model_not_selected` and `voice_model_not_ready:*`
whichever path saw them, and every other refusal keeps the toast and haptic it had.

The desktop's error already carries its reason in the message, so no wire change.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:38:09 -04:00
github-actions[bot] eaf71ce99c Update README downloads badge 2026-09-22 12:36:58 +00:00
OrcaWinandm4air ba742a86bb fix(linux): release orphaned processes when their owner exits (#22247)
* fix(linux): release orphaned processes when their owner exits

* fix(linux): handle inhibitor errors until streams close

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-22 05:10:03 -07:00
Jinwoo Hong 0ab2ba3480 fix(mobile): the page stops writing, importing and requesting what it cannot use (#22241)
* fix(mobile): the page keeps no host app-version record

`host-status-gates.ts` runs above every page route, and a readable
`status.get` had it write `orca:host-app-version:v1:<hostId>` through
`host-app-version-store.ts`. Inside the page AsyncStorage is the bridge's
adapter and that key is not one `page-storage-keys.ts` hands a route, so
every mount posted a write the shell refused and logged as
`storage-write-dropped`.

Not admitted through the storage seam, because the page never reads it
back: the record's only reader is the native troubleshoot screen's
`native-diagnostics-operations.ts`, which is not in the page's bundle. A
`.web` sibling keeps no record instead. The bounds check moves to
`host-app-version.ts` so both hosts read a reported version the same way.

The session render check now collects warnings as well as errors and
answers `status.get`, which is what arms the write: the other cases'
double answers no RPC, so the drop needed a reply rather than a control.

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

* fix(mobile): the page does not import expo-notifications

`DevicePushTokenAutoRegistration.fx` runs at import: it adds a push-token
listener React Native Web answers with a warning, and it reads the
persisted server registration out of `window.localStorage` behind a
`typeof localStorage === 'undefined'` guard. The Android shell's WebView
has DOM storage off, where `window.localStorage` is `null` rather than
undefined, so the guard passed and the read raised "Cannot read
properties of null (reading 'getItem')" at error level on every page
load.

Two modules imported the package — `push-token.ts` and
`desktop-notification-channel.ts`, both reached through
`push-registration.ts`, which the host layout pulls in via the host
screen's remove action. Both get a `.web` sibling. The page holds no
device push token and creates no Android channel; push registration
needs a token the shell owns and a gateway the page has no client for.

Every call in those two files was already inert on web, so a page that
imports one behaves correctly and still loads the package: the closure
check beside them is what keeps a third importer out. The session render
check adds the device's own shape — `localStorage` reading `null` — and
reds on the error the emulator saw.

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

* fix(mobile): the page declares an icon, so no browser asks for one

With none declared a browser asks the origin for /favicon.ico on its
own, and the shell's asset server answers 403 because the path is in no
manifest — which the emulator run saw repeatedly. The document now
carries `<link rel="icon" href="data:," />`, a browser's own way of
being told there is no icon. An empty data URI rather than an asset: the
page is a WebView document with no tab to put an icon in, and the
bundle's images are content-hashed route assets whose names change with
their bytes. `img-src 'self' data: https:` already admits the scheme.

Two assertions, because each is blind where the other sees. The build
check reads the document and runs everywhere. The session render check
reads the request, which only a full Chrome makes —
`ORCA_MOBILE_WEB_RENDER_BROWSER`, what CI resolves — and reads it off the
server's own log: a favicon fetch comes from the browser process rather
than the page, so Playwright's request events never report one. It also
settles on network idle first, because the fetch comes after the text the
route waited on.

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

* docs(mobile): trim the page-noise comments to the bar

Comment-only. The three `.web` siblings, the document's icon line and
the three override reasons each said their cause once and then said it
again; each now states what the page keeps and why, once.

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

* test(mobile): re-pin the session closure after expo-notifications left

Measured on this head with all five generators run first, against a
scratch worktree detached at the base, which reads the committed pin
exactly: 4271 modules and 1023 local.

    modules        4271 -> 4210   (-61)
    local modules  1023 -> 1024   (+1)

65 modules leave and 4 join. 62 of the 65 are vendored: expo-notifications'
own 55, and expo-application, badgin, abort-controller and event-target-shim
behind them. The other three are the native files the `.web` siblings
replace, so the siblings cost the local count nothing and its +1 is
`host-app-version.ts`, the one new module.

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

* docs(mobile): count the packages the closure note names

The note said 62 vendored modules left and then named five packages
without counts, so the names read as the whole of the 62 and summed to
five. Each carries its own count now: 55 + 3 + 2 + 1 + 1.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 08:07:16 -04:00
Jinwoo Hong b76bc79d73 build(mobile): key Metro's transform cache on the shell build kind (#22244)
`babel-preset-expo` inlines `EXPO_PUBLIC_MOBILE_SHELL` into
`mobileShellBuildKind` at transform time
(babel-preset-expo/build/inline-env-vars.js:51), but nothing Metro hashes
into the transform cache key carries that value: the key is
`metro/src/DeltaBundler/getTransformCacheKey.js:21`, whose inputs are the
Metro version, `cacheVersion`, the transformer path and
`@expo/metro-config/build/transform-worker/metro-transform-worker.js:600`,
none of which reads the environment. A release assembled after an
opposite-kind build reuses the warm entries and bakes the wrong shell,
and the absence of the variable's name in the bundle cannot tell the two
apart. The newest published `@expo/metro-config` (58.0.4) keys it no
differently.

Folds the kind into `cacheVersion`, by the same `=== 'ota'` rule the app
applies, keeping Metro's own version as the prefix.

Proven with four `expo export --platform android` runs against an
isolated Metro cache. Before: `ota` then `native` produced byte-identical
bundles, both `return 'ota'`. After: the second run returns `'native'`
under a different bundle hash.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 07:57:43 -04:00
OrcaWinandm4air 632ae1320b fix(daemon): reap terminal descendants during shutdown (#22232)
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
2026-09-22 04:32:26 -07:00
Jinwoo Hong 59c0d5585e fix(mobile): the shell swaps the page's client identity so its terminal reaches init (#22201)
* fix(mobile): give the page a client identity so its terminal reaches init

The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.

The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.

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

* fix(mobile): the shell swaps the page's client id for the device's

The host does not read `client.id` as an opaque key: `terminal.send` refuses a
query reply whose id is not the credential the socket authenticated with, and
`terminal-send.test.ts` pins that as a spoof. So a page-chosen id was wrong on
that path however stable it was, and the session id f29a7d1958 used was wrong
twice over — it rotates per mount, and the composer's send journal refuses a
retained operation whose caller fingerprint moved.

The page now claims one fixed placeholder and the shell, which owns the socket,
swaps this device's real identity in as the frame leaves it — in both doors to
the client, the request path and the stream start. Declared on `init.accepts`,
which is optional and additive, so no protocol version moves and a page served
by a shell that does not swap claims nothing rather than sending a placeholder
the host would refuse.

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

* fix(mobile): refuse an unresolvable page client identity at the door

Stripping `client`/`mobileClient` forwarded a request the page never made: the
host reads a missing `client` as a different caller, so a shell that could not
read its own identity would degrade silently instead of failing. Both doors now
answer the page with `bridge_client_identity_unavailable`, which reaches the
caller as an ordinary rejected RPC and reaches the desktop as nothing at all.

The substitution narrows through a checked `isRecord` guard, so the module
carries no type assertion but `as const`.

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

* test(mobile): repin the session route's page closure for the client identity

`bridge/bridge-page-client-identity.ts` declares the placeholder the page's RPC
provider claims, so `client-context.web.tsx` imports it and the module joins the
session route's closure. Measured on the merged head with all five generators
run first, not summed: modules 4269 -> 4270, local modules 1021 -> 1022.

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

* fix(config): charge a JSON stream event to the shell double's window

The JSON emitter took a slot on `unacked` without adding its bytes to
`unackedBytes`, and the `ack` arm subtracts whatever it finds there. One ack
drove the ledger negative, and the binary emitter reads that floor, so every
later window check admitted frames the real BridgeHostSubscriptions would have
refused. The docblock's "on the same ledger" now holds.

A reader for the window comes with it, because the ledger is a closure variable
and the only thing a check could see before was a posted-or-dropped verdict.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:46:12 -04:00
Jinwoo Hong da1c322b00 feat(mobile): one build-time switch picks native or OTA, default native (OTA phase E1) (#22193)
* feat(mobile): one build-time constant decides native or OTA, default native

EXPO_PUBLIC_MOBILE_SHELL is read in exactly one place, mobileShellBuildKind in
preferences.ts. Expo's babel preset inlines a literal process.env member
expression at build time, so a release bundle carries the answer as a constant
and anything but the exact string 'ota' — unset, empty, a typo — is native.
Every default build is therefore the native app, unchanged.

mobileWebShellFlagCanBeOn now answers __DEV__ or an OTA build, so the ability to
mount the page comes from the build and never from storage: a native binary
installed over an OTA one, same bundle id and same data container, still refuses
a stored 'true' without reading the key. An unset key reads on only in an OTA
build; a development build keeps its opt-in, and a stored 'false' wins
everywhere so the Troubleshoot toggle can switch an OTA build back to native.

That toggle now mounts wherever the flag can be on, which is the only way back
to the native screens in an OTA build, and its label names the build kind rather
than saying "(dev)". The bundle probe row beside it stays development-only: it
fetches.

The flag census gains two rules — one module reads the switch, in the member
form Expo inlines and not the bracket form, and one named function answers the
build kind — and the build-kind fence now lists the Troubleshoot route that asks
it. Docblocks that said a store build can never mount the shell now say it
mounts only when built for OTA.

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

* ci(mobile): one workflow input picks the shell, and no input means native

Both release workflows gain a `shell` workflow_dispatch choice, options native
and ota, default native, and hand it to the step that bundles the JavaScript as
EXPO_PUBLIC_MOBILE_SHELL. That is the Gradle assembleRelease step on Android and
the fastlane build_and_upload step on iOS; nothing else in either file sets it.

A tag push and a schedule carry no inputs at all, so `inputs.shell || 'native'`
yields native for them — the first OTA release is a dispatch with one field
changed, and every other run is the app we ship today.

Each build step prints the value it is about to build with, read back from the
same variable rather than from a second copy of the expression, so a run's log
cannot claim a shell the build did not use.

The new contract test evaluates that expression rather than matching its text:
absent, empty and 'native' all resolve to native, 'ota' to ota, and any
expression shape it cannot evaluate is a failure rather than a pass.

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

* build: the desktop packages the real page, and the placeholder is retired

build:mobile-web now runs the app builder and app verifier, and both take their
output root from MOBILE_WEB_BUNDLE_DIR in the packaging guard rather than each
carrying a constant of their own — one definition of where the bundle lives, so
a drift cannot leave electron-builder's beforePack looking at an empty directory
while the builder reports a tree it wrote elsewhere. build:mobile-web:app is
gone; it was the same two commands.

src/mobile-web/ and its two scripts go with it. What the app builder shared with
them is split into three modules named for what they hold rather than for the
bundle that used to own them: mobile-web-bundle-manifest.mjs (content types, the
canonical asset serialization, buildId, hashed assets, the protocol window and
the manifest write), script-entry-detection.mjs (isDirectInvocation, whose two
failure modes are Windows paths and symlinked entries), and
mobile-web-source-line-endings.mjs (the CRLF guard, now with a required
directory rather than a default pointing at the deleted tree).

The two suites that only needed *a* valid tree on disk — the beforePack guard
and the packaged-bundle guard — build one from mobile-web-bundle-fixture-tree
instead of bundling the whole mobile graph. It goes through the same manifest
writer the page does, so a manifest shape change still reaches them.

Also retired: the placeholder's tsconfig project and its typecheck lane, its
knip entry, its electron-builder exclusion and .gitattributes pins, and the
app-bundle test that asserted the shims stayed out of a builder that no longer
exists. pr.yml's page job builds the same bundle the package job ships.

Inert for native phones: they never fetch it.

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

* style(config): one import of node:fs/promises in the entry-detection suite

The changed-code quality gate's focused plugins read the two as a duplicate
import; the readFile line was left over from the split.

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

* docs: the comments that still describe the retired placeholder bundle

The web entry said it was built by `build:mobile-web:app` into out/mobile-web-app
and shipped by nothing. That script, that directory and that fact are all gone:
it is built by `build:mobile-web` into the packaged bundle dir, and a phone
mounts it only when the binary was built with EXPO_PUBLIC_MOBILE_SHELL=ota.

Two Windows cache keys explained themselves by naming src/mobile-web and "the
two bundle builders"; config/** now covers the builder, the verifier and the
manifest writer, and the spike's key no longer waits on a Phase C flip that has
happened. The keys themselves are unchanged.

Three scratch directories in the app-bundle suites and one in the verifier still
spelled the retired output root. Renamed to mobile-web, which is what the build
writes; they are temp subdirectory names and nothing reads them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:18:33 -04:00
Jinwoo Hong 9a25e318f6 fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour

On React Native Web a TextInput ref is the DOM node itself, so
`setNativeProps` does not exist. The two terminal live-input hooks called
it on `liveInputRef.current`, and the session route reaches one of them
from a mount effect, so the write was a TypeError that faulted the whole
page: the shell tore the view out, the terminal surface stayed 0x0, the
IME never opened and the accessory bar did nothing.

Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts`
sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That
covers the case React has no commit to make, which is where an interrupted
IME composition leaves the field.

The browser oracle is new: the session render check cannot reach this
defect, because its shell double answers no RPC, so no terminal handle
exists, `liveInputEnabled` is false, the field never mounts and the write
is skipped by its own optional chain. The new check mounts both hooks
against a real field on the page bundle instead.

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

* test(config): fence every setNativeProps write with a web sibling

Fenced by absence rather than by a list of approved callers: any mobile
source module that calls `setNativeProps` must ship a `.web.ts(x)` for the
page bundler to resolve in its place, and no web sibling may make the call
itself. Run against the two hooks as they were, it names both.

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

* docs(mobile): trim the live-input seam's comments to what is not obvious

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

* fix(mobile): move the settings scroll lock into a seam, and fence the route tree

The census scanned `mobile/src` only, so it read the terminal-settings
screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app`
too now, under a stricter rule: a route file may not make the call at all,
because expo-router registers a `.web.tsx` beside a route as a second route
rather than as a platform sibling, so a route cannot own a platform split.

The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`,
beside the styles module that already owns this screen. Measured rather than
assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no
`setNativeProps` at all, and drives the scroller's overflow from a generated
class with nothing inline, so the sibling locks with an inline `overflowY`
and unlocks by clearing it. `overflowY` rather than `touch-action`, which
stops a touch drag but leaves the wheel scrolling.

The seam does not enter the session route's closure — terminal-settings is
not under `app/h` — so the module pin is unmoved at 4270, re-measured.

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

* test(mobile): read the absent setNativeProps with `in`, not Reflect.get

`in` walks the prototype chain, so the claim that RN Web has no such method
to inherit is the same one, and the anti-slop gate has no dynamic read to
object to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:15:34 -04:00
Jinwoo Hong 6ae5ef2d00 fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up) (#22182)
* fix(mobile): the page runs one Zod (OTA phase C, C6.5 follow-up)

`nodePaths` is a fallback esbuild consults only where normal resolution
fails, so it never reached the four modules under `src/shared` that the
page imports: sitting above `mobile/`, their bare `zod` resolved upward
to the root's 4.5.4 while the 58 mobile modules beside them resolved to
mobile's 4.4.3. Both shipped -- 808,470 bytes of duplicate source, and
salvage combinators built by one instance nested inside schemas built by
the other.

Mobile's copy, because the mobile app already says so: `mobile/tsconfig
.json` maps `zod` to `./node_modules/zod`, a shared module joins that
program as an imported file, and `--traceResolution` shows tsc holding
`zod-salvage.ts` to 4.4.3 today. The bundler was the only layer that
disagreed with the app's own compile-time contract.

The build drops from 67 scripts to 66 and from 8,055,568 bytes to
7,686,714, nearly all of it before the first route: the entry's static
closure falls from 1,612,253 to 1,244,312. So the C7.8 sweep is
re-measured rather than bumped, and the entry-budget note's static-import
readings are re-measured with it.

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

* test(mobile): re-measure the pins the second Zod was inside

The session route's module closure and the asset ceiling both counted
the root's copy. Both re-measured rather than adjusted to fit.

The closure falls 4363 -> 4269. The two module lists were diffed rather
than the total inferred: 94 entries gone, every one of them `zod@4.5.4`,
none added, because mobile's 79 were already in the closure, and the
`local` count holds at 1021 -- this took no source module out of the
page, only the second copy of a package.

The asset ceiling is derived from the chunk envelope, which the
re-measured sweep moved by one, so 30 routes now derive 248 assets and
31 derive 257. The crossing it exists to name is still 31.

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

* test(mobile): align three readings with the head that measures them

All three described a bundle with two Zods in it, and the third was
already stale on main.

The escape-hatch note said 5 of the 14 routes break the entry budget on
their own. Re-measured by making one route's manifest entry a static
import and reading the entry's own static closure back, it is one:
session at 3.32 MiB, with five more between 1.85 and 2.17 of the 3 MiB.
Same readings as the note in verify-mobile-web-app-bundle.mjs, which is
the thing this case asserts against.

The mermaid pair said the bundle "emits 69 scripts". It emits 66, and
this head's own fourteen-route prefix reads 64. The pair stays at 69 and
172: what the case pins is that the envelope tells the two apart, not
either build's size. Saying so in the comment, with the warning that 69
now sits just under the envelope -- a sweep that falls further fails this
on a frozen number, which is a signal to re-measure the pair rather than
to raise the ceiling.

The assets line said 215 against 112. 215 still derives from the pinned
172; 112 never matched the envelope it claimed, which allowed 114 on main
and allows 113 here.

The route count is the one number here that has to follow the tree, so it
is spelled and pinned to the sweep's own length. The static-import
readings are measurements rather than table counts and the mermaid pair
must not move at all, so neither is spelled.

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

* test(mobile): cut the mermaid and census comments to their claims

Round 2 explained the frozen pair over four paragraphs and the census
row over six lines. Both now say what they are for and stop: the pair,
why neither number moves, and the one warning that matters.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 05:23:33 -04:00
Jinwoo Hong faf7567df5 fix(mobile): the catch-all carries a deep link's query, and reads a trailing rest-segment pattern (OTA phase D, C8 carry-forwards) (#22180)
* fix(mobile): carry a deep link's query through the catch-all switch

The catch-all built its pathname from the `page` segments and dropped
everything else, so /h/x/session/y?tab=files reached the page as
/h/x/session/y. Every named switch passes its params explicitly; this one
cannot name the keys, so it takes everything useLocalSearchParams merged
in that is not one of the two segments the pathname is built from.

firstParam per key, as the named switches do, because init.route.params
is one value per name and a repeated key would cross as `a,b`. An empty
value is kept: a named switch drops its own because it knows what its
screen makes of one, and this switch knows no screen. Over the schema's
param ceiling shellScreenRoute answers null and the switch refuses, which
is the same fail-closed answer it gives a pathname the bridge refuses.

Red first: five of the seven new cases fail on the old body — the carry,
the empty value, the repeated key, the encoding round trip through
shellScreenRouteKey, and the ceiling refusal.

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

* feat(mobile): read a manifest route pattern with a trailing rest segment

matchesRoutePattern was segment-count exact, so `/h/[hostId]/[...page]`
read as an ordinary dynamic segment and matched exactly one segment.
Nothing on the desktop declares such a pattern today; a desktop that
ships a catch-all screen later writes one into its manifest, and a phone
already in the store has to read it, which is what OTA is for.

A trailing rest segment now matches one segment or many, never zero, and
never an empty one. One or more and not zero is expo-router 55.0.18's own
answer for the same pattern: getReactNavigationConfig.js turns [...page]
into *page, fork/getStateFromPath-forks.js turns that into ((.*\/)), and
cleanPath beside it gives every path a trailing slash, so the tail must
hold a slash of its own. Measured against both functions directly.

A rest segment anywhere but last, or a second one, is refused by name
(rest-segment-not-last / rest-segments-repeated) and matches nothing, so
such a route stays native rather than being guessed at.

grantsForRoute now prefers an exactly declared route over a rest one for
the same pathname: a rest pattern covers everything under its prefix, and
first-declaration-wins would hand a screen with its own row whatever the
catch-all asked for.

No schema change. Both manifest readers already accept the pattern string
-- the host's ROUTE_PATHNAME_PATTERN allows brackets and dots, and the
phone's is a bounded string -- and that is pinned rather than assumed.

Red first: seven of the twelve new cases fail on the old matcher.

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

* docs(mobile): name the one place the rest-segment rule narrows expo-router

Its own matcher takes a non-trailing rest — h/*page/tail matches
/h/a/b/tail at 55.0.18 — and this refuses it. Written where the refusal
is, so the divergence is read when it changes.

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

* fix(mobile): drop the fragment key from the catch-all route's query

expo-router's parseQueryParams seeds params['#'] from the URL fragment
before it reads the search string, so /h/x/session/y#files reached the
switch with a `#` key and crossed as params: { '#': 'files' }. A fragment
is not a query and the route the page is given carries none.

Red first: both new cases fail on the old filter — the fragment-only link
lands a params key at all, and the mixed one carries '#' beside `tab`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 05:20:46 -04:00
Jinwoo Hong 0dae510d04 test(mobile): repin the recording corpus to main's tip after #22179 (#22185)
The rejection-golden PR pinned its own branch commit, which the squash left off main; the corpus now pins main at bfcbc6feee, the tree its fenced paths match. Every golden changes only its baseline line.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 05:00:00 -04:00
Jinwoo Hong bfcbc6feee fix(mobile): contain the older-history page's rejection at its own boundary, and re-record its family (#22179)
* fix(mobile): contain the older-history page's rejection at its own boundary

`loadEarlier` in `use-mobile-native-chat-session.ts` runs its read inside a
fire-and-forget `void (async () => {...})()` with a `finally` and no `catch`, so
every throw inside it reaches the document as an unhandled rejection. Two
mechanisms, both certified by
`matrix-session.native-chat-page-nativechat.readsession-1`:

- The awaited `nativeChat.readSession` request rejecting — a transport drop
  (`transport failure`, and the empty-message shape) or the client abandoning
  the in-flight page at teardown (`Connection closed`).
- `'error' in result` on an accepted success whose result was absent or null.
  The operation's reader is `z.unknown()` on purpose, because the reply is a
  union, so `rpcSuccessResultOrSkip` accepts `undefined` and `null` as results
  and `in` throws a TypeError on both before the page is ever read.

Same class as the diff-comments fix (#22111) for the first mechanism and the
catch goes at the same boundary — nothing awaits this promise, so there is no
caller a swallowed rejection could hide from. It differs in the second: that
TypeError is a defect in the payload read rather than an uncaught rejection, so
it is fixed by checking the shape before `in` rather than by the catch, which
would only have hidden it.

Neither mechanism moves an observable value: the `finally` already cleared
`loadingEarlier`, and both new returns land where the throw did, before the
window is touched. A refused page and a failed page now leave the delivered
window alone, which is this operation's declared skip policy.

`mobile-native-chat-page-rejection.test.ts` captures the process
`unhandledRejection` handler for the run and reads it; the three cases fail on
main with exactly `transport failure` and the two `Cannot use 'in' operator`
TypeErrors.

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

* test(mobile): re-record the corpus without the older-history page's rejections

The corpus certified the five unhandled rejections the commit before removes, so
the golden had to move with them. Scoped the same way as #22111: `baseline`
bumped by editing that one line, then `--record`. The recorder refuses to run on
any other tree, and the pin guard's `reproduce` replays the corpus against the
pinned tree with only the recorder overlaid, so a product fix cannot be recorded
without the bump.

788 files, 824 insertions, 938 deletions. Every line classified:

- `baseline`, 787 files (786 goldens + `pilot-scenarios.json`) and nothing else
  in any of them. One distinct changed line across all of them.
- `matrix-session.native-chat-page-nativechat.readsession-1.json`, the only
  golden with a substantive change (37 insertions, 151 deletions). Its value
  pool goes 34 -> 24: ten entries leave, none is added, and no surviving entry's
  content changed.

Inside that golden, three things move and each follows from the effects going:

- **Effects.** Five `unhandled-rejection` values leave the pool — the two
  TypeErrors (`02fb8ef75b28` undefined, `74a36d9b608f` null), the two transport
  rejections (`b5f9fdfa5b32` transport failure, `b388251f574a` empty) and
  `14095708382e` Connection closed — and the 16 checkpoints that carried them
  now read `"effects": []`: four each on `result-absent`, `result-null`,
  `transport-rejection` and `transport-rejection-no-message`.
- **One checkpoint is gone.** `native-chat-page-earlier.prelude:cleanup`.
  `run-recording.ts` emits `cleanup` only when teardown produces an effect, and
  the abandoned page's `Connection closed` rejection was the only one it
  produced. Nothing left to observe, so no checkpoint. The two values only that
  checkpoint referenced leave with it: `980096177097`, its rejected
  `nativeChat.readSession#1` settlement, and `91a7795fa0ab`, its
  `nativeChat.unsubscribe#1` frame.
- **Renumbering, on `payloads` at 12 checkpoints.** Ordinals are one counter per
  run, so dropping an effect shifts everything after it: `nativeChat.subscribe#2`
  5 -> 4, `nativeChat.unsubscribe#1` 6 -> 5, `nativeChat.unsubscribe#2` 7 -> 6.
  Each shifted frame is byte-identical to the entry it now points at apart from
  the ordinal, and every one of those entries was already in the pool — the arms
  that never rejected recorded them. That is why nothing is added and the three
  higher-ordinal twins simply go unreferenced.

`state`, `sender` and `settlements` are unchanged on every surviving checkpoint,
so no observable value moved. `recorderSha256`, `adapterSha256` and
`scenarioSha256` all hold, and `HEAD_EFFECT_SHA256` in
`mobile-session-route-parity.test.ts` does not move: that pin walks
`MobileSessionRouteScreen` and its named expansion hooks, and
`useMobileNativeChatSession` is in neither set.

`baseline` is a branch commit, so a repin to main follows the squash.

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

* test(mobile): build the page-rejection client double with one assertion

The changed-code quality gate read two assertions in the new file and the
second carried no rationale. There is only one shape here the types cannot
express, so there is only one cast: `RpcSuccess` requires `result`, which is
what makes the matrix's `result-absent` reply untypeable. The sender goes into
the object literal the cast already covers.

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

* test(mobile): repin the corpus to the head its product tree matches

The commit before moved a file under `mobile/src`, and the recorder guards that
whole tree rather than only what it loads: `scripts/rpc-recording.mts` refuses
to run when `git diff <baseline> -- mobile/src src/shared` is non-empty. Left
alone, the pin would name a tree that still reproduces the corpus but that no
one could record against without repinning first.

788 files, 788 insertions, 788 deletions, and `baseline` is the only distinct
changed line in any of them. Nothing else moved, which is also the determinism
check: a second full record against a product tree that differs only by a test
file the drivers never load reproduced all 787 goldens byte for byte.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 04:39:06 -04:00
Jinwoo Hong a932147308 test(config): the render rig's navigation wait is bounded, and the frame-budget sweep reads the frames its capture painted (#22177)
* test(config): the render rig's navigation wait is bounded and says why it gave up

`waitForRecordedNavigation` ticked every 10 ms until the case's own timeout,
so an arm whose click missed its 2 s actionability window waited for a record
nobody would write and failed as a bare timeout with the click's error
swallowed. Measured on this tree: with the click pointed at a selector that
does not exist, both engines failed with `Test timed out in 120000ms` and no
mention of the click. C8.1 round 1 saw the same shape at 240 s on CI and
dropped a render arm for it.

The bound is 10 s, sized from the rig rather than chosen: the four arms that
take this path, three runs each on both engines, answered on the loop's first
check at 0 ms in all 24 readings, and in 24 more taken while two full
`config/scripts` suites ran beside them, where the slowest whole case was
2859 ms. Past it the wait throws naming the arm, how long it waited, what the
click did and what the frame last read; the same case now fails in 14.4 s.
The happy path is unchanged -- the first check still answers it, with no
added wait.

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

* test(config): the frame-budget sweep reads the frames its own capture painted

The sweep decided which screencast frames carried the noise by counting
arrivals after the raster barrier, and frames do not reach the client in
capture order. Measured directly against Chromium through CDP at 1400x1600,
six captures at 20x CPU throttling: every frame of the black canvas the resize
left, and every frame still in flight from the previous viewport, was stamped
86 to 161 ms before this capture's paint and still arrived after the barrier,
while every frame carrying the noise was stamped inside 150 ms after it. A
black 1400x1600 frame encodes to 13483 bytes, which is the 0.006 bytes/px read
on 2026-09-22, and a full frame of a previous smaller viewport is the ~447 KB
whose posted envelope was the 596462 that 2026-09-21 expected to be null.

So the precondition is a reading rather than an ordering: the paint hands back
the page's own clock, `Page.screencastFrame` carries the browser's capture
time on that same clock, and only frames stamped at or after the paint are
admitted. A frame with no capture time is not admissible either, since it
cannot be told from a stale one. When none is admissible the error prints every
frame with how long after the paint it was captured, instead of understating
the budget in silence.

The healthy reading is unchanged -- 0.55296 max, 0.54399 min over three idle
runs and three under two concurrent config/scripts suites, against 0.55296 /
0.54399 before -- and so is the cost: 18.4 s against 18.1 s.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 04:11:08 -04:00
Jinjing b57facc5bc Fix stale listings when search query changes (#22173)
* fix(renderer): prevent stale listings when query changes

Bundle files and truncated state with the request key that produced
them. Only display a listing when its request key matches the current
query, preventing previous results from leaking through before the new
request completes.

* fix(renderer): associate loading state with request key

When a query changes, there's a gap between the render and the effect
that starts the new request. During this gap, the listing becomes empty
(because it's scoped to the request key), but the loading indicator was
stale. To fix this, track loading per request and also report true if
this render is about to start a new request — so the empty listing
correctly appears as "loading" rather than "no results".
2026-09-22 01:00:40 -07:00