Commit Graph
11086 Commits
Author SHA1 Message Date
Brennan Benson 8316448826 fix(native-chat): make command recovery replayable 2026-09-16 15:39:33 -07:00
Brennan Benson 707d21e1f1 fix(native-chat): bound conversation command recovery 2026-09-16 15:17:43 -07:00
Brennan Benson 12e2a7a51c fix(native-chat): preserve conversation command escape paths 2026-09-16 15:01:37 -07:00
Brennan Benson 59aaeae2bc fix(native-chat): release session lane during commands 2026-09-16 14:42:00 -07:00
Brennan Benson 81ad1c2224 fix(native-chat): preserve unconfirmed lifecycle compatibility 2026-09-16 14:02:09 -07:00
Brennan Benson 9f3cd91830 fix(native-chat): preserve newer mutation errors 2026-09-16 13:57:24 -07:00
Brennan Benson 57ed0e28a2 fix(native-chat): admit controls before interrupting commands 2026-09-16 13:54:41 -07:00
Brennan Benson 000aa7d48b fix(native-chat): reconcile conversation command ownership 2026-09-16 13:43:28 -07:00
Brennan Benson 7d17a91fb0 chore: keep conflict resolution scoped 2026-09-16 13:17:52 -07:00
Brennan Benson cd0c4d9b3f Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-rpc-guard
# Conflicts:
#	src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts
#	src/renderer/src/components/native-chat/use-structured-agent-session.ts
2026-09-16 13:16:07 -07:00
Brennan Benson ebf408c517 fix(native-chat): resolve a conversation command against its terminal frame
A conversation command runs on the host long after the request that started
it, so the reply is a receipt rather than a completion. The client raced the
transport promise against a 195s timer, which reported an outcome nobody had
established and left no residue: the next attempt silently raced a live first
one.

The client now holds one claim per session and resolves it against the frame
the host revises when the command finishes, carried on the session's own
journal stream; the reply is one way that frame can be learned, not the answer.
When neither arrives inside the deadline the claim becomes a marker that
refuses the next attempt and says the command may still be running, and the
marker retires on a late frame, a session restart, or an interrupt.

Also:

- callRuntimeRpc dropped a declared timeoutMs on the desktop branch while
  forwarding it on the remote one, so every local caller that asked for a
  deadline silently got none. The local path now honours it, and stays a
  synchronous pass-through when no deadline is declared: an extra async frame
  inserts a microtask that reorders calls the caller sequences.

- Cancel already escaped to its own lane while a compaction held the session's
  mutation lane. close() did not, so closing a session queued behind the very
  wait that closing ends. Both now take one named control lane, chosen by
  re-deriving from the durable record whether a command still parks the main
  lane.
2026-09-16 12:06:51 -07:00
Jinwoo Hong 71e308e574 feat(relay): count failed cell-inventory lock acquisitions (#21067)
* feat(relay): count failed cell-inventory lock acquisitions

The cell inventory lock is taken NOWAIT, so contention errors with 55P03 and
retries instead of waiting. CellInventoryHoldSamples.record only runs after a
successful acquisition, so the hold metrics were structurally blind to the
dominant failure mode: production showed ~65 failed fleet-wide acquisitions per
minute while cellInventoryHoldMsMax read a benign 53ms mean.

Count failures next to the holds and publish them as cellInventoryLockUnavailable
in orca_relay_runtime_metrics. Drained on both the commit and the rollback path,
since a 55P03 rolls its transaction back.

* fix(relay): separate request-path lock timeouts from sweep deferrals

Review caught that the first counter only incremented under failIfUnavailable,
which is the sweep mode. Background sweeps take the inventory NOWAIT and
re-derive a skipped candidate next tick, so those deferrals are by design and
already reported as orca_relay_sweep_cell_inventory_busy. The request path uses
a bounded lock_timeout instead, whose expiry raises the same 55P03 without
NOWAIT and was not counted at all -- so the metric measured only the benign
population and missed the user-visible one.

Split them: cellInventoryLockUnavailable for NOWAIT deferrals,
cellInventoryLockTimeouts for expired bounded waits. Production over 30 minutes
shows why the distinction matters -- roughly 1,200 fleet-wide sweep deferrals
against roughly 10/min request-path timeouts.

Adds transaction-path coverage for both drains, which were previously unpinned.
Timeouts count per attempt, not per request, since 55P03 is retryable.

* fix(relay): publish the cell-inventory lock metrics to Cloud Monitoring

google_logging_metric.relay_snapshot only creates metrics for fields listed in
relay_runtime_metrics, and the cellInventoryHold* fields were never added when
the hold telemetry landed. They have been log-only since, so nothing could
alert on the lock and the contention stayed invisible in exactly the way the
telemetry was meant to prevent.

Maps the three hold fields and both new failure counters.

Also corrects the field comment: the split is by wait policy, not by caller.
assignOnce takes the inventory fail-fast on its first placement attempt, so
request-reachable sites land in cellInventoryLockUnavailable too; that lane
reads as contention pressure, and the expired bounded wait is the stall lane.
2026-09-16 14:50:34 -04:00
Brennan Benson f02d09c1ba fix(native-chat): deliver queued messages while the chat pane is hidden (#20659)
* fix(native-chat): deliver queued messages while the chat pane is hidden

With two or more messages queued, everything behind the head waited on the
user's attention. The drain only inspected the head and returned unless it was
`queued`, and a `pending` send deliberately leaves the head `dispatching`. An
entry only leaves that state through the journal subscription, which is torn
down when the pane goes hidden -- and a worktree switch hides it.

Two changes, both needed:

- One shared admission rule now says what the queue does next, and the drain
  takes its `dispatch`: the first `queued` entry, skipping entries the host has
  already acknowledged. It still stops at an `unconfirmed` entry or a refusal
  the user must act on. Order is not the outbox's to keep -- the host appends
  the submission inside the per-session serialize chain before dispatching, so
  journal order is arrival order. Holding the tail bought no ordering guarantee
  and cost delivery. Single-flight still keeps sends strictly sequential, and a
  launch prompt's in-flight send, which runs outside it, still stops the queue.
- The journal subscription now stays open while a session has undelivered outbox
  entries, published from the `writeOutbox` choke point. The subscription's
  retaining hold is what also keeps the host from evicting the session 15s after
  the last turn, which would otherwise turn the stall into a blocked head
  refusing `agent_session_ownership_unknown`.

An acknowledged entry stays in the outbox rather than retiring on `pending`: the
text is safe either way, since the journal upserts a render item from the
submission's own body, but a `pending` can still settle `rejected` or `unknown`
and only the entry carries the retry state that answer needs.

Follow-on corrections the head-only assumption had hidden:

- Single-flight is released where the disposition is applied, not in a later
  `.finally`. That state write is what re-runs the drain, so the release has to
  land first or the queue has no trigger left.
- One ref now holds the in-flight entry's id instead of a bare boolean, and the
  reconcile effect keys its release on that, not on the head, so a journal update
  about the head can no longer discard a still-unsettled send of the tail.
- A refusal blocks the entry it refused, read back by index so a rotated id is
  preserved.
- The automatic unknown probe and the Retry affordance both read the blocker at
  whatever index it sits, the Retry through the same shared rule as the drain.

`raises no delivery notice for a stuck message behind a healthy head` asserted
that a message behind an admitted head raises nothing, because a Retry could not
act on it. It now can, so that guard is rewritten to assert the notice names
that entry and its Retry sends that entry.

* fix(native-chat): resume outbox after journal admission and scope subscriptions

* test: name outbox send request by domain role
2026-09-16 11:26:53 -07:00
Brennan Benson 36cdb34097 test(agent-status): pin each legacy-bypass detector to its own case (#21004)
The ratchet's planted-fixture test collapsed every detection into a
deduplicated kind set, so `passed-map` — which has two independent
producing sites — stayed green when either one broke on its own.
Give each planted form its own case with an exact expected detection.
2026-09-16 11:01:58 -07:00
Jinwoo Hong 383c543e0f test(mobile): repin the recording baseline to main after #20950 (#21065)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 13:44:50 -04:00
Jinwoo Hong a28085adbf refactor(mobile): checked reply readers for the source-control domain (step 7 pilot) (#20950)
* test(mobile): ratchet the 201 unchecked RPC reply readers

Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those
readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader`
(163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader`
(12), across 42 files. The cast moved; it did not become true.

Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file
that is not listed fails, a listed file that no longer has one fails, and a count that
rises fails. Only a call counts, so an import is not a reader and prose never is.

No behaviour change: this commit adds a list and a test.

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

* feat(mobile): validate the source-control domain's RPC replies at arrival

Replaces all 17 unchecked readers in mobile/src/source-control/ with
`rpcResultVariant(variant, schema)`, so a malformed reply is an
`RpcIncompatibleReplyError` naming the operation instead of a TypeError three
frames downstream. The inventory drops 201 -> 184 and the five source-control
operations files leave it entirely.

This is a behaviour change, scoped to malformed replies. Six reply-matrix
goldens move; every named-scenario golden and every `normal` partition is
byte-identical, which is the parity claim.

Schemas live one module per reply domain, beside the operations that read them:
git-status, git-compare, git-history, hosted-review and worktree-metadata. A
member is required only where a consumer reads it unguarded, and each schema
records the consumer line that justifies it. Nothing is `.strict()`; every
reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host
member still passes through. Six replies have no reader anywhere in mobile and
get `z.unknown()`, which is the honest schema for them, not a holdout.

Three readers stay total by construction, because their contract is that an
unreadable reply is a value rather than an error: the `git.status` projection
(a null status three screens route on), the `session.tabs.list` reveal (a null
list means poll again) and the generated commit message (a screen's copy, never
a decode error in a text field). They gain the salvage report, not a verdict.

Consumers take the schema's output type, so `MobileGitStatusResult` and the
branch-compare aliases now name what mobile reads rather than the desktop
aggregate, and seven call-site casts are gone.

Three requirements came from the goldens, not from the host types:
`git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility`
sends a `reviewLookupOutcome` the shared union does not list, and the
`git.status` projection writes an absent member as a present `undefined`.

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

* test(mobile): re-record the six source-control reply-matrix goldens step 7 moves

Six goldens, all on malformed partitions. Every named-scenario golden and every
`normal` partition is unchanged, which is the parity claim for this step.

  git.history-read / git.history#1
    result-absent, result-null, inner-ok-missing, inner-false-string-error,
    inner-false-object-error: the load rejected with a TypeError reading 'items'
    or 'map' off undefined/null; it now rejects with
    `incompatible_reply: git.history-page (git.history)`.

  hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility
    result-absent, result-null, inner-ok-*: the fetch fulfilled with the error
    envelope itself, re-typed as an eligibility and published into the compose
    prefill; it now rejects, and both callers already route that to the same
    "eligibility unavailable" state a null answer produced.

  hostedReview.create-chain + create-intent / hostedReview.create
    result-absent, result-null, inner-ok-missing, inner-false-object-error: the
    create form showed the raw TypeError text "Cannot read properties of
    undefined (reading 'ok')"; it now shows the incompatible-reply message.

Every header digest is unchanged -- baseline, recorder, adapter, scenario and
lockfile all match -- so the diff is the behaviour and nothing else.

Recorded from this branch into a scratch directory and copied in, because there
is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless
the product tree equals the pinned baseline, and the README's remedy for an
intended behaviour change is to repin, which rewrites the `baseline` header of
all 667 goldens. So these six now carry a pin whose tree no longer produces
them. That is a real gap in the oracle's design for behaviour changes, not a
detail of this step, and it needs a decision before this lands.

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

* test(mobile): pin the four reply-schema properties the goldens found

Each of these cost a reply-matrix golden while writing the source-control
schemas, and none of them follows from reading the consumers or the host types:
a newer host's undeclared members must still decode, `git.history` sends
`timestamp: null`, `hostedReview.getCreationEligibility` sends a
`reviewLookupOutcome` the shared union does not list, and the `git.status`
projection writes an absent member as a present `undefined`.

The `.strict()` case is the one worth stating twice: at the top level it rejects
the reply, and on the entry it drops the row, which shows a dirty worktree an
empty Changes list. The fifth test pins the salvage report that makes such a
drop visible instead of silent.

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

* fix(mobile): give an unreadable reply a message a user can read

`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in
`message`, and `message` is what the screens hand to a toast. Step 7 is the
first change that can reach this error at all, so the token would have shipped
to users as its own error copy.

Fixed at the boundary rather than per site: `message` is now plain copy, and the
machine token moved to `code` (`incompatible_reply`) and `name`
(`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle
fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so
it now matches on `name`, which a foreign copy of the module still carries.

No existing test pinned the old text. Two new ones pin the copy, the token and
the foreign-copy match.

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

* test(mobile): repin the recording baseline to this branch and re-record

Commit adeb5f9531 recorded the six moved goldens into a scratch directory and
copied them back, which left them pinned to `e7206f62`, a tree that no longer
produces them. That is the one claim the `baseline` header exists to make, so
this replaces it with the README's remedy done in full.

`baseline` is now f741b2ea82, the last commit on
this branch that touches a fenced path, so the recording fence passes in place
and every golden is pinned to the tree that produced it. All 667 were
re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited.

Decoding every value pool against the branch point b8d4cde09f sorts the corpus
into 661 header-only moves where `baseline` is the only key that moved, 6 whose
body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta,
unchanged at 69 moved observation fields across malformed reply partitions, plus
the readable incompatible-reply copy from f741b2ea82. No `normal` partition and
no named-scenario golden moved.

`scenarioSha256` hashes the derived scenarios, not the manifest, so the repin
moves no other header key; the README section this adds records that, the
scratch-copy failure mode, and the follow-up repin main needs after a squash
merge.

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

* test(mobile): narrow the incompatible-reply error by instanceof, not by cast

The two new tests in f741b2ea82 read the error through `as` casts, which the
changed-code casting gate rejects. An `instanceof` guard narrows the same value
and checks the class at the same time.

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

* test(mobile): repin the recording baseline to the branch tip and re-record

71d8c6a1e2 touched a fenced path (`mobile/src`), so the pin from 5f3f184fdf no
longer named the tree that produces these goldens. The fence compares the whole
of `mobile/src`, and a test file is inside it, so the pin follows the last commit
that touches a fenced path rather than the commit whose behaviour moved.

Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`.
Decoding every value pool against the branch point b8d4cde09f still gives 661
header-only moves with `baseline` the only moved key, 6 body moves, 0 added and
0 deleted; the six and their 69 moved observation fields are unchanged.

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

* test(mobile): record the four source-control reads that had no oracle

git.status (host payload), git.branchCompare, git.commitCompare and
git.branchDiff were migrated to checked readers with no recording observing
them, so a required member a host omits would have surfaced only in production.

Three families mount the owners rather than the senders, because each reply is
only visible in what the owner then publishes: the Changes screen's loader hook
(git.status, and the base-ref chain and git.branchCompare it triggers), the
history list screen (git.history and the per-commit git.commitCompare), and the
committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings
and seven reply matrices.

Two adapter capabilities this needed. An inert FlatList never calls `renderItem`,
so the history adapter renders one row through the screen's own callback, both to
reach the handler that expands a commit and to read the file list back; without
that the commit-compare reply changes nothing observable. And `lowlight` joins
`react` and `zod` as a real library rather than a refusing proxy, because the
branch diff highlights on its success arm before the preview reaches state, so
the shipped text arm was otherwise unrecordable. No golden recorded its absence,
so only `recorderSha256` moves.

Recording the same scenarios against 4b0009d414, the pre-refactor tree, is the
before column. Decoding every value pool across the two gives 11 body moves and
666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new
matrices at 63 moved observation fields. What moved is the point. A malformed
git.status used to leave Changes `ready` over the malformed payload and go on to
fetch a branch compare; it now says the host sent a reply it could not read. An
absent git.branchDiff result used to put "Cannot read properties of undefined
(reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the
expanded commit forever; it now says "No file changes".

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

* test(mobile): repin the recording baseline to the merge commit and re-record

The merge is the last commit touching a fenced path, so it is the only tree
the recorder's fence can match. Every golden moves `baseline` and picks up
main's `recorderSha256` from #20920; the six the checked readers changed are
the only bodies that move against main.

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

* docs(mobile): note the merge-commit pin and unwrap the recipe's record command

`format:check` from `mobile/` caught the wrapped inline command the recipe
had been carrying since it landed; pointing at the command above removes the
duplicate and the wrap together.

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

* fix(mobile): open the source-control reply enums so a newer host's arm degrades

A closed `z.enum` in a reply schema is a version claim, and it refused replies
every declared reader could have rendered: a `git.branchCompare` summary status
of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed
the whole eligibility, and a 'typechange' entry status dropped the row. Main
passed all three through.

`openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as
a member the consumers already handle, while absence and a non-string stay fatal.
Not `.catch()`, which would swallow those two as well.

`area` stays closed and says why: every arm grants stage, unstage or commit, so
there is no member to degrade to that would not offer an action against a row
this build cannot place. Main rendered such a row in no section either.

Also drops two claims the code does not back. Nothing reads the salvage report,
so the two comments promising a dropped entry "arrives as salvage.droppedPaths"
are gone, and `hostKind` on the non-text diff arm had no reader.

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

* docs: write down the open-enum rule and the header keys a branch moves

Rule 4 in the wire-compatibility page, beside the three rules it belongs with:
an enum arm set is a wire surface, unknown arms degrade rather than reject, and
leaving one closed is a decision to state where the schema is declared.

The recorder recipe's step 4 said `baseline` would be the only moved header key,
which is only true of a branch that never touched the recorder. It now names the
three digests a branch's own edits move, so a reader recognises a clean result.

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

* fix(mobile): stop the recorder's own timeout killing a full re-record

The corpus records in ~110s warm and 160s under load, against a 120s budget, so
a full re-record was killed roughly half the time. A killed run wrote a partial
reporter banner and exited 1, which reads as a failing scenario rather than as a
run that never finished — it cost two investigations here. The budget is now ten
minutes, and a killed run says so.

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

* test(mobile): repin the recording baseline to the open-enum commit and re-record

`baseline` is the only header key that moves and no golden body moves: no matrix
partition scripts an unknown enum arm, so the corpus cannot see this change. The
eight schema unit tests are its only oracle.

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

* fix(mobile): stop an unresolvable eligibility claiming the branch is not ready

Both fallback prefills set `canCreate: false`, which is a determination nobody
made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome
is read, so a malformed, refused or rejected eligibility told the user "This
branch is not ready for a pull request yet." instead of asking them to retry.
Dropping it leaves `canCreate` undefined, which is what "unproven" means here.
Only a host that determined `canCreate: false` still gets the blocked copy.

`area` now degrades to absent rather than staying closed. Dropping the row also
dropped it from the unresolved-conflict gate, which grants create on a conflicted
worktree; absent withholds stage, unstage and commit while keeping the row, since
every area reader is an equality check. Its four consumers narrow explicitly: the
diff-review queue filters unplaceable rows, the opener withholds the route, and
the commit-failure prompt pins 'staged' where its own filter already did.

`git.branchCompare` entries are nullish, matching the `?? []` its consumers use.

Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`,
the boundary test drops its dead inventory self-file (the AST counter finds zero
calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no
caller in mobile, desktop or e2e.

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

* style(mobile): formatting and a thrown rejection in the round-2 tests

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

* test(mobile): repin the recording baseline to the round-2 tip and re-record

The round-2 eligibility fix is a behaviour change, so the corpus has to be
re-recorded at a pin that includes it. Four goldens move body: the two
create-intent eligibility matrices on every non-normal partition, and the two
prefill scenarios that lose the fallback's `canCreate: false`.

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

* test(mobile): repin the recording baseline to the main merge and re-record

The merge is now the last commit touching a fenced path, so the corpus has to
carry its sha. No body moves against the pre-merge corpus: main's engine change
shifts `recorderSha256` on every golden and nothing else, and main's fifteen
step-6 goldens re-record byte-identical.

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

* test(mobile): admit the three unchecked readers #20954 landed

The ratchet is a ceiling against this branch adding readers, not a claim about
what main may land. #20954 brought `notification-stream-closed`,
`native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to
raise those lines and say where they came from.

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

* test(mobile): repin the recording baseline to the inventory commit and re-record

The ratchet inventory is a fenced path, so admitting #20954's three readers
moved the fence head again. Baseline only; no body moves against the merge
re-record.

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

* fix(mobile): send the host's own provider token back instead of a fallback

`provider` is not a member mobile only reads. The eligibility reply names it and
the create call returns it, so `openEnum(..., 'unsupported')` did not soften a
reading — it rewrote the bytes, and a host that had just named `codeberg` refused
its own provider as unsupported. The action-sheet Create path has no provider
gate, so nothing caught it.

Passes the token through as a string from the reply to the create params. The
allow-list that decides whether mobile may create stays supportsHostedReviewCreation(),
which already answers no for a token this build does not know; its parameter
widens to `string`, since answering for an unknown token is the whole job. The
worktree-link switch gains a default, which also fixes an older hole: an
unrecognised provider used to fall out of the switch as `undefined` params.

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

* test(mobile): pin the provider pass-through in the corpus

Repins to the provider fix and records `sc-create-intent-unlisted-provider`,
whose eligibility reply names `codeberg` and whose recorded `hostedReview.create`
params carry it back unchanged. Restoring the old enum fallback fails that
golden on `Request params mismatch: hostedReview.create#1` and nothing else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 13:32:09 -04:00
Jinwoo Hong 740887fbbb feat(settings): connected computers rows for session history indexing (#20887)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* feat(settings): connected computers rows for session history indexing

Agent Session History now lists every computer that can hold an index --
this computer first, then each paired Orca server -- as one row with an
icon, a name, a single status line and its own switch. Indexing consent is
stated once above the list, and each row carries the switch for the host it
names, so turning search on for a server no longer means finding that
server's own settings.

Server rows poll aiVault.searchStatus on the same 2s/10s cadence as the
local one while the pane is visible, and report what the host actually
answered: Off, a sweep in progress, or an up-to-date count. A server that is
not connected stays listed but dimmed, with its last known switch position
and no claim about its index. A host that refuses the set call with
host-too-old flips to an update prompt that links to Remote Servers.

The old "Enable session history search" switch and the separate index-status
row are gone; their status copy moved to session-history-status-copy.ts and
their polling to use-session-search-status.ts, so every row shares one
message builder and one poll. Advanced > Delete index copy is unchanged and
still local-only, and a paired web client still sees this computer alone.

window.api.aiVault.setSearchEnabled is declared and bridged here but
implemented by the parallel backend PR.

(cherry picked from commit 0497e6fe93)

* fix(settings): treat a host-too-old status read as an outdated server

(cherry picked from commit a3d751f6e7)

* fix(settings): turn search off before deleting its index

Delete index cleared the index while search was on, so the host closed, removed and immediately reconstructed it and everything reindexed. Turn local search off first, then clear, so the rebuild only happens when the user switches search back on.

(cherry picked from commit 0515588681)

* feat(settings): product-facing copy for session search

Say search, not index or transcript; lead with what the user gets and
where it shows up; one plain privacy sentence; count sessions, not files;
drop the mechanics that change no decision (stop hint, SSH note, source
roots jargon).

(cherry picked from commit 267af1afb3)

* fix(settings): let Button and Collapsible own their spacing and type

* fix(settings): let Button and Collapsible own their spacing and type

* feat(session-search): report how many messages an index holds

The status contract gains an optional messagesIndexed, read from the store
beside the file-state counts and cached the same way, so a settings row can
say what is searchable rather than how many files were opened. Optional on
the wire: a paired server that predates the field degrades to a session count.

* feat(right-sidebar): let a caller open the session panel ready to type

showAiVaultSearch opens the sidebar on the vault tab and sets one flag. The
panel takes the flag, widens its scope to every computer, focuses the search
box and clears the flag, so a later remount stays where the user left it.

* feat(settings): redesign Agent Session Search for many computers

Renames the pane, splits the list into this computer and paired Orca
servers, and puts a count of what is on above it with a Turn on all that
skips offline and too-old hosts and keeps going past a host that refuses.
Consenting once persists a standing consent so a server that later becomes
reachable turns on without another dialog; turning one off by hand drops it.
Rows past the sixth fold away, ordered by what the user can act on. Status
sentences now say how much is searchable instead of Ready, and an off
computer says so with its switch alone.

* fix(settings): hide the fleet roll-up when no server is paired

With only this computer, the count, the Turn on all button and the two
subheads all restate the single switch under them. Show them once a paired
Orca server exists, which is the first point at which they say anything.

* fix(settings): turn session search on without a confirmation dialog

Each switch and Turn on all now act on the click. The dialogs restated the
row they sat under and stood between the user and a preference they can
reverse with the same control. Clearing search data keeps its dialog: that
one destroys something.

* fix(settings): say how many computers Turn on would reach

Drops the summary sentence: every row already states whether it is offline
or needs an update, so counting those again above the list said nothing new.
What is left is the one thing the list cannot say, the size of the action,
carried by the button's own label. With nothing left to turn on, the
standing consent speaks in its place, and only when it is armed.

* Revert "fix(settings): say how many computers Turn on would reach"

This reverts commit 42a4320ae1. The roll-up row's design is still open, so
the branch keeps the summary sentence and the plain Turn on all button until
it is settled. The dialog removal in 30b0786cc6 stands.

* feat(settings): offer one stateless Enable on all computers button

The row above the list is now just that button. It appears when a paired
server is reachable, new enough and off, acts on exactly those plus this
computer, and disappears when there is nothing left to do. What it offers is
read off the rows each render, so it cannot disagree with them.

Deletes the standing auto-enable consent with it: the persisted flag, the
code that armed and cleared it, the per-host memory of which switches the
user had touched, and the line that promised future computers would turn
themselves on. A preference that acts on hosts the user never sees is worse
than a button they press when they mean it.

* fix(right-sidebar): keep the focus-request callback out of render

React Doctor flagged the ref written during render; useEffectEvent is the
codebase's pattern for a latest-callback the effect reads.
2026-09-16 13:31:26 -04:00
Brennan Benson aee98ccaa0 fix(browser): make the browser identity one process-wide choice (#13822) (#20767)
* feat(browser): process-wide browser identity, chosen before ready

Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.

Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.

Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.

* test(browser): cover cross-context UA wire identity

* refactor(browser): make user agent identity app-wide

* test(browser): repair process identity wire fixture

* Fix browser identity startup migration failures

* WIP: rescue in-flight reduced-design work from a dead worker

Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat
2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it
recoverable; not reviewed, not necessarily green.

* fix(browser): repair the rescued identity work so it typechecks

Finishes the interrupted edits in 7db9c54b54:

- browser-user-agent-migration-notice.ts was truncated mid-write; close the
  then() callback so the file parses.
- Register browser.identity.get/set in the generated RPC params catalog so the
  params type-parity gate is satisfied.
- Retire the persistence assertions for the superseded design: a
  migratedNativeProfileIds event map, a notice-acknowledgement clear, and a
  global persistence-failure accessor. Legacy userAgentMode bytes are retained
  now, so these assert retention plus a failed notice write still hydrating.
- The in-memory fs fixture threw a codeless ENOENT, which reads as "unreadable"
  rather than "missing" and made every identity write refuse. Carry the code.
- Use the segmented control's per-option disabled rather than adding a
  control-level prop it does not have.

* refactor(browser): make the identity store the only writer

The rescued work already serialized identity writes, but the writer lived beside the pre-ready reader, so nothing stopped a second caller from writing the record directly -- which is the shape of the bug this change set removes.

browser-identity-mode-record.ts is now read-only: record shape, path, parsing and the pre-ready synchronous read. browser-identity-mode-store.ts owns every mutation behind one queue, holds the snapshot and listeners, and derives restartRequired from appliedMode vs configuredMode rather than storing it. Consumers move to the store.

The two identity RPC methods also move out of browser-core.ts into browser-identity-rpc.ts: they read and write this host's own process identity rather than driving a page, and browser-core.ts was over its line cap. The generated params catalog is byte-identical.

* feat(browser): make resetting unhealthy identity data explicit and lossless

A corrupt or newer-version record left the identity unchangeable with no way out. An explicit reset now copies the old bytes verbatim to a fresh unique path before publishing a replacement, and refuses the whole operation if that backup cannot be written -- so the reset can never be the thing that loses the data. Nothing resets automatically.

Future-version data says update Orca rather than reporting corruption. Reset is opt-in via browser.identity.set and orca browser identity set --reset.

ProfileCreate and BrowserIdentitySet move to browser-identity-params.ts: both carry the per-profile to app-wide identity move, and browser-params.ts was over its line cap.

Also registers browser as a top-level CLI name so the Windows launch redirect covers it -- without it orca browser identity get boots the GUI and exits silently there -- and adds the canonical browser identity show alias the CLI vocabulary policy requires.

* feat(browser): advertise the identity capability only where it exists

browser.identity.v1 was static, so every host claimed it including one that never initialized the identity store, where both methods can only throw. It now follows the browser.headless.v1 precedent and is pushed at status time when the store is actually initialized.

Also covers the retired profileCreate userAgentMode field at the dispatcher rather than only at the schema, so an older client provably gets the changed-semantics rejection over the wire instead of a success with the field quietly dropped.

* refactor(browser): delete the identity write queue and guard backup uniqueness

The queue could not be falsified by any test: writeRecord is synchronous end to end, so two calls cannot interleave and removing serialization entirely left every store test green. Carrying machinery whose guard is unconstructible is what the design review told us to cut, so it is gone. If durable writes ever become async, serialization comes back with the change that makes it testable.

The test that claimed to prove serialization now states what it actually pins -- the later of two selections is the one that survives -- and the module doc no longer claims a queue that is not there.

Adds the guard that was missing on reset: two resets across separate launches must produce two distinct backups, each holding its own original bytes. Verified discriminating -- a fixed backup filename fails it.

* test(browser): guard the identity capability and harden two weak assertions

Pins the mixed-version guarantee that had no test: browser.identity.v1 is advertised when the identity store is initialized and absent when it is not. Verified discriminating -- advertising it unconditionally fails the test.

The profileCreate rejection test asserted ok:false against a runtime with no browserProfileCreate, so that assertion passed even when the retired field was accepted. It now stubs a working runtime method, making ok:false load-bearing, and asserts the runtime is never reached.

Removes the persistence fixture's dead failIdentityWrite branch on writeFileAtomically: nothing on that path calls it, so it implied a second write mechanism that does not exist. Failure is injected through node:fs, which is what the identity write actually uses.

* test(browser): classify the identity channels on the preview seam

The channel split is asserted total, so adding browser:identity:get/set left it
short by two. They manage the host's own process-wide user-agent choice rather
than acting on a guest the reader is looking at, so they sit with the session
and profile channels, not the preview tools.

* test(browser): audit the identity rig's global-fetch call sites

The wire probe server and CDP collector arrived with the cross-context coverage
and were never added to the audit list. The collector's two real call sites are
safe: the poll cancels its unread body and the version probe consumes it through
response.json(). Every hit in the probe server is inside an injected page or
worker script source string, not a call this process makes.

* fix(browser): strip an app name that contains a space

app.setName decides the app token in the user agent, and dev sets "Orca Dev".
The cleaner matched a single whitespace-delimited token, which cannot span that
space, so the replace failed outright and every dev build presented
"Orca Dev/1.4.203" on the wire — the exact token class that gets transplanted
sessions revoked.

Anchoring on the engine comment and consuming lazily up to Chrome/ removes any
number of app tokens. A user agent without that comment is returned unchanged
rather than mangled, because over-stripping is worse than under-stripping.

The function had no unit test at all; it was only exercised through the
real-Electron wire tests, which run with a single-token fixture name. That is
why this survived.

* fix(browser): anchor the cleaner on the gap before Chrome/

My first attempt anchored on the engine comment, which broke a startup fixture
whose platform comment is "(Test)" with no "(KHTML, like Gecko)" at all — the app
token survived and the ordering test went red.

Anchoring on the nearest ")" before Chrome/ and consuming only non-")" tokens
keeps the match inside that gap, so it handles a multi-word app name, a synthetic
platform comment, and an already-clean identity alike. A user agent with no such
gap is still returned unchanged.

The fixture shape is now a test case, since it is what caught the first attempt.

* test(browser): repair the cleaner's case table

A missing comma between two it.each elements was reformatted into an index
expression, collapsing the table so every case ran with undefined input.

* test(browser): make a CI-only capture failure diagnosable

This probe passes locally and fails on CI with an empty receipt set, an empty
CDP diagnostic list, and a fixture that still exits 0 — so the assertion message
carried nothing usable. Thread the fixture's own result and stderr into the
capture assertion so the next run says what the fixture actually did.

* fix(browser): let an explicit choice retire the migration notice for good

The retired per-profile userAgentMode bytes are retained on disk by design, so
every launch rediscovers them and re-arms the notice — including the launch
right after the user answers it, and every launch after that. Documented as
one-time, it was permanent.

The record already carries explicitSelection, which is exactly the fact that
should end the notice. Gate the mark at the single writer rather than deleting
the legacy key, so the retained bytes stay untouched and disk never claims a
notice is pending beside a choice the user already made.

The new test pushed the persistence suite past max-lines, so the in-memory fs
and module mocks move to a named fixture module and the retired-identity tests
move beside them in their own file.

* fix(browser): stop reporting an unhydratable profile as a retired choice

A profile that fails validation for a reason unrelated to identity — a non-UUID
id, a mismatched partition — armed both the notice and its degraded flag. Since
hydrateFromPersisted skips such entries silently and nothing ever repairs them,
the user got "an old browser identity choice could not be inspected" forever,
about a profile that never carried one.

Key the notice on the presence of userAgentMode instead, and use validation only
to decide whether the choice that was found is inspectable. Refusing to hydrate
an entry and finding a retired choice are now separate facts.

The old case table asserted the defect for null, 42 and 'broken', so it is
replaced by two tables stating the new contract rather than adapted to pass.

* fix(browser): stop rewriting worker requests for viewport emulation

A worker request carries no webContentsId, so it always took the session-wide
branch and picked up the mobile UA if any tab in the session had a mobile
preset. That made a single context disagree with itself: a desktop tab's shared
worker reported a desktop navigator.userAgent — the per-target CDP override
cannot reach a worker — while its fetches left as CriOS. It also leaked across
tabs, and closing the emulated tab silently reverted it.

On main the divergence was between contexts, each internally coherent. Making
one context internally inconsistent is worse by this PR's own standard, so
accept that viewport emulation reaches documents only. Workers keep the session
identity on the wire, which is the identity they report in JavaScript.

That left hasSessionMobileViewportIntent with no reader, so the map it fed and
its three accessors go too, rather than leaving a dead latch behind the guard.

The electron fixture models this rule in its own header hook, so its hook and
both mobile arms are rewritten around the invariant that each context's wire
identity equals the identity its own JavaScript reports — not adapted to keep
the old path list passing.

* test(browser): point the identity tests at keys and writers that exist

browserUserAgentMode appears in zero production files and zero commits on main;
`git log -S` finds nothing. The retired key is profile.userAgentMode inside
browser-session-meta.json. Two tests were built on the invented one.

The global-settings test is deleted rather than repointed: no browser identity
key has ever lived in global settings, and stripRetiredGlobalSettings strips
only three unrelated keys, so the test asserted that an arbitrary unknown key
survives an object spread — a fact about the normalizer, not about identity.

The ready-phase test asserted on writeFileAtomically while the identity store
writes through writeFileDurableSync, so it could not go red for the write it
existed to forbid. It now watches the real writer, matched on the record path so
an unrelated durable write cannot fail it for the wrong reason, and the invented
settings key is gone from the Store mock.

Proven by ablation: injecting a byte-identical rewrite of the record into ready
composition leaves every snapshot and record assertion green and is caught only
by the new assertion, while writeFileAtomically is never called.

* fix(browser): let an unavailable process identity reject instead of throwing

installBrowserSessionPartitionPolicies returned Promise<void> without being
async, and configures the user agent policy before any suspension point.
getBrowserProcessUserAgentIdentity throws when the process identity was never
initialized, so that throw escaped synchronously past every caller's handler:
`void install(...).catch(...)` in the registry, and a bare `void install(...)`
in the route policies, which has no handler at all.

Bookkeeping must never gate a user action. Session startup would have died on a
failure its callers were already written to absorb and report.

* docs(browser): scope the meta-store claim about dropped legacy keys

The comment said persistMeta drops legacy keys on the next write because the
loader no longer carries them. That holds for the top-level userAgent keys it
describes, but not for the retired per-profile userAgentMode: it sits inside
each BrowserSessionProfile in `profiles`, which is carried through untouched, so
those bytes survive every write.

Retaining them is deliberate — it is what makes rollback and data-loss machinery
unnecessary, and the startup notice keys on their presence — so the comment read
as broader cover than it provided, in the one place someone would look before
deciding it was safe to strip them.

* test(browser): pin the unmapped-webContents path beside an emulated tab

A popup carries a webContentsId that maps to no registered tab, so it resolves
through the same branch as a worker request that carries none at all. The branch
already handled both, but only the absent-id case was covered.

* test(browser): make the ordering fixture exhibit a multi-word app name

This file sets the dev app name to "Orca Development" and then used a
single-token user agent fixture, so it set up the multi-word scenario and used a
fixture that could not exhibit it — which is how the multi-word app-name leak
got through. The fixture now carries a two-word app token, matching what
app.setName produces in dev, and the assertion names both words: a single \S+
match would leave "Orca" on the wire and still pass a one-token check.

* test(settings): cover the local branch of the browser identity setting

The only existing test covered the remote-host branch. The local branch — load,
select, refused write, and reset-required — had none, and that is the path the
retired-identity notice sends users down to make the choice that retires it.

Covers the selected-mode render, the commit that reports restartRequired, a
refused write surfacing its message without showing the mode as changed, and the
reset-required state offering no control.

* test(browser): run the real registry path in the ready identity pin

The test stubbed browser-session-startup and browser-session-registry, which are
the one ready-phase path that can write the identity record, so the record
content assertion could not fail for the write it existed to forbid.

Both are now real. Only the pieces hanging off the identity path are stubbed —
partition policies, route sessions, cookie staging, webauthn — so the meta load,
the retired-choice inspection, the identity store and the durable write all run
for real against temp directories. The canonical path mock moves to
persistence/loading-store/user-data-path, which is where the registry reads it;
mocking persistence alone left the registry pointed elsewhere. The active
profile directory is now a real temp dir, so the seeded browser-session-meta.json
is actually found — against the old /test-profile literal the meta load found
nothing and the whole exercise would have been vacuous.

A third case proves the path is live: with no explicit choice, the same retired
profile arms the notice through ready and lands migrationNoticePending on disk.
The two authority cases assert the opposite, that an explicit choice leaves the
record untouched.

initializeBrowserSessionsForApp latches on module state, so each case resets
modules and imports ready dynamically.

Ablated: disabling the explicitSelection gate turns both authority cases red on
the record content assertion while the arming case stays green.

* fix(browser): reject an unrecognized identity mode at the IPC door

normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.

The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.

normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.

* fix(settings): name the reset command where identity data is unusable

When configuredMode is null the setting says identity data must be reset
explicitly and then offers no control, because the reset overwrites data that
may belong to a newer Orca. The only escape is the CLI, which the message never
named — so it told the user to do something and gave them no way to do it.

Copy only: one line naming the command, no control and no destructive action in
the UI. The command goes in a new key beside the existing sentence rather than
expanding its default, which keeps the already-translated string valid.

No en.json entry: this component has no catalog entries for any of its keys, so
English resolves from the call-site defaults and adding one only for the new key
would be inconsistent with its siblings.

* fix(i18n): add the browser identity keys to the localization catalog

* fix(i18n): regenerate the runtime-required English catalog

* fix(browser): attach nested CDP targets paused before enabling Network

An OOPIF or dedicated worker was reached only through Target.targetCreated plus
an explicit attachToTarget, which never pauses the target. The frame could issue
its subresource fetch before Network.enable took effect, so the capture came back
empty and the cross-context assertion failed under CI load.

Re-arm auto-attach on each attached session, filtered to nested target types, so
an OOPIF or worker arrives waiting for the debugger and its enables are ordered
ahead of the resume. Drop the explicit attach, which is now both redundant and
the racy path.

* fix(settings): localize the browser identity search keywords

* fix(browser): await route policy setup

* fix(browser): satisfy strict static analysis

* test(browser): update live identity fixture API

* test(browser): preserve native UA in live probe

* fix(browser): close the open review findings on the identity revert

- drop a stray JSDoc left over from the removed per-profile setting
- leave user agents without a Chromium engine comment byte-identical
  instead of anchoring the app-token strip on the OS comment and
  destroying a real engine token
- localize the browser identity unavailable error
- correct the worker comment: only shared and service worker requests
  carry no webContentsId, so emulation still reaches dedicated workers
- retire the session user agent policy when a profile is deleted

* test(browser): model a real Electron fallback in the startup UA fixture

The ordering fixture carried no "(KHTML, like Gecko)" engine comment, a
shape app.userAgentFallback cannot actually produce. That unfaithfulness
was what made the old over-stripping look correct, and it broke once the
cleaner started leaving non-Chromium identities alone.

Add the engine comment, keeping the two-word "Orca Development" app token
so the multi-word leak this test exists to catch is still caught. Both
assertions are unchanged.
2026-09-16 10:31:01 -07:00
Jinwoo Hong bdb18003e0 test: add accumulated-workspace terminal typing reproduction (#20934)
* test: reproduce accumulated-workspace typing latency through real PTYs

* test: make the bench harness self-checks falsifiable

Review found four assertions that could not fail and one fixture gap:

- `missingPtyArrivalCount`/`missingEchoCount` were hardcoded `0` and
  `validateExpectedSeqs` throws before them, so every assertion on them
  was vacuous and every report read `0`. The throw is the real guard and
  is already covered; drop the vestigial fields.
- An absent status controller returned an all-zero result, which satisfied
  its own accepted-equals-generated equality. Assert presence first.
- The byte-pacing control had only an upper bound, so a generator emitting
  no stream bytes passed. Add the lower bound.
- `lineageEvery: 1` built zero lineage: no ordinal satisfies
  `% 1 === 1`. Offset the interval and cover the densest setting.
- The documented control command never set ORCA_TYPING_BENCH, so it
  skipped instead of running.
2026-09-16 13:10:46 -04:00
Jinwoo Hong 7c4325457c fix: name the notification project by the worktree's own host (#20958)
* fix: name the notification project by the worktree's own host

STA-4343: a worktree id is `repoId::path` with no host component, so the
local host and an SSH host publish one id for two different workspaces.
The id-keyed worktree map is first-wins and the repo map is last-wins, so
a colliding workspace could be labelled with the other host's project and
branch. Resolve the owning host first and name nothing when hydrated
ownership cannot prove one; an omitted label beats a wrong one.

Only the collision branch changes: a single-row id still takes the same
map lookups it did before.

* fix: name the project by its host when a repo id spans hosts

A repo id is registered per host, so two hosts can hold one id at
different paths. Their worktree ids are then unique, so the single-row
path skipped host resolution and fell back to the id-keyed repo map,
which is last-wins — a local worktree got the ssh project's name.

Gate the repo lookup on the id actually being ambiguous: one owner keeps
the plain map lookup and its cost, and only a spanning id resolves the
owning host, naming nothing when ownership is unprovable.
2026-09-16 12:51:17 -04:00
Jinwoo Hong a01027697c feat(session-search): enable indexing on paired servers from a client (#20886)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* feat(session-search): enable indexing on paired servers from a client

Adds `aiVault.setSearchEnabled` so a desktop can turn a paired Orca server's
transcript index on or off and have the server apply it without a restart.

The runtime method refuses any caller without a `pairedDeviceId` with a
`forbidden`-class error, writes the whole resolved policy through the runtime
store so retention rides along untouched, then reaches the index through a
host-supplied hook: `applySessionSearchSettingsChange` on the desktop, the
in-process instance's new `apply` on orcad. The relay is unchanged.

Wire compatibility is Rule 1 shaped: a new optional method. A server that
predates it answers method-not-found, which the desktop IPC handler maps to an
error whose message is exactly `host-too-old`. Old clients never call it. The
method is deliberately absent from the mobile allowlist, and `aiVaultSearch`
stays out of the paired settings projection.

(cherry picked from commit 640c715fbd)

* fix(session-search): report a paired server without session search as host-too-old on status reads

(cherry picked from commit 1463e8a4bc)

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:50:59 -04:00
Jinwoo Hong 8153ec2306 feat(session-search): search every computer from the history panel (#20885)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* feat(session-search): search every computer from the history panel

The panel's "All computers" scope produced no request: the hook parsed the
scope into a single host id and stopped when that was null, so the panel
answered "Choose one computer to search its sessions." The desktop already
merges every enumerated host behind `aiVault:searchSessions`, so pass the
scope straight through and stamp each hit with the host it came back on.

Hosts the merge could not search are named under the results header with a
short reason, since a silent partial answer reads as "no such session".

(cherry picked from commit c6b9179316)

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:40:42 -04:00
Jinwoo Hong 3631a1e77f feat(cli): show orca search now the settings toggle ships (#20677)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.

* feat(cli): show orca search now the settings toggle ships

* docs(skills): teach the orca-cli guide the search command

One section: what orca search covers, one host at a time, scope and
narrowing flags, index status before searching, and that a human turns
search on.

* docs(skills): shape the search section like the other command sections
2026-09-16 12:29:35 -04:00
Jinwoo Hong 46ed53b88a feat(session-search): merge all-computers search across hosts (#20670)
* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(session-search): merge all-computers search across hosts

The `all` scope on `aiVault:searchSessions` now fans out from the desktop
to every host the session list enumerates and merges the pages into one.
Legs run in parallel: the local index through the search service, SSH and
runtime hosts through the existing remote search client.

Two fixed orders, because relevance scores from independent indexes are
not comparable. `newest` asks every leg for recency and k-way merges on
`updatedAt`, nulls last, ties broken on execution host id. `relevance`
rotates hosts in host-id order by their own rank.

The merged cursor is an opaque base64url payload holding each host's
cursor, how many of its current page were already emitted, and the
generation that offset counts into, plus the page size and sort the
cursor belongs to. A host whose index moved is fenced to `stale` and
stops contributing; the rest keep paging. Per-host outcomes ride back on
one new optional `hosts` field on the results response.

`aiVault:searchStatus` with `all` stays refused, and neither the runtime
RPC nor the CLI gains the scope, so a fan-out is never two hops.

* fix(preload): let the search bridge address the all-computers scope

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 12:29:11 -04:00
Jinwoo Hong f6197dbd36 fix(ai-vault): index Codex agent replies whose content blocks are typed Text (#20763)
Codex 0.153+ writes paginated rollouts whose completed agent messages carry
content blocks typed `Text`. The transcript reader matched block types
case-sensitively, so every assistant turn from those sessions was dropped
before it reached the search index while user turns and tool output were
kept. Match case-insensitively and bump the index schema so existing
indexes are rebuilt with the replies present.
2026-09-16 12:28:12 -04:00
Jinwoo Hong 0d2f7bcea3 fix(session-search): index OpenCode SQLite sessions (#20870)
* feat(session-search): index OpenCode SQLite sessions

OpenCode sessions live in one SQLite database read on a worker thread, and
the worker only ever answered with the newest few messages for the panel
preview. The parser therefore published nothing over the transcript channel,
so the search index wrote a placeholder row for every OpenCode candidate and
no OpenCode message was ever searchable.

Adds a `capture` request to the worker protocol that returns the session and
every text part of every user/assistant turn from one open of the database.
The agent parser asks for it whenever a sink is listening, so OpenCode joins
the whole-document sources on the same path as Grok, Cursor and Gemini. The
placeholder path (`parserPublishesMessages`, `noteUnreachableParser`) is gone;
an OpenCode read that fails now fails like any other file.

Bumps the index schema so existing indexes drop their placeholder rows, and
adds `sessionsByAgent` to the index status, which is the count that made this
bug visible.

* test(session-search): assert every source speaks, not every agent

OpenCode has two storage shapes, so asking only that some OpenCode session
published messages was satisfied by the legacy JSON fixture while every
SQLite session in the vault stayed silent. Assert per discovered source and
keep the agent-coverage check beside it.

* feat(session-search): capture OpenCode tool and reasoning parts

Text parts alone left OpenCode behind every file-based provider: a command
someone ran, what it printed, and the model's reasoning were all unsearchable.

Widens the capture query to text, reasoning and tool parts. Reasoning folds
into the turn's own words, the way the shared block list already treats a
thinking block. Each tool part becomes one `tool` message carrying the call
line and what came back, built with the same `toolCallText` every file
provider uses; OpenCode's `filePath` is renamed to the `file_path` spelling
that list knows, so a call is findable by its file argument.

Adds a decoded-size ceiling beside the existing part ceiling. It is the bound
a non-streaming source needs and a streaming one does not: a JSONL provider
publishes each message as it reads it, while this one holds a whole session
before posting it across the worker boundary. Neither ceiling truncates; both
fail the read so it is retried and surfaces.

* fix(ai-vault): fail an OpenCode capture it cannot read the message parts of

`readOpenCodeSessionMessages` returned an empty list when the message-part
schema probe failed. The sink-aware reader treats that as a complete read,
so the consumer committed nothing and marked the source `current`: the
session stayed out of the index with nothing on its row to say why and no
retry. The part limit a few lines below already throws for exactly this
reason, so the two now agree.

The preview path is unchanged and still degrades to no messages, which is
what a list read should do.

Also throws from the fixture's `appendOpenCodeSqliteTurn` when the session
id names no row, instead of falling back to the fixture epoch and appending
orphan messages a test would then assert over.
2026-09-16 12:27:26 -04:00
Jinwoo Hong b997fcc77a fix(session-search): try phrase and AND routes for prose queries before OR (#20754)
* fix(session-search): try phrase and AND routes for prose queries before OR

An exact sentence pasted out of a transcript was not returned. The route
ladder only ran the phrase and AND rungs for a literal-looking query, so
prose fell straight to OR, where the sentence's common words filled the
candidate limit with recent sessions and the old session holding the
sentence never reached ranking.

The planner now carries a `phrase` candidate: the query's tokens in order
with stop words kept, which is what the sentence is actually indexed as.
The ladder runs phrase then AND over those tokens for every query of two
or more tokens. A one-token query still takes the rung only when it
looked literal. `incomplete` is reported by the rung that answered rather
than accumulated across every rung tried.

* fix(session-search): mark a snippet with the route that retrieved it

A phrase hit was highlighted with the OR expression over the stop-word
stripped terms, so an exact sentence rendered as scattered bold words with
its stop words plain. The snippet now uses the expression the route
matched by: one run for a phrase, every typed word for AND, the terms for
OR.

* fix(session-search): repair a prose phrase without dropping its stop words

Typo repair re-planned the query from `plan.body`, which prose has already
had its stop words removed from. `relay is droppng frames` therefore came
back as the plan for `relay dropping frames`, and the phrase rung searched
for a sentence nobody wrote: the transcript holds `relay is dropping
frames`, so the exact match fell through to AND.

The repair now maps over `plan.phrase`, the tokens as typed, and re-plans
from those. Only terms the body holds are offered to the corrector, so a
stop word is still never repaired, and the re-plan recomputes the body
from the corrected sentence exactly as before.
2026-09-16 12:27:05 -04:00
Jinwoo Hong dbd750f64d fix(session-search): keep the index status honest while a sweep has a backlog (#20753)
* fix(session-search): keep the index status honest while a sweep has a backlog

A pass stops reading at its wall-clock deadline and records nothing about
the candidates it never opened, which is correct: being owed a read is a
fact about the row, not an entry in a queue. But a candidate the opening
sweep never reached has no row at all, so the store's `due` count cannot
see it. The sweep still reported `completed`, the indexer stamped
`lastSweepCompletedAt`, and `status()` answered `current` with a backlog
of thousands: "Up to date - 130 files indexed", then 630, then more.

The read loop now counts what it decided was owed and did not read and
hands the number back as `left`; the pass propagates it; the indexer
holds the last pass's count, adds it to `filesDue`, reports `indexing`
while it is non-zero or a sweep is owed, and no longer stamps a sweep
the deadline cut short as complete.

* fix(session-search): only an unread backlog keeps the phase at indexing

An armed cadence sweep on a drained index is not a backlog, so it no
longer flashes the pane to indexing with nothing due.

* fix(session-search): stop counting a deferred `due` row twice in filesDue

`status()` reports `filesDue` as `stateCounts().due + left`. The read loop
incremented `left` for every candidate the deadline cut off, including one
whose row already said `due` — and that row is what `stateCounts().due`
counts. A sweep that ran out of time therefore reported each already-due
transcript twice.

`left` now skips a deferred candidate whose row is already `due`. A
candidate with no row, and a `current` row whose file moved, still count:
those are the backlog no query can see, which is why `left` exists.
2026-09-16 12:26:47 -04:00
Jinwoo Hong a7e34d5695 feat(session-search): add panel search and opt-in consent (PR7) (#20580)
* feat(session-search): add ranked history panel search and consent

* test: wait for initial session indexing before refreshing results

* fix(lint): drop the type import #20898 left behind in the windowing test

main's tip fails `typecheck` and `static analysis` on
`NativeChatMessageList.windowing.test.tsx`: #20898 moved the growth/append
suite into its own file and took the last use of `NativeChatMessage` with
it, leaving the import. Every open PR reds both jobs through the merge ref,
so this rides the first branch that has to merge main in.
2026-09-16 12:12:59 -04:00
Jinwoo Hong f85d2bf6ad fix(ai-vault): say OpenCode-in-WSL is not searchable from Windows yet (#20971)
The scan issue for an OpenCode database on a \\wsl.localhost share read as an
error with an instruction the user cannot follow. It now surfaces on the Agent
Session Search settings page under a computer row, where it belongs as a known
limitation, so the WSL branch now reads 'OpenCode sessions inside WSL can't be
searched from Windows yet.'

Copy only: the issue keeps kind 'scope' and its path, every other branch is
untouched, and discovery, the WSL gate, and the busy-timeout behavior are
unchanged.
2026-09-16 12:04:16 -04:00
Jinwoo Hong 3e5eb0329a feat(cli): orca search over the agent session index (#20514)
* feat(cli): orca search over the agent session index

`orca search <query>` calls PR 5's `aiVault.searchSessions` over the CLI's
existing runtime RPC, against the host `--environment` / `--pairing-code`
selects and no other. `orca search --index-status` calls `aiVault.searchStatus`.
It is the proof the contract works with no panel.

Every flag maps onto a contract field and nothing else: `--scope`, `--fresh`,
`--limit`, `--cursor`, repeatable `--agent` and `--path`, `--since`, `--sort`,
`--debug`, `--json`. No fan-out, no merged output, no `--host`.

One command rather than a `search status` subcommand: the query is a bare
positional, so `orca search status` could not be told apart from searching for
the word "status". `--status` is unavailable because `orchestration task-list
--status <state>` already owns the name as a valued flag.

No new runtime capability. PR 5 decided an explicit `method_not_found` refusal
maps to `unavailable/no-service`, so reusing `createSessionSearchClient` gives
an old host a plain "this host runs no session search service" answer at exit 0
instead of a raw JSON-RPC error.

`CommandSpec.repeatableFlags` scopes repeatability per command, because
`--agent` must repeat for search and stay single-valued for `worktree create`.
`help.ts` sat exactly at max-lines, so `skills-command-flag-help.ts` becomes
`command-scoped-flag-help.ts` carrying both tables at the same call-site size.

* refactor(cli): drop the search type assertions main's casting gate now rejects

Main gained a `consistent-type-assertions: never` scan in the changed-code gate
after this branch was cut, and it reported twelve assertions in the new files.

The four in the argument parser were avoidable. `readEnum` now keeps the value
`find` returns, which already carries the narrow type, and the agent filter goes
through an `isAiVaultAgent` predicate over a `Set<string>` instead of widening
the agent tuple.

The test now narrows the printed envelope by shape and re-reads the printed
result through `AiVaultSearchResponseSchema`, so the JSON assertions are checked
rather than claimed, and the flag table is typed so its callback needs no cast.
One assertion is left, for the structural fake client, with the SAFETY rationale
AGENTS.md requires.

* fix(cli): sanitize host strings and scope pre-command repeatable flags

Route every host-supplied string the search formatter prints through the
escape stripper, and resolve the repeatable-flag set from the command
tokens ahead when a flag sits before the command.

* refactor(cli): resolve repeatable flag rules once per command

* fix(cli): clarify session search availability and SSH scope

* feat(cli): hide orca search until the settings toggle ships

`orca search` stays dispatchable but leaves every discovery surface: root
help, group help, unknown-command suggestions, and `agent-context --json`.
`buildAgentContext` did not filter hidden specs, so it also stops leaking
the hidden `terminal stop`.
2026-09-16 12:03:38 -04:00
Jinwoo Hong dec0e2cd56 feat(session-history): add local search settings and index controls (#20582)
* feat(session-history): add local search settings and index controls

* Use shared local host identifier for session index status

* feat(settings): live index status, enable confirm, advanced delete

* fix(settings): let Button and Collapsible own their spacing and type
2026-09-16 11:59:22 -04:00
github-actions[bot] 0b28d354fe Update README downloads badge 2026-09-16 12:37:45 +00:00
Brennan Benson 291b4ddd6f feat(agent-status): route structured sessions through canonical ownership (#20718)
* feat(agent-status): route structured status through canonical ownership and fence child lifetimes

Restacked onto the canonical store and child-work contract. Completing that
restack drops the `reopenStructuredParent` mutation flag this change had
carried, along with its contract field, its codec branch, and its single
call site in structured ingest, which passed a hardcoded `true`.

The flag was a narrow escape hatch from the absolute `tombstones.has(...)`
rule that governed parent upserts in this branch's original base. The
canonical store replaces that rule with a revision envelope, because a
bounded store compacts tombstones away and a presence-based guard silently
stops fencing once one is evicted. With the envelope deciding the outcome,
the escape hatch has nothing left to escape from, so removing it changes no
production behaviour.

`agent-status-store-reopen.test.ts` is rewritten against the envelope: the
reopen case now pins that an unflagged republication succeeds while replay
from before the reopen stays fenced even after the parent tombstone is
compacted away, and the second case pins where the guard genuinely bites —
a republication inside the removing mutation itself, for every subject kind.

* fix(agent-status): re-admit unchanged structured owners after teardown

* fix(agent-status): clear anti-slop object-param and Reflect.apply findings

- agent-status-store-byte-budget.ts: type the byte-budget helper's
  record parameter as the union of what its call sites actually pass
  (the snapshot header plus each store entity record) instead of the
  broad `object`.
- server-structured-canonical-status.test.ts: replace `Reflect.apply`
  with a typed, explicitly-bound call that models a caller at an
  untyped boundary omitting the trusted owner subject.

* docs(agent-status): drop the 2A progress doc from docs/reference

docs/reference/ holds implementation detail, not rollout progress. The
canonical-boundary notes move to the effort's working directory; the
agent-status-store status section keeps the boundary statement and loses
the now-dangling link.

* fix(agent-status): mint the canonical epoch on first use, not at construction

The hook server's canonical store was built in an instance-member initializer, so
constructing AgentHookServer — which happens at import time for the module
singleton — demanded a live randomUUID. Any importer that stubs node:crypto threw
'Invalid agent status store epoch' before a single test ran.

The store is now created on first canonical access and reset by dropping it, so
construction owes nothing to a crypto implementation and the epoch still rotates
per authority incarnation.

* fix(agent-status): drop the orphaned snapshot budget and a duplicated pane guard

Two leftovers from the canonical-store routing change:

agent-status-store-snapshot-budget.ts lost its only caller when the store state
switched to agentStatusStoreFitsByteBudget. Nothing in the repo imports it now,
so the module goes with the caller it existed for. The replacement is not a
straight copy: it only memoises a record's measured size once the record is
frozen, so a still-mutable record can no longer return a stale byte count.

persistedStructuredWorkerPaneKeyIsValid repeated its public-pane-key rejection
verbatim three lines below the first one. The tests covering that rejection pass
on the first occurrence alone, so the second decided nothing and only obscured
which predicate was load-bearing.

* fix(agent-status): stop a failed structured publish from latching as owned

Three defects found reviewing the structured routing path.

combinedStatusEntries defaulted a missing listing order to 0, but the counter it
compares against starts at 1, so any unordered row sorted above every ordered
one. Unknown order now sorts last.

The owner map recorded a session as owned before the sink ran. A publish that
threw therefore left matchesLocation reporting an owned location for a row that
was never written, and the unchanged-projection path — the only thing that would
re-offer it — stopped. The address still has to survive a throw so teardown can
forget a row that did land, so the two facts are now separate: the address is
recorded up front, and only a publish that returned marks the row as landed.

The reopen test claimed the revision envelope rather than the tombstone fences a
stale replay. It cannot tell: transport consecutiveness, the parent-revision
validator and the tombstone guard each refuse that replay alone, and ablating any
two leaves the test green. It now asserts the outcome and says so.
2026-09-16 01:20:58 -07:00
Brennan Benson 170ebce1f2 fix(ci): run static analysis for every tree the repo-wide audits scan (#20918)
A mobile-only diff is desktop-irrelevant, so should_run was false and every PR check skipped -- including the audits that do lint mobile/. The violation then landed on main and failed the same gate on every later PR's merge ref. Derive the trigger from the audit commands' own scan roots so the two cannot drift.
2026-09-16 01:13:06 -07:00
Jinjing 15cac68802 Native chat keeps scrolling to bottom (#20898)
* fix(native-chat): prevent auto-scroll when transcript is hidden

Stop following new messages to bottom when the chat view is not visible
(e.g., in an inactive tab). Restore scroll position when the transcript
becomes visible again.

* fix(native-chat): preserve reader scroll offset when transcript is revea

When a reader scrolls away from the latest messages and the chat tab becomes hidden, save their scroll position. If messages arrive while the tab is hidden, don't auto-scroll. When the tab is revealed, restore the saved offset instead of jumping to latest, preserving their reading context across hide/reveal cycles.

* refactor(native-chat): extract growth-append tests and status component

Move transcript growth/append test suite to dedicated growth-windowing.test.tsx file for better organization. Extract status rendering logic (errors, retry, background tasks) from NativeChatStructuredSession into NativeChatStructuredSessionStatus. Fix scroll offset preservation in test harness when transcript visibility toggles.

* refactor(native-chat): remove retry UI

Remove unused retry functionality for failed message delivery from the native chat status component. The retryableOutboxEntry state is no longer managed, so the retry button and associated handling can be safely removed.
2026-09-16 00:44:11 -07:00
OrcaWinandm4air 07b7687a2e fix(sidebar): keep the Projects filter when a project is added (#20987)
Adding a project wiped the Projects filter: both reveal paths made the
new project visible by clearing filterRepoIds outright, so a user
filtered to A and B was dropped back to every project.

The filter is an allow-list, so revealing a repo only needs that repo
added to it. revealRepoInProjectFilter widens the selection instead,
and no-ops while the filter is off, where adding an id would turn
"show everything" into "show only this project".

STA-7588

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-16 00:37:01 -07:00
Neil d62328aa4d fix(codex): remove redundant Windows hook launcher for Unicode profiles (#20952)
* fix(codex): reuse the Windows hook shell for Unicode profile paths

* test(codex): register Unicode hook tests in Windows CI

* test(codex): pin trust hash replacement during Windows upgrade

* test(codex): retry transient Windows teardown locks
2026-09-16 00:30:52 -07:00
Jinjing 78609330d1 Fix browser viewport presets incorrectly scaled by UI zoom (#20962)
* Fix browser viewport presets scaled incorrectly by UI zoom

Browser viewport presets must remain in window DIP (native) coordinates
but scale in CSS pixels as UI zoom changes. Store preset dimensions as
CSS variables in DIP, then divide by the live UI zoom factor in the
stylesheet. Also consolidate zoom factor calculations across the app
to use a shared `uiZoomFactorFromLevel()` function and add
`windowDipToCssPx()` for converting native coordinates to CSS pixels.

* Move viewport preset zoom compensation to CSS class

Inline width/height styles outrank class rules, preventing the zoom
compensation from applying. Using a class rule ensures the viewport
scales correctly as the UI zoom factor changes.
2026-09-16 00:16:56 -07:00
Neil e39b432c40 fix(editor): preserve Markdown scroll after image layout (#20956)
* fix(editor): preserve markdown scroll after image layout

* test(editor): harden scroll regression cleanup and geometry checks
2026-09-16 00:05:29 -07:00
OrcaWinandm4air 16ac9018db docs: remove unavailable diff shortcuts (#20974)
Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-15 23:44:23 -07:00
Brennan Benson c702e77bc7 Stop reading the terminal arguments field on the structured chat route (#20944)
* fix(native-chat): stop reading the terminal arguments field on the structured chat route

Setting Claude's Arguments to "--dangerously-skip-permissions --model Opus" made
every new Claude tab open in the old terminal-backed chat instead of the new
structured one, with nothing on screen to explain why. Removing "--model Opus"
fixed it.

The cause was a whole-string comparison: the configured arguments were checked
against a single blessed value per agent, so any added token at all — including
one the agent supports — stopped the string matching and the launch was demoted.

Structured chat does not run the interactive CLI. It drives Claude through the
Agent SDK and Codex through app-server, and those take narrower option sets that
are versioned separately from the CLI's, so one free-text field cannot have a
guaranteed meaning for all three. The structured route now reads only what it can
actually honour: a replaced launch command, or a launch that names its own working
directory. Terminal launches still apply the field exactly as before.

Permission posture no longer travels as a raw flag. It is derived from the
resolved launch arguments, which is the same fact a terminal launch acts on and
which falls back to the default Orca ships when the field was never touched, so
bypass stays on by default and Manual is still honoured. Claude gets the SDK's
typed permissionMode and allowDangerouslySkipPermissions at query start; Codex
gets its bypass flag placed before the app-server subcommand. Both are re-derived
per acquisition beside the auth policy and environment overlay rather than stored
in the session record, so nothing can disagree with the setting.

Codex also loses the --profile, --add-dir and -c passthrough that reached
app-server through that field. Only the permission posture comes back.

* test(native-chat): pin routing authority on the narrowed feasibility input

The routing-authority pin still named the old bundled blocker and built its
"customized" fixture out of the arguments field, which is no longer a feasibility
input. Both are now the launch command, and arguments and environment are
customized on both passes of the loop, so the flag handed to the shared resolver
tracks the command alone — a caller that resumed reading either one fails here.

No case is dropped and no assertion is relaxed: the blocker list is still
exhaustive and every caller must still honour a refusal from the shared resolver.
2026-09-15 23:38:04 -07:00
Jinwoo Hong f78483ec29 refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6, migration 1) (#20954)
* test(mobile): record the three step-6 families at the pin, and record a stream listener that dies

Step 6 migrates the requests step 4 left behind because they share an effect with a
`client.subscribe`. This records them first, from the pinned baseline, so the refactor that
follows has a parity oracle.

Three new families, one adapter module each:

- `session.native-chat-page` — the older-history page. The read is a callback, but only the
  mount effect's `nativeChat.subscribe` arms what it pages against, so the frames are the setup:
  the snapshot's `beforeOffset` decides whether the request carries a cursor or asks for a
  growing tail. A cutover and a second snapshot pin the reconnect replay merging into paged-in
  history instead of collapsing the window.
- `notifications.desktop-stream` — the desktop notification socket: the subscribe, the catch-up
  read its `ready` arms, the tray dismissals its events drive, and the server unsubscribe the
  disposer sends. Split in two so the base scenario's matrix sites all have partition-stable
  params: a variant that answers the second `ready` differently leaves the unsubscribe carrying
  the first subscription id, which the base's scripted params could not assert.
- `session.terminal-gesture-input` — the debounced gesture flush and the menu's clear-buffer.
  Neither rides a subscription; a mount holding no terminal ref reaches both.

The engine change is what makes the first two recordable at all. `ScriptedRpcTransport.frame`
now returns what the product listener threw instead of throwing it on, and the runner records it
as a `stream-listener-crash` effect. Only the two `runtime.clientEvents` listeners check that a
frame payload is an object before reading its `type`; every other subscribing family took the
matrix's `result-absent` and `result-null` partitions as an uncaught TypeError, which failed the
suite rather than recording what a malformed frame does to a subscription. That is the same rule
the crash boundary already holds for a screen and the unhandled-rejection window for a detached
effect. The scenario's own faults stay loud: a missing subscribe payload, a params mismatch and a
closed stream are all raised outside the caught region.

`recorderSha256` therefore moves, so all 679 pre-existing goldens are re-recorded from the pin
with this branch's recorder laid over it. Every one of them moves exactly one line and that line
is `recorderSha256`: no `adapterSha256`, no `scenarioSha256` and no observation moved.

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

* refactor(mobile): send the subscription-gated holdouts through typed RpcOperations (step 6)

Five references over four files leave the raw request port. Each was held out of step 4 because a
request-only recorder could not mount it; the recordings landed in the previous commit and no
golden moves here.

- `use-live-worktree-name.ts` — `worktree.show` inside the focus effect that opens
  `runtime.clientEvents`. It reuses `sessionWorktreeRecordRead`, which is the diff-comment loader's
  reader renamed: both consumers read the `worktree` member whole and narrow their own field off
  it, so a second family would have been a second name for the same wire. The resolution still
  comes off the raw reply, because `selector_not_found` is what proves the worktree is gone and no
  acceptance policy carries a refusal code; the skip that follows is the same verdict main's
  `!response.ok` reached, since a refusal is the only reply this policy declines.
- `use-mobile-native-chat-session.ts` — `nativeChat.readSession` in the paging callback. The
  payload stays whole because the reply is a union: an older runtime answers `{ error }` in place
  of a window, and the caller discriminates before reading a message list.
- `mobile-notifications.ts` — `notifications.unsubscribe` in the `ready` branch of the
  subscription callback, in its own module rather than beside the push-route sends: one is the
  route this device holds with a gateway, the other the socket the paired connection holds.
- `use-mobile-session-terminal-input.ts` — the gesture flush reuses `terminalInputSend`, which
  already carried the four other terminal-input call sites and the same accepted-verdict, and the
  menu's clear gets `terminalBufferClear` beside it. The clear is a skip because main never read
  the envelope: it toasted success on any fulfilled reply, so only a transport rejection reached
  the failure toast. That is preserved, not repaired.

`mobile-session-route-parity.test.ts` refreshes three pins with their reason: the callback bodies
and the twelve nested-function bodies moved where those send expressions were rewritten, and the
runtime-string count drops by two because `terminal.send` and `terminal.clearBuffer` are now fixed
at their operation's definition instead of spelled at the call site.

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

* test(mobile): hold the subscription coverage as a checked inventory instead of a README paragraph

Every product `client.subscribe` is now an entry in
`mobile/src/transport/rpc-subscription-inventory.ts`, classified as recorded (naming its family),
an unwritten scenario, or walled with the wall named. `rpc-subscription-boundary.test.ts` fails on
a new site with no entry, an entry whose file no longer subscribes, an entry naming a method the
file does not open, and a `recorded` entry whose family the scenario manifest does not have. Both
the unlisted-site and unresolved-family gates were checked by removing an entry and by misspelling
a family; each fails on its own assertion.

The paragraph this replaces said nine sites when there were ten. It counted over `mobile/src`, and
the host screen's `accounts.subscribe` lives under `app/` — so the scan here covers both roots, the
way the raw-port ratchet next door does. Ten sites today: four recorded, two unwritten scenarios,
four walled (two on the webview ref, one on the multi-host client context, one on two unsubstituted
view members).

Unlike the raw-port inventory this list does not count down to zero. A typed operation fixes one
method, one acceptance and one reader for one reply; a stream has many, and replacing a subscribe
is not what this is asking for. The question it holds is the other one — which stream a golden
actually has, and for the rest, what exactly stops it.

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

* test(mobile): pin the notification stream close, which writes nothing to the wire

Deleting `unsubscribeStream()` from the notification cleanup — the local close,
not the `notifications.unsubscribe` RPC beside it — survived all 810 tests.
Neither unsubscribe builder in the stream registry knows `notifications.subscribe`,
so closing that stream sends no frame; the mutant leaks a live subscription record
instead, and the leak only surfaces when the logical client replays it onto the
next session. `notifications-desktop-stream-closed` stops the stream and then cuts
over, where the leak becomes a second `notifications.subscribe` payload.

Recorded at the pin. No existing golden moves: the new scenario is appended, so it
is not the family's matrix base, and every notification matrix site already had a
fulfilled reply to replay.

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

* fix(mobile): name the accounts screen's real wall, which is ScrollView and Alert

The entry blamed `expo-router.useFocusEffect`, which is substituted, and the
inventory's own `use-live-worktree-name` is recorded while importing it. Probed
by mounting the screen through the trap: the first refusal is
`Unsubstituted native member: react-native.ScrollView`, and `Alert` refuses too.

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

* refactor(mobile): drop the terminal-send response reader that lost its last caller

`isTerminalSendRpcAccepted` read the verdict off a whole envelope, which is what
the raw call site did. Both callers now send through an operation and read the
admitted payload, so the response form had only its own test left. The three
cases move onto `isTerminalSendResultAccepted`, with the refusal envelope's
missing result standing in for the failed response.

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

* test(mobile): attribute a frame crash to the listener that threw, not to the registry

The try wrapped `stream.deliver`, so anything the registry raised on its way to
the listener was recorded as a `stream-listener-crash` effect and blamed on the
product. A reply like `{ok:false}` with no error object throws reaching for
`error.message` before any listener runs, and that is a scenario that stopped
matching, not an observation.

Only the product's own `onData` is wrapped now. The throw is stashed and
rethrown unchanged, so the registry still sees it the way a device's message
handler does and what it skips after a dead listener stays recorded rather than
invented; `frame` reports it only when the error it caught is the one the
listener raised. `FrameListenerCrash` is local to the file again.

Engine change, so every golden re-records: 694 files, every changed line the
`recorderSha256` header, no body movement. Against main the set is 679 modified
header-only and the same 15 added.

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

* fix(mobile): read the frame listener stash through a method, not a narrowed field

`this.listenerCrash = null` before the try narrows the property to `null` for
the rest of `frame`, so the catch compared against `never` and mobile's own
`tsc --noEmit` failed. A private taker returns the declared type.

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

* fix(mobile): abort a registry throw that stashed nothing, and fold the last native-chat read module in

The frame catch compared `crashed?.error !== error`, which is false when nothing
was stashed and the registry threw `undefined`, so that abort was swallowed and
`frame` reported a clean delivery. It now asks whether a listener crashed at all.

Also: `nativeChatSessionPageRead` moves beside the three other `nativeChat.*`
reads and its one-export module goes; the session read header names the whole
`worktree.show` record rather than review notes; the guarded-listener count is
three, not two; the README names the ten subscribing sites blur is unrecorded
across; and the gesture flush reads the send verdict as `=== true` like the
other four sites.

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

* chore(mobile): drop an oxlint disable the rule never needed

`no-throw-literal` is not enabled here, so the directive read as unused and
failed the changed-code quality gate.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 01:59:55 -04:00
Jinjing 4948afc2ec fix(linear): show 'Cannot verify' when skill scan is inconclusive (#20964)
A scan that encounters an error before discovering skills, or hits an
unreadable root, cannot vouch for "not installed". Previously these cases
were conflated with proven absence, so the checklist would claim the skill
step incomplete even when all three steps had been finished. Now the UI
distinguishes between confirmed states and unknown ones, showing "Cannot
verify" instead of listing the skill as an unfinished step.
2026-09-15 22:53:48 -07:00
Jinjing feb04ec254 Virtualize automations run history table for large histories (#20916)
* refactor(automations): virtualize run history table

- Add virtual scrolling to AutomationRunHistory for efficient rendering of large run lists
- Implement sticky table header that stays visible during scroll
- Update keyboard navigation and focus management for virtualized rows
- Move AutomationRunsTable header inside scroll container for visual consistency
- Add virtualizer-test-stub for testing virtual scroll behavior without DOM measurement
- Cache DateTimeFormat to avoid per-cell allocation overhead

* test(automations): add coverage for virtualized run table

- Tests verify row content renders spend, tokens, and workspace labels correctly
- Keyboard navigation guards prevent operations during failed host reads
- Load-more pagination triggers at scroll end and respects page boundaries
- New fixtures support flexible automation run and usage test scenarios

* test(automations): verify scroll-to-focus path in virtualized runs

- Implement scrollToIndex in virtualizer stub to move viewport window
- Optimize row-size estimation to use predicate instead of labels
- Test validates keyboard navigation scrolls rows into view before focus

* add more tests
2026-09-15 22:21:38 -07:00
Jinjing b0d46e2d3d fix(settings): preserve multiline proxy bypass rules (#20957) 2026-09-15 22:08:40 -07:00
Jinjing bfc297df99 fix(settings): keep integration connect dialog drafts on backdrop click (#20932)
* fix(settings): keep integration connect dialog drafts on backdrop click

A backdrop click in the Settings → Integrations Jira/Linear/Bitbucket
connect dialogs dismissed the Radix modal, and each dialog's reset-on-open
then wiped the typed credential.

Generalize SshTargetForm's dirty-gated outside dismissal into a shared
preventOutsideDismissWhenDirty factory and wire it into the three dialogs
(and SshTargetForm), so an accidental backdrop click no longer discards a
draft while Escape / Cancel / × remain the explicit discard paths.

Bitbucket compares email/baseUrl against a props-seeded baseline and only
counts the active auth mode's fields, so a mid-edit status refresh and a
mode toggle cannot make the form sticky.

STA-7332

* test(e2e): drop ticket id from dismiss spec comment
2026-09-15 22:03:16 -07:00
Brennan Benson 9ed561c1d2 fix(claude): judge Stop against the turn the journal published (#20921)
* fix(claude): judge Stop against the turn the journal published

A Stop could be refused for the turn the user was actually looking at. The
client derives the id it sends from the published journal rows, but the host
compared it against the adapter's own in-memory turn. The journal sink drains
asynchronously, so that in-memory value can already name a turn whose row has
not landed — an id no client has been shown, and one the client's Stop can
therefore never match. The user pressed Stop and nothing stopped.

Fix the guard's source rather than the guard. ownsRequestedTurn stays: it is
what stops a delayed request from interrupting a later turn, and without it a
stale Stop would reach a session-scoped interrupt that settles every queued
send as durably rejected. The host now resolves the live turn from the journal
projection and hands it to the adapter, which prefers it and falls back to its
in-memory read for direct callers that have no journal.

It is passed as a read rather than a value because the guard re-checks after
the delivery fence may have waited seconds; a value captured at request time
would interrupt whatever turn ran next.

Both callers supply it. The handoff's own Stop bypasses performCancel, so its
body moves into stopNativeHandoffTurn beside the file's other extracted flows,
which is also what lets it be tested on its own.

* fix(claude): fall back to the in-memory turn while the journal drains

The journal drains through a serialized async queue, so a live turn routinely
has no published row yet. Judging a Stop only against the journal refused in
that window, which gates a user action on bookkeeping. The journal stays
authoritative while it HAS an answer; a null read falls through to the
in-memory turn, and the nothing-dispatched clause is unchanged.

Both call sites now read the live turn through `journal.activeTurnId()`, which
folds reduced items instead of rendering and sorting a whole snapshot.
2026-09-15 21:56:49 -07:00
Jinjing 47bb473ec6 Remove agent map from dashboard popout (#20929)
The agent map view was not functional and its components have been removed entirely. The dashboard popout now only supports the kanban board view, with all map-related code, utilities, types, and translations cleaned up accordingly.
2026-09-15 21:55:06 -07:00
Jinwoo Hong 96d77b37c5 perf: always show project names and remove notification scans (#20931)
* perf: avoid repeated agent scans when labeling notifications

* perf: always label notifications and remove project counting

* fix: qualify the notification project group by its folder's host

Folder notifications resolved the folder host-aware, then looked its
project group up by bare ID. The owner index fails a bare ID closed when
two hosts publish the same group ID, so a remote folder lost the project
name the catalog already had.

Also drops the identity rescans that recovered display fields: the
catalog finders now return the caller's row type, matching
findIndexedRepoOwnerForHost.

Updates the idle-arbitration expectation that still asserted the removed
hasMultipleActiveRepos flag.
2026-09-16 00:46:46 -04:00