Repository navigation
Conversation
…ing the creation launch route (stablyai#19819) * fix(native-chat): wait for the runtime capability probe before resolving the launch route A worktree created before the renderer's hydration-gated capability refresh runs read the local capability set as null, which resolveStructuredNativeChatSupport treats as a blocker, silently degrading structured native chat to the legacy terminal-backed route. Creation submits now await ensureLocalRuntimeCapabilities(), which probes the local runtime when no answer has landed yet, so the route resolves on an actual answer. Fixes stablyai#19154 * fix(native-chat): await the capability probe in the work-item direct launch route too prepareDirectWorkItemAgentLaunch is the fourth creation-flow route owner and already async; a pre-hydration submit-after-ready launch (fix-checks) read the unprobed cache as unsupported and silently degraded to legacy. Draft-delivery launches were unaffected (draft-prompt blocks structured before the capability check). Same shape as the three creation-submit sites. * fix(native-chat): keep the capability probe starting synchronously The broken-bridge hardening wrapped the probe in Promise.resolve().then(...), which deferred window.api.runtime.getStatus() by a microtask. The session-tabs restore deliberately overlaps its inventory RPC with this refresh and relies on the probe already being in flight when refresh returns, so the deferral broke it. The bridge call is synchronous again; a synchronous throw becomes a rejection instead, which is what the wrapper was actually for. * fix(native-chat): hydrate local runtime capabilities at renderer boot The capability cache's only writer was `useLocalStructuredSessionTabsSync`, gated on workspaceSessionReady + terminalStartupRestorationReady + the experimental flag. Every `resolveAgentLaunchRoute` reader treats an unanswered cache as "unsupported", so the answer arriving seconds late is what produces the bare-terminal create in stablyai#19154 — awaiting the probe at a route decision guards four call sites but leaves the window open for the three readers that are synchronous and cannot await. Start the probe from the renderer boot chain, ungated, so the answer is cached before any launch route is resolved. The per-call-site awaits stay as the backstop for the residual window and for re-probing after a failed probe. Also: hoist the full-creation probe above its cancel gate so the gate stays adjacent to createWorktree; pin the retry-after-failure, concurrent-ensure and missing-bridge contracts; drop a stale microtask tick and correct two comments that no longer described the code. * test(native-chat): pin the cancel gate around the capability probe The probe added an await to two composer creation paths. Full creation had no gate between the route decision and createWorktree, so the earlier revision opened a window where a dismissed composer still created a worktree; the hoist that closed it was unpinned. Quick creation already gated immediately before runBackgroundWorktreeCreation, so its inline await is safe — pin that too, since nothing asserted it. Both tests fail against origin/main (no probe) and the full-creation one fails against the pre-hoist revision. * fix(native-chat): close the folder-create cancel window the probe opened The probe added the first `await` inside `submitFolderWorkspaceCreate`. On `main` that function ran straight through to `createFolderWorkspace` with no suspension of its own, so its caller's `isSubmissionCancelled()` gate and the create call sat in the same turn. With the probe inline, a composer dismissed while the probe is in flight still creates the folder workspace and launches an agent — the same defect the full-creation hoist fixed on the git path. Resolve capabilities in `folder-submit-orchestration` above its existing gate and hand them down, so the create path's prefix is synchronous again. The parameter stays optional: a caller without a cancel gate keeps the probe. Both new tests fail against `origin/main` and against this branch's previous head; the cancel-window one still fails with its probe-pending assertion removed, so it pins the create, not just the probe. * refactor(native-chat): require pre-resolved capabilities on the folder create path The cancel-window fix in f492064 left its invariant -- a caller that gates on cancellation must resolve capabilities above its gate -- enforced only by a comment, because `hostCapabilities` stayed optional with an inline probe as the fallback. A future caller that owns a cancel gate and forgets the parameter would silently reopen the window twice fixed already, and nothing would catch it: the caller census test pins `resolveAgentLaunchRoute` callers, not this function's, and `exactOptionalPropertyTypes` is off so even an explicit `undefined` is legal. Make it required and drop the now-unreachable inline probe. The sole production caller already passes it, so runtime behaviour is unchanged: the old ternary never evaluated its `await` when a value was supplied. `null` keeps its meaning -- probed, genuinely unknown -- and still degrades to the legacy route; only absence becomes impossible. The launch-route test that covered the removed probe is replaced by one pinning that `null` contract with the cache and the bridge both holding the structured capability, so only the handed-in value can produce the legacy outcome. The cases in the sibling suite are not about the route, so they go through one typed wrapper that supplies the unknown answer rather than repeating it 21 times. --------- Co-authored-by: Merge Sim <sim@local>
…workspace goes (stablyai#19970) * fix(worktrees): retire the chat tab of a chat with no child when its workspace goes Deleting a workspace left a chat tab behind for every structured session that had no attached provider child at the time, and that tab came back at the next launch pointing at a workspace that no longer exists. A provider child is scoped to a VISIBLE pane, not to a tab: the hold that keeps one is `enabled: isVisible && isWorktreeActive`, and dropping the last hold evicts the child after the release grace. So the sweep's liveness predicate selected only "the chat that is the visible pane in the active workspace, or was moments ago" — which means deleting a workspace from the sidebar while a different one is active left every chat in the target invisible to the sweep, and the `live.length === 0` early return did nothing at all. The durable reference is `visibleSessionIds` in the agent-session record store. Both purges a removal already performs miss it: the renderer drops `unifiedTabsByWorktree` and the main process drops the workspace metadata, and neither touches that index. Startup replays it, restores the session from it and republishes the tab. Worktree ids are path-derived, so a later workspace created at the same path inherits the old chat. Splits the two concerns the sweep conflated in one list. Liveness still decides what to CLOSE and what to refuse over, unchanged. Membership — the same fenced record filter minus the liveness clause — decides what to RETIRE, and covers exactly the complement of the close list so each session's tab is handled once. Retirement runs from `killAllProcessesForWorktree`, past every point that can refuse, not from the structured sweep itself: that sweep is joined BEFORE the unstopped-PTY verdict so a structured refusal can outrank a terminal one, and a tab retired there would still be ahead of a gate that can refuse the whole removal — leaving the workspace in place with its chats gone. * fix(worktrees): retire tabs across all teardown outcomes * test(worktrees): type teardown fixtures --------- Co-authored-by: Merge Sim <sim@local>
…tablyai#20277) * feat(ai-vault-search): define public contract and service seam * feat(ai-vault-search): add IPC runtime relay and web transports * fix(ai-vault-search): register search IPC at the core handler site ai-vault.ts was two lines over the 300-line max-lines limit; the search handlers belong with the other register*Handlers calls anyway. * fix(ai-vault-search): withhold degraded-root paths from relay status Status carried local filesystem paths over the relay while hits redact theirs. redactStatusForTransport applies the same policy at the same boundary: relay callers keep each root's reason and the array length as the count, so the type only makes root optional. * fix(ai-vault-search): close diagnostic path leak and remove test casts * feat(ai-vault-search): carry an execution host id and per-host outcomes on hits * feat(ai-vault-search): route desktop search by execution host scope, including runtimes * feat(preload): accept an execution host scope on session search * feat(web): answer only for the paired runtime on session search * docs(ai-vault-search): describe execution-host routing and the all-hosts merge * test(ai-vault-search): cover every host scope, the all-hosts merge and wire compat * fix(ai-vault-search): resume every host mid-page so a merged page never drops a hit * fix(ai-vault-search): decode the merged cursor with a schema instead of casts CI's type-aware audit refuses type assertions; a zod record validates the per-host entries and yields the typed map without one. * refactor(ai-vault-search): defer cross-host merged search
) * refactor(mobile): migrate settings reads to RpcOperation Replay the settings slice on the landed RPC foundation after rebasing onto main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh task parity snapshots after main rebase Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct rebased declaration parity hash Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): account for main task declaration Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): preserve raw RPC rejection timing Return the transport promise directly and interpret replies separately so sibling Promise.all rejection order cannot change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): refresh parity hashes after timing fix Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): use operation interpreter after raw request * test(mobile): refresh settings migration parity hashes Refresh hook and statement parity hashes for the two task declarations whose settings reads now use RpcOperation request and interpretation. Changed declarations: - useMobileTasksRuntimeHydration: settings.get replaced by settingsRead request/interpret. - useMobileTasksWorkspaceCreateActions: settings.get response handling replaced by settingsRead request/interpret. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…s jumping (stablyai#20493) * fix(mobile): stabilize native chat tail following * refactor(mobile): give native chat one tail-follow owner Extract the streaming scroll contract into use-mobile-native-chat-tail-follow, so intent and geometry have a single writer instead of a state/ref pair hand-synced at five call sites. No behaviour change: the existing guards pass untouched. * fix(mobile): fence native chat tail follow through momentum * fix(mobile): repin chat at measured tail --------- Co-authored-by: Merge Sim <sim@local>
…ablyai#20280) * fix(mobile): preserve delivery ambiguity across transport cutover Let physical close settle requests and retain its error as the cutover cause, copying only an existing delivery-unknown mark. Pin sent and unsent caller outcomes and both cutover predicate carriers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): pin the RpcClient.close() settlement contract close() was declared `() => void` with no stated obligation. That was harmless while migrateTo rejected pendings itself; now that it does not, close() is the retiring generation's only settlement path, so a type-compatible implementation that leaves a request pending strands its caller for good. States the obligation on the declaration and pins it for both trackers the real implementations reject through. Dropping the delivery-unknown flag, dropping the relay mark, or leaving pendings in the map each fail a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the cutover cause without a type assertion main's new casting gate rejects `(error as Error).cause`; narrow instead so the assertion still distinguishes a missing cause from an unmarked one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Require padded file-write payloads to end on a Base64 quartet boundary. Cover both RPC methods and padded final upload chunks, and document client compatibility evidence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…ablyai#20460) * fix(tasks): preserve repository results under GitHub search quota * fix(github): preserve search budget on count fallback --------- Co-authored-by: m4air <m4air@Mac.localdomain>
…20455) * fix(ai-vault): stream oversized remote session transcripts * fix(build): bundle streamed JSON parser in desktop main --------- Co-authored-by: m4air <m4air@Mac.localdomain>
…Claude (stablyai#20507) * fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude Grok's hook discovery reads ~/.claude/settings.json (and the Cursor equivalent) for vendor compatibility, and that is on by default. So inside every Grok pane Orca's managed Claude hook fires in addition to Orca's managed Grok hook, and both POST the same Grok envelope. The Claude-routed copy lands last and wins, so the pane's agent type is resolved from the POST route as "claude" and no Grok-specific normalization runs for it. Guard the managed Claude and Cursor scripts on GROK_HOOK_EVENT, which Grok's hook runner stamps into every hook subprocess it spawns — including replayed vendor configs — after any user-supplied environment, so a hook cannot spoof it. This mirrors the existing DEVIN_PROJECT_DIR guard in the same script, which solves the identical problem for another agent that imports Claude hooks. Placement is load-bearing: the guard sits after the stdin capture, so Grok's writer never blocks, and before both the spool write and the HTTP POST, so a replayed event cannot leave a spool entry that replays later. The Windows variants jump to the stdin-drain label rather than exiting, because abandoning stdin there hangs the writer. The guard is scoped to agent === 'claude'; OpenClaude reuses ClaudeHookService with its own settings file, which Grok does not replay, so it is unaffected. Verified live against Grok 1.0.25 in a dev instance: the pane's reported agent type goes from "claude" to "grok" on every turn-end, including the hidden follow-up turns Grok runs when background work finishes. The guard pushed hook-service.ts past the 300-line cap, so the script builder moves to a sibling hook-script.ts. That mirrors the existing split under src/main/cursor/, where the service owns install/status and the script module owns script text. * fix(agent-hooks): preserve Windows background worker stdin contract
… a subprocess per keystroke (stablyai#15310) Typing in the remote browser pane spawned an agent-browser process per keystroke -- ~160ms each, so a 17-character password took seconds -- and some keys arrived half-formed: F-keys, Insert and ContextMenu dispatched windowsVirtualKeyCode 0, Shift+1 typed '1' instead of '!', and non-ASCII printables reported success while typing nothing at all. keypress now resolves the key name through a US-layout table and dispatches the Input.dispatchKeyEvent pair over the electron debugger, the same transport mouseClick already uses. Two fallbacks keep the old behavior reachable: - a single printable BMP character outside the table dispatches in process as an IME-style event (keyCode 229 with the character as text, the shape composed input already has when it reaches pages) - anything else -- media keys, surrogate pairs, unrecognized names -- goes to the helper exactly as before, and only that path pays for creating the helper session Virtual key codes come from the table, never from the character's own char code: charCodeAt puts '&' on 38 (VK_UP) and '.' on 46 (VK_DELETE), which Blink runs as caret commands that swallow the character. Dispatch failures normalize the way evaluate's already do -- a gone page becomes browser_tab_not_found, anything else browser_error -- because attach and sendCommand reject with plain Errors that the RPC layer would report as runtime_error, and the pane only reclaims a dead page when it sees a browser_* code. Result shape is unchanged and no wire, schema or RPC surface moves, so mixed-version client/server pairs see no difference. Pages can observe the fidelity fixes: Shift+a now types 'A', Shift+1 now types '!', Alt+<char> no longer carries text, and editing keys arrive as rawKeyDown. Each matches what a real US keyboard produces. Verified against the shipped agent-browser 0.27 binary on the same browser: every difference is a fix, nothing regressed. macOS editing shortcuts (Cmd+A) do not fire through either path -- Blink runs those off the native responder chain and neither sends CDP `commands` -- so that gap is unchanged, not introduced. Co-authored-by: Neil <neil@stably.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…tablyai#20284) * fix(rpc): validate task provider identity fields Validate provider-specific field types while preserving nullable scopes and unknown identity fields. Record the producer census and pin validation with regression tests. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(rpc): reject a blank GitHub owner or repo normalizeTaskProviderIdentity treats a blank owner or repo as no identity at all, but the schema accepted '' and whitespace-only, so the two disagreed about the same payload. Refined rather than trimmed: trimming would rewrite the parsed value and change what the handler receives. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): re-measure the identity evidence counts The blank-field commit added seven tests, so the recorded 74/16/58 described the commit before it. Re-ran both: 81 tests, and the discriminant-only mutation now gives 17 failures / 64 passes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): correct the remaining stale gate count The blank-field commit moved the full-RPC total too; 2,463 was the count before it. Re-ran: 278 files, 2,470 passed, one skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…#20463) * perf(terminal): batch file-link existence checks on their owning host * test(relay): allow additive filesystem capabilities * fix(web): keep terminal file links working under batched existence checks createShellApi omitted pathsExist, so withFallback answered the new batch call with a truthy proxy resolving to undefined and the whole hover batch rejected — dropping every link on lines with an out-of-worktree path. * test(web): assert the shim without type assertions --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local> Co-authored-by: Neil <neil@stably.ai>
* feat(native-chat): support workspace file drops * fix(native-chat): report OS file drops that attach nothing stablyai#15782 is a silent failure on the Finder route, and that route still swallowed every way it could fail: - the preload handler returned with no feedback when the OS handed us file items `webUtils.getPathForFile` could read no path from (promised or virtual files). It now sends the existing `rejected` payload with a new `unresolved-paths` reason, which the global drop toast names. - the composer's external-attach path dropped the batch with no notice when every path failed authorization, when an upload came back empty, and (new in this branch) when the owner changed mid-flight. Each exit now sets a notice; only a disabled composer stays quiet, because it has no notice surface. Also stops `resolveNativeChatAttachmentOwnerForWorktree` throwing out of a drop/IME handler when an SSH connection's generation is gone mid-attach — that is an unknown owner, which the resolver already models as `not-ready`. * refactor(native-chat): one owner-identity check for composer attachments The branch had two near-identical "is this still the same owner" helpers, one per attach route, and they disagreed: the workspace-drop copy ignored the SSH connection generation, so a reconnect between the drop and the IME flush read as the same owner and the path landed on a new connection. Collapses both onto one predicate in the pure ownership module (the store/toast-free seam both routes already depend on), which compares the full SSH expectation and never treats `not-ready` as a match. * perf(file-explorer): resolve drag ownership at dragstart, not per render The virtualized row list resolved the selection's source execution host on every render — the virtualizer re-renders on every scroll frame, so a large multi-selection paid a full projection scan plus a route allocation per selected path per frame, and per visible row on top of that. Only `onDragStart` ever read the result. Rows now receive a resolver they call with the paths they are about to drag. The three copies of the "stamp only if both halves resolve" guard (explorer row, both combined-diff row shapes) collapse into one helper next to the writer. * fix(native-chat): refuse a guarded composer drop visibly The drop handlers claimed the drag (preventDefault + stopPropagation) before checking `disabled`, so a guarded composer told the browser it accepted the drop, left the copy cursor up, and then did nothing — the same silent swallow this branch exists to remove. Dragover now answers `none` when the composer is guarded, so the cursor refuses and no drop event follows. It still claims the event either way: the composer sits inside the terminal surface, which accepts the same drag and would paste the paths into the shell instead. Drops `stopImmediatePropagation`. The capture-phase `stopPropagation` already keeps the event off the editor below, so the stronger form only risked suppressing unrelated listeners on the React root. The fake DataTransfer in the test now starts at a dropEffect we never write, so asserting `none` or `copy` proves the handler set it. * fix(native-chat): decide attachment ownership per path, not per batch A queued batch can mix sources — a workspace drop the target host owns and a client-local paste it cannot read — because IME composition holds both until it settles. Collapsing the batch to one verdict refused the whole thing on a remote target, including the drop the user was entitled to make. The verdict now follows the path it belongs to: owned paths attach, client-local ones are refused, and the refusal is reported rather than dropped. A stale owner still refuses everything, since that means the target moved under all of them. Also guards the empty-batch case, which previously read as "every path owned". * refactor(combined-diff): resolve drag ownership from the live workspace The combined diff captured an execution host into the open-file record at tab open and drilled it through three components to reach the row. That host was never persisted, so after a restart every drag from a restored diff was refused until the tab was reopened, and the capture failure was swallowed into an undefined source with no trace. Rows now resolve the owner the same way the source-control rows already do, from the workspace the diff belongs to at the moment of the drag. That deletes the prop drilling, the store capture and its bare catch, and leaves one way to answer "who owns these paths" for every live listing. The file explorer keeps its per-node owner: its tree is a cache that can still be showing a previous host's listing, which is exactly what that field records. * revert(file-explorer): drop the workspace-id tree reset Resetting and reloading the tree when the workspace id changes at an unchanged path is not needed for the drag source to be correct. The tree already records the workspace whose root listing it committed, so a cache left over from a previous workspace stamps that workspace and the composer refuses the drop — the intended answer, reached without touching the reset rule. That rule clears selection, the name filter and undo history, which is more file-explorer behaviour change than this feature asked for. * test(native-chat): stop the external-attach mock hiding new notices The hook's test replaced the whole attachment-owner module with a hand-written stub, so the two notices added alongside the owner-change guards resolved to undefined. Calling them threw inside the async attach loop — an unhandled rejection, which leaves every test in the file reported as passing while the run as a whole fails. CI caught it; a local run reporting only pass/fail counts does not. The mock now spreads the real module, so a notice added later cannot go missing from it, and both owner-change tests assert the string a user would read instead of only asserting that nothing attached. * test(native-chat): guard the last-path owner change on a one-file drop The owner flipping while the final path is authorizing has no next loop iteration to catch it, so the post-loop check is all that stands between a single-file drop and a path attached to a host that no longer owns it — and a one-file drop is the ordinary shape. No test covered that exit. Removing the post-loop check now turns this red; before it, only the multi-path exit was guarded. * fix(native-chat): keep a mixed attachment batch in attach order applyResolvedPaths partitioned a queued batch into a target-owned half and a client-local half and concatenated them. An IME-delayed batch that mixed a workspace drop with a paste made earlier in the same composition was therefore inserted owned-first, so the dropped reference jumped ahead of the pasted one in the draft. Filter against the two verdicts in place instead. Membership is unchanged, the order the user attached in survives, and the two intermediate arrays go away. * fix(file-explorer): name the owner of a dragged path whose row is hidden A multi-selection outlives the rows that showed it. Nothing prunes selectedPaths when a directory collapses, when the name filter narrows, or when dotfiles are hidden, and the drag still carries every selected path. Drag-source resolution read those owners from the row projection, which is built from visible rows only, so one hidden path collapsed the whole drag to an unstamped one and the composer refused it as coming from another workspace. The owner was never unknowable — the dir cache the projection is built from still records which host listed that path. Fall back to it when the path has no visible row. A path in neither (a name-filter synthetic node for a directory that was never listed) still fails closed. * fix(native-chat): ask which workspace the composer serves now The IME-flush ownership check compared the workspace id captured when the drop happened against the same captured value, so for a structured pane the comparison could only ever hold. The live protection came from the host and owner checks beside it; this one asked nothing. Read the id through a ref so the check means what it reads as. A pane whose structured target moves between the drop and the composition settling now refuses the queued path instead of attaching it. * fix(native-chat): ask which workspace an external attach lands on The post-await ownership gate resolved the owner through the render closure, so it re-asked the workspace the attach started in and compared the answer with itself. A tab moved to another workspace mid-authorization passed the gate, and the paths landed in a composer that no longer served that workspace. Read the pane through a ref and compare the workspace identity as well as the owner: two workspaces can both report a local owner, so the owner alone cannot tell them apart. * test(native-chat): read the real notice on a workspace drop The drop tests hand-built their attachment-upload mock and hand-copied the not-ready wording into it, so the assertion tracked the copy rather than the string a user reads: rewording the real notice left all 15 tests green. Spread the real module and override only the owner resolver, matching the two sibling test files in this directory. Rewording the notice now fails the test. * docs(native-chat): restore the hook's doc comment to the hook The workspace comparison landed between the doc block and the function it describes, leaving the comment attached to a type alias. * test(native-chat): cover the upload window for a moved pane The workspace-currency gate guards two windows and only the authorize loop was covered. The upload window is the longer one: the paths go to the worktree the attach captured, so a pane that moved workspaces meanwhile must not receive remote paths living under the workspace it left. * test(native-chat): pin the two untested attachment refusals Refusing an already-blocked target at the drop rather than queueing it had no test: queued paths that can never attach still spend the pending budget, and the next legitimate drop is then turned away for being one too many. Also pins the immediate already-false ownership verdict. Today's only caller settles ownership synchronously so it cannot arrive false, but the hook exports this entry point and the fallback is not a refusal — a false verdict is not "owned", so a remote target blames client-local attachments for an ownership failure. Verified: removing the branch reports the wrong notice. * docs(native-chat): say which rule the ownership refusal follows The per-path comment sat directly above the batch-wide ownership refusal while describing the blocked-target logic below it, so the refusal read as a contradiction of the line under it rather than as the file's stated rule. Name the rule at the refusal: a failed ownership verdict refuses the whole completion, the same way the pending-limit rejection does. --------- Co-authored-by: Merge Sim <sim@local>
…ture (stablyai#20521) * test(mobile): record main RPC hooks and regression schedules Add scripted sender recordings, guarded main goldens, reply matrices, lifecycle schedules, settings caller fixtures, and targeted B-seed mutants. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): flush recording user actions through React act Keep lifecycle updates in separate act boundaries while wrapping direct stateful user actions. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): compile recorded modules with the Node VM API Use the same trusted-source execution boundary as existing mobile VM test harnesses. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pool golden values and hoist pre-divergence checkpoints Golden format version 2 stores each distinct observation field value once in a `values` map keyed by a 12-hex sha256 of its sorted-key JSON, and a checkpoint references five hashes. Output stays pretty-printed; the reader rejects any other format version, resolves hashes back to values, and reports the scenario, checkpoint, field and JSON path on a mismatch. Generated variants now declare where their distinguishing input lands, so checkpoints observed before that point are recorded once in a `.prelude` scenario instead of once per reply partition. Reply matrices, interruption schedules and lifecycle schedules share the primitive, which asserts each variant's pre-divergence prefix matches the base. Equal-but-differently-reached checkpoints are untouched. 17.71 MB / 3,599 checkpoints / 58.4% intra-file duplicates becomes 4.21 MB / 1,961 checkpoints / 23.6%, with every file's set of distinct observations unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the recordings sense deadlines, the recorder, and every family The goldens carried no temporal information, so a request deadline could be cut to a third and all 61 files stayed byte-identical. Every threshold is now straddled by two advances with a checkpoint between them: the 30 s request deadline in both schedule drivers, the 120 ms search debounce in b1, and the 60 s repo-metadata cache TTL. Shortening any of them moves an observation. The record fence pinned product sources but excluded the whole recorder, so --record could rewrite every golden from a modified runner and report the baseline intact. Goldens now pin recorderSha256 over every non-markdown file in the runner plus pilot-scenarios.json, and the fence exemption shrinks to the one directory that digest covers. Mutation evidence covered 3 of 13 mounted operations. There is now one anchored mutant per adapter family, covering 11 operations and 51 of the 61 goldens; the two omitted are the pure async loaders whose entire output is their settlement. Anchors are asserted to match exactly one site, which caught the acceptance mutant silently half-applying against three identical guards. The archived-tree assertion pins each seed's visible state instead of merely differing from main, and error observations carry code and cause when present. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record settlement times instead of straddling deadlines The previous commit made the reviewer's divide-by-three deadline mutant fail by placing checkpoints on each side of the 30 s deadline. That is a patch: a timing change that does not cross a hand-placed boundary stays invisible. Those scenario edits are reverted, and pilot-scenarios.json and schedule-driver.ts are byte-identical to what they were before them. The real defect was that the projection had no temporal dimension, so every settlement now carries startedAt and settledAt in virtual milliseconds on the pinned fake clock. Any transition the product schedules for itself is recorded at the time it actually fires, so a deadline or debounce change of any size, in either direction, moves a recorded number. A checkpoint's own clock is not recorded. It is always the sum of the scripted advances, so it is a function of the scenario rather than of the code under test; run-recording.ts asserts that equality at every checkpoint instead, which costs no bytes and fails loudly if it ever drifts. projectionVersion is 2 and all 61 goldens are re-recorded. With the added timestamps stripped, the distinct-observation set is identical to the previous recording, so the change is purely additive. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): probe the repo-metadata cache inside its TTL window Recorded settlement times cover thresholds the product schedules for itself, but not one it only consults when something else makes it act. The repo-metadata TTL is the single such case: with probes only at 0 s and 60 s, a 20 s TTL and a 60 s TTL are both expired at 60 s and record identically, so a 3x cache-lifetime regression was invisible. settings-repo-cache-expiry now probes the cache at 59 s as well. This is coverage, not a substitute for recorded time: it bounds how small a TTL reduction is visible rather than making the reduction itself observable, and the README says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the reply shapes a host can send, not a cross product The reply matrix froze ~26 malformed envelopes crossed against every consumed field and three boundary kinds, which is 163,925 lines of JSON pinning accidents on inputs no desktop produces. `successResponse` always sets `result`, so a JSON wire has no explicit-undefined slot, and no mounted handler returns a number, a string, an array, a bare `{}` or a boolean: `settings.get` returns `{settings: ...}`, and the seed methods return an object or nothing. Each family now runs nine witnessed partitions once, with no field cross: a normal result, an absent result, `null`, an inner `{ok: false}` envelope with a string or an object error, an inner envelope missing `ok`, an outer refusal, `method_not_found`, and a transport rejection. `null` stays because `linear.getIssue` returns it for a missing issue and b2 is a shipped null-result bug; it is also what carries the one named delta these goldens record. `run-step1-exit.ts` had zero callers and shelled out to the same two Vitest files as `rpc-recording.mts`, so it and its README paragraph go, along with `MUTATION_NAMES`, which only it read. In the module loader, the `rpc-delivery-ambiguity` escape is measured dead: over every scenario, mutant and reference run it was taken once, by the test that existed to take it. Golden comparison already fails loudly if a mounted module ever imports the marker, so both go. The history-panel exposure moves into a declarative table beside the mutation anchors, leaving the loader with one source-text mechanism and no per-file branch. The VM stays. Mount adapters load product sources from an arbitrary `root`, and the archived bcba08b tree is bare `mobile/src` and `src` with no package.json and no node_modules, so no bundler-resolved import can reach it and the seed rejection gate cannot run without it. Direct import also swaps a 38-module lazy graph for a 338-module eager one behind 20 native mocks, because `mobile-tasks-dependencies.ts` re-exports from `react-native` and four other native packages and `export *` enumerates. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): treat the recorded lockfile hash as provenance, not an oracle Every golden pinned `lockfileSha256`, so any dependency bump on main failed all 61 comparisons on the merge commit while the traces were identical. A dependency that changes behaviour changes the trace itself; one that does not must not fail a candidate. `platform` already had this exemption — `lockfileSha256` joins it. Recording still refuses to run unless the lockfile matches the pinned baseline, so goldens are still produced under frozen conditions. Verified against main's lockfile: 85 passed, previously 61 failed. The declared mutation set still reports every mutant killed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(rpc): cover repeat queries and settings refresh boundaries Add three scenarios, preserve existing traces, remove unreachable archived checks, and document observed mutation kills and remaining adapter limitations. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): keep the known-open holes, drop the review transcript The audit file was mostly a point-in-time record of mutation runs that had already happened, in an artifacts directory, where it would go stale on the next scenario change. The durable part is which holes are still open and why they cannot be reached, which belongs beside the runner it describes. Markdown is outside recorderSha256, so no re-record; 88 passed | 3 skipped. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): stage the nine RPC probe scenarios and goldens These existed only on one machine's /tmp. Landing them verbatim first so a reboot cannot lose them; a follow-up commit moves them into the suite. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fold the nine probe scenarios into the recording oracle The probes were env-var invocations over loose /tmp manifests. They now live in pilot-scenarios.json and rpc-foundation/goldens, so `pnpm --dir mobile test` runs them with no flag to remember. Re-records every golden against main (22f56f7). Two causes: - stablyai#20280 gave LogicalClientCutoverError the delivery-unknown mark and its cause, so nine cutover/interruption goldens now record `isRpcDeliveryUnknown: true` plus a `Connection closed` cause. The other 55 are byte-identical after 260 commits of main. - stablyai#20499 replaced the five anchored raw-envelope reads with typed operations, so those mutation anchors matched zero sites. Each is re-anchored at the same defect's new home; bot-overrides moves to the shared reader that now owns it. probe-hole-witness.test.ts pins hole and closure together: a probe must kill its mutation and every pre-probe scenario of the same operation must still survive it, so a redundant probe fails instead of accumulating. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): list the recording harness in the raw-port inventory main's stablyai#20026 boundary test fails on any non-test file that reaches the raw request port and is not inventoried. The oracle's scripted transport is exactly that — it drives the real tracker and logical client — so it belongs in OWNERS beside the supervisor fakes, not in the step-4 pending backlog. Also states what the oracle covers, the two holes it was blind to until the probes, and the step-4 runbook. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the goldens to the tree that recorded them The inventory entry is a fenced product-tree edit, so --record refused against main's sha. Baseline now names the branch commit the goldens were recorded from; the next re-record after this lands bumps it to the merge commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): carry a SAFETY rationale on every recorder cast main added a changed-code casting gate after this branch was cut, so 45 `as` sites in the recorder read as new findings. Each now states why the assertion holds; they cluster into five reasons — recorded observations are RecordedValue by construction, parsed manifests and goldens are validated on the next lines, interned pools resolve their own hashes, a VM-evaluated module has no static type, and the mount adapters supply only the members each hook reads. Re-records the goldens: the comments move recorderSha256. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(rpc): state the measured blindness, not the assumed one Applying each mutation to real product source shows the two holes are not equal. The reorder is invisible to 83 of 84 tests and only a probe sees it. The refusal blanking is also caught by the family reply matrix, because a refusal from cold publishes null over a non-null initial value — an observational gap, not a detection gap. Says so rather than letting the stronger claim carry both. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close three ways the oracle could pass without checking All four review findings were real; three let the oracle report green while verifying less than it claimed. - The baseline guard used `git diff --quiet`, which ignores untracked files, so an untracked module under mobile/src or src/shared could change resolution while a golden still recorded a pinned baseline header. Adds a `git ls-files --others` check over the same paths, recorder still exempt. - The determinism loop read `Number(env ?? 2)` unvalidated, so RPC_FOUNDATION_DETERMINISM_RUNS=0 skipped the body and 57 tests passed having recorded and compared nothing. Now requires an integer >= 2. - Cleanup-time observations were dropped: every checkpoint clones the effects array, so anything appended during dispose or the final flush never reached a golden. Warns and documents the six scenarios that hit it today; recording them changes every golden and is its own change. - Two SAFETY rationales described each other's assertion. Swapped. Goldens re-recorded for the recorder-digest change: 73 files, one header line each, no recorded observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record teardown observations as a cleanup checkpoint Each checkpoint clones the effects array, so a rejection or state write produced by dispose, the transport teardown or the final flush landed after the recording was built and never reached a golden. An unmount leak is exactly what this oracle exists to catch, so teardown now runs on the recorded path and anything it observes becomes a checkpoint with id `cleanup`. State is captured before dispose, since the operation is gone afterwards. Six scenarios were dropping observations, across five goldens: projectRowDetailError, projectMutating, hostLabelById, hostPlatform, workspaceAgent, workspaceAgentOverridden, creatingKey, selectedAgent, agentOverridden and error. Those five gain a cleanup checkpoint; the other 68 goldens change by their header line only, so no existing observation moved. Also fixes the README's own formatting, which failed `format:check`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…anes (stablyai#20525) * refactor(attention): move the agent attention boundary off terminal panes The completion-attention pipeline asked PTY questions inline, so a non-terminal agent surface was structurally excluded from unread, delivery and acknowledgement. Extract a provider-neutral policy under `src/renderer/src/attention/` that reaches every surface fact through an adapter, and move the PTY-shaped predicates (`hasLivePtyForNotification`, `isCurrentLivePaneKey`, `isCurrentKnownPaneKey`, `isVisibleForegroundPaneKey`, plus the leaf/foreground resolution the auto-ack scan did inline) into `terminal-attention-surface.ts`, now their only call site. Unread markers carry the reason that wrote them (`agent-completion`, `terminal-bell`, `manual-mark-unread`, `legacy`); an unclassified boolean still on live state reads as `legacy` rather than being guessed or migrated. Main's `notifications:dispatch` closure becomes a delivery service with injected collaborators; the IPC handler is a thin adapter. Behaviour is unchanged: unread is still written before main's desktop gate, the tray dot before the cooldown/focus gates, and mobile fan-out before the desktop early returns. * fix(attention): recognize classified tab unread markers
…#20535) * fix(auth): distinguish failed sign-ins from user cancellation * test(mobile): fix conditional registration lint and refresh recorder fingerprints
* perf(skills): bound WSL installed skill discovery * fix(skills): preserve bounded discovery correctness * fix(skills): bound WSL metadata prefilter reads * fix(skills): isolate absent discovery cwd cache keys * test(skills): adapt WSL discovery mocks to runner * fix(skills): preserve filtered discovery fallbacks * fix(skills): share filtered scans and preserve WSL inventory * fix(skills): share WSL scans without losing skill aliases * fix: preserve skill metadata and retire filtered peer caches --------- Co-authored-by: m4air <m4air@Mac.localdomain>
…lyai#18455) * fix(git): skip upstream remote probes when the remote is absent Issue and PR resolvers listed remotes by probing `git remote get-url upstream` on every poll, including origin-only clones where that remote cannot exist. List remotes once, cache against git config, and skip the probe unless `upstream` is present. * fix(git): avoid stale remote probe cache entries * fix(github): observe origin repository probe failures * fix(github): observe verified origin probe failures * fix(github): skip missing upstream probe for PR lists * test(github): scope the stablyai#9171 lazy-resolution guard to default-branch commands The guard asserted that no git command runs for an open PR, using "no git at all" as a proxy for "no default-branch resolution". Remote-name listing is a separate concern, so allow it and keep every other command forbidden; the symbolic-ref/rev-parse resolution this issue is about stays unreachable. --------- Co-authored-by: Neil <neil@stably.ai>
…tablyai#19837) * fix(orchestration): retire read deliveries and clarify mailbox recovery * fix(orchestration): simplify delivery recovery and update nudge contracts * test: align orchestration check help expectation * refactor(orchestration): derive delivery eligibility from messages * fix(orchestration): validate live consumers and simplify batch revocation * refactor(orchestration): keep deliveries.status and derive eligibility without a column drop The outstanding_deliveries view now reads status = 'outstanding' plus unread membership, so v41 only drops uniqueness from idx_deliveries_one_outstanding and adds the view and trigger. Older binaries can still open the database. Removes the column-drop migration, the v40 test fixture and hasColumn guards, the fenced skew probe, and the unrelated nudge-text change. * docs(orchestration): drop delivery storage reference The compatibility caveat it existed to explain no longer applies; the view and index comments carry the remaining rationale. * docs: revert unrelated formatter churn * test(orchestration): verify historical database downgrade round trip
* fix ssh relay incumbent probe timeout * test ssh relay unconfirmed probe termination * fix(ssh): preserve connect evidence while bounding lsof * fix: preserve uncertainty when relay holder enumeration fails * fix(ssh): supervise lsof helpers and preserve partial holder evidence * test(ssh): prevent GC racing unconfirmed probe cleanup * refactor(process): keep POSIX lsof supervision in process owner * fix(ssh): confirm census cleanup and handle probe startup signals * fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts lsof warns to stderr about mounts it cannot stat, and any stderr byte forced the holder enumeration to 'unavailable' — making the 'exited' verdict, and so husk reaping, unreachable on those hosts. Pass -w to suppress the warnings. * Revert "fix(ssh): keep lsof holder evidence usable on hosts with unstat-able mounts" This reverts commit 10d7b0d. Passing -w is correct in isolation, but it activates a previously dormant path: in the SSH docker e2e lsof warns about the container filesystem, so every probe used to degrade to 'unavailable'. Suppressing the warning lets holder enumeration succeed and the takeover/reaping path run for the first time there, and 'e2e / ssh docker watcher isolation' then hung to the job timeout (52m, vs 28m passing on the parent commit). The underlying gap is real and still open: on hosts where lsof always warns, the 'exited' verdict and husk reaping stay unreachable. Fixing it needs the reaping path understood in that environment, not just the flag. --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Neil <neil@stably.ai>
…tablyai#14555) * perf: avoid repeated remote import capability probes * test: cover cold remote import compatibility probe * fix(remote): fence imports across runtime reconnects * fix(remote): bind import proof to connection * fix(remote): fence import routing by runtime identity * test(remote): remove unsafe import fixture assertions - type remote RPC mocks at declaration so call arguments stay checked - narrow upload params before reusing generated temp paths --------- Co-authored-by: Neil <neil@stably.ai>
…stablyai#20523) * fix(grok): announce a completion once, when Grok is actually finished Orca pinged on every Grok turn-end. Grok runs turns the user never asked for: when a background task finishes it wakes itself, does a little work, and ends another turn. One request produced several pings. Grok already reports, on every turn-end, whether it still has work outstanding. Read that instead of trying to classify which turns are "real": backgroundTasks absent -> silent, this is the session-end tail StopFailure / StopCancelled -> announce, a failure is never hidden stopHookActive -> silent, a Stop hook is keeping it working a shell task or subagent running -> silent, the work is not done otherwise -> announce Nothing here knows what an auto-wake turn is. A turn that ends with work outstanding stays quiet; the later turn where that work is finally done is the one that announces. That is also why this survives the case where Grok completes a user's goal inside one of those turns — prefix-based suppression would have silenced it. Monitors and scheduled entries are deliberately not counted as outstanding work. They can run indefinitely, so counting them would suppress a user's completion permanently, and a lost ping is worse than an extra one. Also registers StopCancelled, which Grok fires instead of Stop on a user interrupt, a declined permission, --max-turns, or a no-progress bail-out. Orca never subscribed to it, so those turns were reported as successes. Also removes a stale notification matcher that searched for prose the shipping binary never sends; the typed notification kind is matched instead, and neither idle_prompt nor task_complete is treated as a completion. Needs-input behaviour (permission prompts and ask_user_question waits) is unchanged and stays ungated by background work. * fix(grok): never hide a failed or cancelled turn behind the background-work gate The announce predicate checked field-absence before terminal outcome. Grok's StopFailure and StopCancelled payloads carry no background inventory at all, so the absent-field branch — added so the session-end tail stays silent — fired first and silenced every failure and every cancellation. That inverted the rule it was meant to serve. Before this series a cancelled turn at least surfaced as a (wrong) success; gated this way it surfaced as nothing. Terminal outcome is now checked first, so a failure or cancellation announces regardless of what other fields the payload happens to carry. The existing tests passed straight through the bug because they built failure payloads with a backgroundTasks field Grok never sends for those events. They now model the real payload shapes, verified against the provider's payload definitions and the captured envelopes. * fix(grok): settle completion from provider lifecycle state * fix(grok): fence stale turn ends without prompt ids
* feat(native-chat): add provider-aware fast mode * chore: drop unrelated formatter churn from the merge pnpm format reflowed pnpm-workspace.yaml quoting and a source-scan test that this PR does not otherwise touch. * fix(native-chat): review fixes for provider-aware fast mode Review pass over the Fast mode work. Claude reads its model catalog once per option write. The admit check, the effort guard and the Fast guard each took their own `list_models`, so a model write with Fast on paid two round trips for one list and let two guards answer from two different catalogs. The guards are now pure over a single read. Claude no longer refuses a Fast enable when the catalog identified nothing at all. An empty list is not evidence against a model -- the same rule the model admit-check already applies -- so a CLI that cannot answer would otherwise have Fast refused on every model. A catalog that did list the model and stayed silent about Fast is still not positive evidence and keeps refusing. Codex refuses a direct `serviceTier` write instead of accepting one the next turn discards. The turn derives the tier from `fastMode`; the key still restores so a session persisted before Fast existed migrates. Both option surfaces return a cached snapshot again. `SessionOptionsSurface` is read through `useSyncExternalStore`, whose contract is a stable snapshot, and rebuilding it per call breaks that for any consumer wired that way. Also records two decisions that were emergent rather than stated: routing Standard when Fast is on but no tier is named yet, and what a readback disagreement does and does not prove. Quality gate: merges the duplicate imports static analysis flagged, adds SAFETY rationales for two pre-existing casts the changed-code gate now sees, and drops a new assertion in favour of a checked narrowing. * fix(native-chat): read Claude Fast state from the session frame A fresh Claude session reports `fastModeState` while the settings readback still has no `fastMode` boolean, so the two are not redundant -- the frame answers at a moment the boolean has none. The picker fell back to "value unknown" and asked the user to disambiguate what the provider had already reported, and the state it reported had no reader at all. Falls back to the frame only when neither a pick nor the settings readback answers. `cooldown` throttles routing rather than clearing the pick, so it reads as on; reading it as off would flip a control nobody touched. Display only. The launch seed is untouched: an unset Fast preference still seeds nothing, which its own guard continues to pin. * perf(native-chat): skip the model catalog read when turning Fast off Turning Fast off needs no support evidence, so the read only cost a round trip — and restore replays a stored `false` on every acquire. Also narrows the alias-matcher comment: the effort and admit guards match on alias and resolved id only, so calling it the sole matcher overstated it. * fix(native-chat): clear a Claude Fast block once the child stops reporting it The child omits fast_mode_disabled_reason entirely when nothing blocks Fast and never sends a null, so requiring the key back latched the first reason for the session's life: switching to a model that disallows Fast and back retired the control for good, leaving a session running Fast with no way to turn it off. A frame that reports state without a reason is the all-clear. * test(native-chat): cover the mobile structured option hook useMobileStructuredAgentOptions gained generation fencing, a pending-write guard and a post-write options refresh with no test file. Pins the concurrency contract and the fast mode round trip: - a superseded options read is dropped instead of overwriting newer state - an overlapping write is refused and the pending guard is released after - an accepted same-fence write reads options back and applies the result, and a different-fence write does not - a boolean fastMode pick reaches the wire encoded and is remembered decoded - no Fast row when session support, catalog support or the model capability is missing Each behaviour was ablated against the production logic to confirm it fails without it. No production code changed. * feat(native-chat): render a boolean session option as one toggle On and Off were two radio rows under a header repeating the option name, so a binary choice cost three lines and two clicks to read. It is now a single switch row that owns its label, on desktop and mobile. An unknown value keeps its caption: a switch cannot say "unset". * fix(native-chat): resolve a boolean option's display value at the producer A boolean session option reached the UI in three states while its control had only two, so the renderer apologised for the gap with a "Current value unknown" caption beside a switch that had already collapsed to off. For `thinking`, whose catalog default is on, that caption sat next to a switch asserting the opposite of what every composed dispatch assumes. One expression fed both the displayed value and the option's provenance. Split them: the boolean descriptor now always carries a value, resolved to the same `values[id] ?? defaultValue` that buildNativeChatSessionOptionCommand already composes, while `valueSource` is untouched and still records whether anything confirmed it. `kind.currentValue` is required on the boolean arm so the third state cannot come back. The launch path is unaffected: resolveAgentSessionOptionLaunch and buildNativeChatSessionOptionCommand build the composed `--model` argument from the caller's picks and the catalog, never from a descriptor. Both surfaces mark an unconfirmed value instead of captioning it, and the two reasons stay distinct — `default` says the catalog value is what a launch will send, `unreported` says nothing has told us anything. Only `unreported` is reachable in the structured lane, where the agent may be routing a tier we have never been told about, so the two never share a label. * fix(native-chat): let assistive tech read the option value marker The marker was aria-hidden next to an explicit aria-label, so the label already won the accessible name and hiding it only cost screen reader users the default-vs-unreported distinction that sighted users get. It is now referenced by aria-describedby, which keeps the name Fast mode. Mobile's summary row said "Not set" for a boolean while the sheet behind it showed the switch on, so the two screens disagreed. A boolean always has a value; the summary states it and the sheet's marker qualifies it. * chore(i18n): drop the On/Off option strings the switch row retired Replacing the On/Off radio pair removed the only call sites for these two keys. i18next cannot rebuild a key with no call-site default, so leaving them in the catalogs forced them into the boot bundle as dead weight. Removing them shrinks it by two entries instead.
…rations (stablyai#20544) * test(mobile): record main's source-control RPC behaviour before migrating it 45 scenarios over 11 source-control senders, recorded from main so the step-4 migration has a frozen answer to compare against. Adapters mount the real exported senders as plain functions, so no React host or device is needed. The 73 existing goldens change header-only (`baseline`, `recorderSha256`): any new scenario re-digests the recorder, and the pinned baseline had drifted from main in `src/shared` so recording required bumping it. Content is byte-identical on all 73 — verified field-by-field against HEAD. Scenarios deliberately pin the empty-message cases (`sc-*-refused-empty-message`, `sc-*-rejected-empty-message`), because a refusal with no message falls back to the screen's copy while a transport error with no message does not, and the two paths are easy to collapse when a call site moves behind an acceptance policy. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the source-control domain through typed RpcOperations 13 of the domain's 14 files now send through a declared operation instead of the raw request port: 44 references to 0. The holdout is use-mobile-git-requests.ts, whose single reference is a `(method: string, params)` dispatcher that five other hooks feed `{ method, params }` action steps at runtime; typing it is a step model change, not a call-site move, so its line stays at 1. Fifteen operations over fourteen methods. Two of them read git.status, and that is deliberate: the Changes screen publishes the host payload verbatim while hosted-review preparation reads the normalized projection, which returns null when `entries` is not an array and drops entries missing a path. Sharing the projecting reader would change what the Changes list renders, so both are named. Four loads still read the refusal envelope before interpreting, through readMobileGitRefusal: two degrade to a capability-missing screen, one retries a selector that is not visible yet, and one falls back from files.openDiff to files.open. `isMobileGitUnavailable` consults the code *and* the message and no acceptance policy carries either through, so the alternative was parsing a code out of a message. No new acceptance policy was added. Every migrated site keeps two error paths where it had two: a refusal with no message falls back to the screen's copy, a transport rejection surfaces its own message verbatim and keeps its delivery-unknown mark. Collapsing them into one catch is what would have turned an unknown mutation into a failed one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-digest the goldens after a lint fix in the new adapter recorderSha256 only, all 118 files; every recorded observation is byte-identical. Re-recorded from c57de48 in a separate worktree so the goldens stay attributable to pre-migration product source — recording from this branch would have made the parity claim circular. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive the reply matrix over every scripted reply, and fail closed The matrix picked its driven request from a hardcoded prefix list and `continue`d past any family the list did not name. That was 10 of 23 families — every one the source-control migration added — with no red test to say so, which is why that migration's mutation evidence came down to single hand-written scenarios. `replyMatrixSites` now takes every completion step in a family's base scenario: 61 sites instead of 13, one golden per site, no judgement about which request is the "real" one and nothing to edit when a domain is added. A family that scripts no reply throws, a repeated request name throws, and a census test asserts every family in the manifest has a matrix. A variant's downstream replies are marked `optional` and answered only if the request is outstanding, so a diverged reply that ends the chain records the truth instead of failing on an unsent request. The `normal` partition replays the first fulfilled reply the family records for that request, rather than a payload the test file invented per family. Absent and null do not count — each is already a partition — so four sites with no other recorded success are inventoried in REPLY_MATRIX_NORMAL_RESULT_INVENTORY with a reason each, and an entry whose family later records a success fails. Two partitions added: a refusal and a transport rejection with no message. That is the axis that separates a refusal falling back to the screen's copy from a transport drop surfacing its empty message verbatim; without it the two paths produce the same text and collapsing them is invisible. Every source-control family carried a hand-written `*-empty-message` scenario for exactly that. 13 hand-written scenarios the matrix now covers are deleted: 8 `*-empty-message` cases plus sc-history-rejected, sc-commit-message-null-result, sc-eligibility- refused, sc-create-stops-on-push-refusal and sc-base-ref-rejected. Kept, with reasons, are the ones the matrix cannot reach: a different action or action args (sc-review-commit-*, sc-prefill-*, sc-create-{refused,rejected}-empty-message, sc-prerequisite-{publish,force-with-lease,skipped}), a payload shape rather than an envelope shape (sc-review-status-entries-not-array, sc-create-existing-review), and multi-request combinations (sc-base-ref-{unavailable,repo-fallback}, sc-reveal-timeout). Goldens: 118 -> 153. All 92 survivors changed by their `recorderSha256` line only; no recorded observation moved. Re-recorded from the pinned baseline in a separate tree so the goldens stay attributable to pre-migration product source. `matrix-hostedreview.create-intent-git.commit-1` fails on this branch, and it is a true positive: `hostReplyErrorTextOrFallback` stringifies a non-string in-band host error where main returned `result?.error || fallback` and passed the object through. Left failing — the fix is a product change, documented in the README. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep main's in-band commit error pass-through The expanded reply matrix caught a real divergence the nine original partitions missed. Main returned `result?.error || 'Commit failed'`, passing a truthy non-string straight through under a `string` annotation; the migrated helper stringified it to "[object Object]". Stringifying is arguably better — downstream does `result.error.replace(...)`, which throws on an object and merely looks ugly on a string. But this migration's contract is that no behaviour changes, and shipping an unannounced improvement inside a refactor is exactly what the parity evidence exists to prevent. Restores the pass-through; the latent throw is its own ticket. No host sends this today (`git.commit` is typed `{success, error?: string}`), but nothing validates it and mixed client/host versions are normal. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-digest the goldens for the merged recorder Main inverted two guards in family-recordings.test.ts and pilot-recordings.test.ts. No behaviour change, but both files are inside recorderSha256, so all 153 goldens failed the header check after the merge. Re-recorded from 16d1ab8 in a separate worktree carrying main's product source and this branch's merged recorder, so the goldens still capture main's behaviour rather than the migration's. `baseline` moves from 7ce8e18 to 16d1ab8 because main touched src/shared/skills*.ts, which the record guard compares; that change moved no recorded observation. Every field except `baseline` and `recorderSha256` is byte-identical across all 153 files. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): intern each observation entry instead of the whole field Making the reply matrix fail closed took the goldens from 118 files / 1.53 MB to 153 / 5.35 MB, because a per-site golden replays the chain across 11 reply partitions and every checkpoint's sender, payloads, settlements and effects re-state the whole history that came before them. Format version 2 pooled those fields whole, so the shared prefix was stored once per checkpoint, and once per partition again. Version 3 pools each entry of a list or map field instead. `golden-value-pool.ts` declares the container per field rather than sniffing it from the value, so a projection that changes one fails loudly instead of silently switching encodings. 153 files / 5.35 MB becomes 153 / 2.78 MB; the family that drove this, hostedReview.create-intent, 2.0 MB over 12 sites becomes 792 KB. This is a re-encoding, not a re-observation. Every one of the 153 goldens resolves to the recording its version 2 file resolved to, checked field by field, and every header field except recorderSha256 and goldenFormatVersion is byte-identical. The three mutations this branch's coverage rests on fail exactly as before: the gitStatusProjectionRead acceptance policy 16 (13 matrix, 3 hand-written), interpret inside the request chain 5 (all matrix), and the rewrapped transport rejection 4 (all matrix). It also makes diffs smaller, which is the opposite of what version 2's note predicted when it rejected this. Adding a timeoutMs to the first git.status of the create-intent chain touches the same 16 goldens either way, but version 2 moves 17,100 lines / 1.03 MB and version 3 moves 3,764 / 0.20 MB, because a changed entry no longer rewrites every field value containing it. `readGolden` now also refuses a pool entry that does not hash to its own key, and one no checkpoint reads. Content addressing is what makes an entry shared between checkpoints safe to share; an unreferenced entry would be content in the file that nothing compares. Recorded from 16d1ab8 with this branch's recorder laid over it, per the README's flow. The record fence is unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make the matrix census and inventory checks able to fail Three review findings, all in the recorder, none in product code. The family census pushed every family unconditionally, so it could never differ from the manifest keys; it now records a family only when a site generated a test, which is independent of replyMatrixSites throwing on an empty list. REPLY_MATRIX_NORMAL_RESULT_INVENTORY was only consulted for a live site, so a stale entry retired silently; a new assertion fails on any entry that names no live (family, request). Both verified by mutation: an empty site list and a renamed inventory request each fail the suite. The value pool resolves hashes with Object.hasOwn so a malformed golden cannot read an inherited key. Re-recorded from 16d1ab8 with this recorder laid over main's product source, per the README. All 153 goldens move on recorderSha256 only. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…ttach (stablyai#20137) * perf(persistence): add pty-binding fast lane to skip redundant flushes Terminal pane reattachment currently clones the session and serializes the entire 9.2 MB app state even when the binding is already in place and durable. Add an early-return fast path that skips this work when all nine predicates hold: no split, binding matches in-memory and on-disk, incarnation matches, no tombstone, and generation counter proves durability. Includes one-line fix in `writeToDiskSync` to record hash-matched sync flushes as durable, so the fast path doesn't stay parked behind a stale generation. Adds `persistence.pty-binding` observability spans (local NDJSON, unsampled for mutations, budgeted for fast-lane hits) to measure eligibility rates before and after. Includes ratchet test to ensure every binding writer bumps the generation. Diagnostic tools and full investigation notes from September 7, 2026 capture that identified the 59–100 ms no-op binds and measured a real terminal keystroke queued 117 ms behind one such call. * perf(persistence): add pty-binding fast lane to skip redundant flushes Rapid rebinds of already-durable PTY bindings (e.g., remounting panes) were unnecessarily expensive because they cloned and flushed the entire document state every time. Detect when a binding hasn't changed since the last durable write and skip to return immediately, eliminating main-thread cost on that path. * perf(persistence): record binding.origin on the pty-binding span Fresh spawns always flush, so a fast-lane rate over all calls is diluted by however many terminals the user opened. Each caller knows whether it is a spawn, a reattach, a split, or a relay reattach; pass that through as metadata and record it so the reattach hit rate can be read from the trace file. Never branched on. * fix(persistence): keep the tab row on its first pane when a sibling pane binds A tab row names one PTY, but a split tab holds several panes. The renderer keeps the row on the first pane and refuses to let later split-pane spawns steal it, since a remount reattaches the tab to whatever the row says. Main overwrote it with whichever pane was binding, and the renderer's next publish put it back, so every sibling reattach was a state change and could never take the fast lane. On the real profile that is 38% of panes. Rewrite the row only when it names nothing useful: null, the PTY this leaf is replacing, or a PTY no leaf holds. The fast-lane predicate compares against the same rule. * perf(persistence): record durable pty-binding flushes per pane The global write generation is held back by any unrelated dirty state, causing bindings unchanged for minutes to appear unpersisted despite being on disk. Track per-pane durability to skip redundant flushes. * docs(persistence): describe the per-pane durability record The durability section still described the global generation check as the whole story and claimed there was no binding durability cache. Record the measurement that motivated the per-pane record, and why retiring one needs no cooperation from other binding writers. * docs(perf): consolidate every measured Orca performance issue into one register Folds the findings from all related debug sessions into the live lag investigation: the persistence/main-thread work (P1-P11), host contention (H1-H5), git and subprocess load on main (G1-G8), renderer and terminal rendering (R1-R8), the terminal daemon session leak from the deleted debug-orca-perf-issue worktree (D1-D9), and the Cmd-J palette review (C1-C6). Keeps the measurement behind each claim, records what is fixed versus open, and restates what the 117 ms keystroke delay still does not explain. * fix: address performance review findings * fix: satisfy diagnostic probe lint * chore: keep investigation artifacts out of performance PR * fix: run lag probe regression tests with Vitest * perf(persistence): replace pane receipts with global durability check * refactor(persistence): remove redundant binding review machinery * test(persistence): satisfy current assertion-free quality gate --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
* 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.
…ed Text (stablyai#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.
…ai#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
…0677) * 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
…tablyai#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 c6b9179) * fix(settings): let Button and Collapsible own their spacing and type
…stablyai#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 c6b9179) * 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 640c715) * fix(session-search): report a paired server without session search as host-too-old on status reads (cherry picked from commit 1463e8a) * fix(settings): let Button and Collapsible own their spacing and type
…ai#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.
…i#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.
…lyai#13822) (stablyai#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 7db9c54: - 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.
…stablyai#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 c6b9179) * 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 640c715) * fix(session-search): report a paired server without session search as host-too-old on status reads (cherry picked from commit 1463e8a) * 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 0497e6f) * fix(settings): treat a host-too-old status read as an outdated server (cherry picked from commit a3d751f) * 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 0515588) * 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 267af1a) * 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 42a4320. 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 30b0786 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.
… (step 7 pilot) (stablyai#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 adeb5f9 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 f741b2e, 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 b8d4cde 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 f741b2e. 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 f741b2e 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 71d8c6a touched a fenced path (`mobile/src`), so the pin from 5f3f184 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 b8d4cde 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 4b0009d, 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 stablyai#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 stablyai#20954 landed The ratchet is a ceiling against this branch adding readers, not a claim about what main may land. stablyai#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 stablyai#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
…tablyai#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.
…en (stablyai#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
* perf(usage): resolve each cwd's worktree once per scan Codex and OpenCode attribution ran the worktree containment search for every parsed event, so a cold scan cost events x worktrees. On 745 MB of real rollouts (~20k events) that is 1.2s with 0 worktrees, 5.0s with 100, 12.8s with 300 and 39.8s with 1000; a full corpus with hundreds of remembered worktrees is where the STA-7724 reparse burned minutes of main-thread CPU. A scan holds only a few hundred distinct cwds, so both scanners now build one memoized resolver per scan and thread it through parsing instead of passing the worktree list to every event. * refactor(usage): make the worktree resolver own canonicalization `createUsageWorktreeResolver` now takes raw worktree refs and canonicalizes them itself, so each scanner has one entry point and neither keeps a private `buildWorktreesWithCanonicalPaths` or `canonicalizePath`. The resolver unit test counts comparisons through the same `areWorktreePathsEqual` mock the scanner-level test uses instead of a property getter.
…lyai#21102) * perf(codex-usage): resume rollout scans at the last parsed byte Codex rollout files are append-only and grow all day, but any append changed both mtime and size, so `canReuse` discarded the cached entry and the scanner re-read the whole file from byte 0 on the Electron main process. On one real corpus that was 6.59 GB re-read per cycle across 26.63 GB / 21,110 files. Each parsed file now persists a resume point: the offset just past the last newline-terminated line, the parse context at that offset (session id, cwd, model, running totals), a sha256 of the 4 KiB before it, and the file's dev:ino. A grown file resumes there and merges the appended rollup into the cached one; anything unproven falls back to a full reparse — truncation, an in-place rewrite, rotation, a counted tail with no trailing newline, a legacy copied-session suffix offset, or a file that must reclaim deferred fork claims. Resume never depends on mtime equality, so a coarse-mtime filesystem cannot hide an append. Fixture: a 75,737-byte rollout with a 758-byte append re-read 76,495 bytes before and 8,950 after (the append plus two bounded 4 KiB boundary windows). Also bounds the automation-attribution force predicate for both Codex and Claude: it keyed on `lastScanError`, so a persistently failing scan forced a fresh full rescan on every single lookup. It now keys on the most recent scan attempt, which is one forced scan per run regardless of outcome. * fix(codex-usage): verify the head of a resumed rollout prefix The resume guard proved only the 4 KiB before the resume offset, and leaned on dev:ino to catch a rollout that was replaced at the same path. ext4 and overlayfs hand a recreated file the inode the old one freed, so on Linux that check passes and a same-length prefix swap resumes over changed history. Measured 20/20 inode reuse on ext4 and overlayfs, 0/20 on APFS and tmpfs -- which is why the case only failed in CI. An in-place prefix rewrite kept no inode change on any platform, so that variant was missed on macOS too. Digest a bounded window at the start of the parsed prefix as well. When the two windows meet, one read covers the whole prefix and leaves no gap. The head window is carried across a resume rather than re-read, so a resumed scan reads the appended bytes plus three 4 KiB windows. * test(codex-usage): cover the resume window layout switch * test(codex-usage): cover the boundary window in isolation * test(codex-usage): isolate the boundary window with disjoint windows * fix(codex-usage): restart a rollout parse when its verified prefix is gone The scanner verifies a rollout's prefix in its first pass and reads it in the second, so a truncation in between left the merged projection holding the whole pre-truncation history while `processedFile` was re-stat'd to the new, smaller size. Size and mtime then matched disk with no resume state left to reject, so the reuse path served the stale total on every later scan. The resume-state builder returns null only on a short read, which is exactly that signal; on it, drop the merge and reparse the file from zero. Also covers three guards that no test was holding: the unterminated-tail resume suppression (a tail that is valid JSON minus its newline is counted, so resuming over it double-counts), the short-read check in `readWindowDigest` (without it a resume point past EOF verifies against itself), and the legacy-suffix exclusion in the scanner's resume guard (bridge markers can appear on a file that already has a resume state). * fix(codex-usage): re-verify a rollout resume point at the point of use The scanner verified each resume point while walking the sessions directory, then parsed the files afterwards, so every file discovered or parsed in between widened the gap between the check and the read. A rollout replaced in that gap resumed at the old offset into unrelated bytes: the cached session id, cwd, model and running totals were stitched onto another file's records, and because the projection was then re-stat'd to the new size, the reuse path froze the corrupted numbers. A shrink was the visible half of this; a replacement larger than the recorded offset never short-reads and corrupts instead of going stale. Re-run the full check — inode, head window and boundary window — inside the parse, against the file about to be read. The short-read fallback added alongside it still covers the narrower case of a truncation landing after that check, during the read itself. Cost, measured on the existing byte oracle: a resumed file now reads `appended + 5 * 4096` rather than `appended + 3 * 4096`, paid only by files that changed since the last scan; untouched rollouts still read nothing. Two byte-total assertions that a 15 KB rollout can no longer satisfy now assert their intent directly — that the parse read did not reopen at byte 0 — via a stream oracle that records each read's offset. * test(codex-usage): pin mid-scan replacement on attribution, not totals The mid-scan replacement case was written with a heavier replacement so the token totals diverged, which overstated how visible the defect is. Rebuilt on the variant where the stale prefix contributes exactly as many events as the resumed read skips: daily aggregates and token totals then match a cold scan byte for byte, and the misattribution — 60 records of one session recorded against another — is the only remaining signal. Oracle is now the session shape. Removing the point-of-use re-verification fails it with `session-grower` in place of `session-other`; every totals-based assertion still passes under that mutation. * perf(codex-usage): stop resuming a rollout prefix too short to pay for it Point-of-use re-verification made a resumed scan cost five bounded windows, which is more than re-reading a small rollout outright. Measured against a cold reparse of the same file, resuming lost below a 12,288 B prefix and lost badly under 8 KiB, where the coalesced-window layout rehashed the whole prefix on each of the three verification passes. Set the floor at that break-even — 3 * 4096, the point where two verification passes plus the recorded boundary stop being cheaper than reading the prefix once — and refuse to record or accept a resume point below it. Measured: a 12,568 B prefix now reads 21,234 B resumed against 21,514 B cold, and a 76,484 B rollout reads 21,238 B against 84,676 B. No size band reads more than a cold scan any more; under the floor the windows are skipped entirely and a scan reads exactly the file. With every offset past the floor the two windows can no longer overlap, so the coalesced-layout branch and the empty-window branch are gone. The floor is also input validation: a persisted offset below it would put the boundary window at a negative start and throw ERR_OUT_OF_RANGE. Tests that meant to exercise the resume path were silently reparsing whole once the floor landed — the suite stayed green while three guards lost their only coverage. They now size their rollouts off RESUMABLE_RECORDS and assert the offsets their parse reads actually opened at, so a test that stops resuming fails instead of passing quietly. * test(codex-usage): cover the reuse gate's own legacy-bridge check `scanner.ts` carries the same `legacySourceSkipBytes === 0` term twice and they are different guards: line 83 gates resuming, line 71 gates reuse. Only the first had a test, so dropping the second left the suite green. It is load-bearing. A cached entry can predate the bridge marker while the source file is untouched, so size and mtime still match and nothing else stops the scan serving a full-history projection for a file that is now parsed suffix-only. With a total-only record after the copy point the two readings diverge — baseline worth nothing against a delta worth three — and the reused entry reports 18 tokens where a cold scan reports 15. * fix(codex-usage): annotate the mid-scan seam instead of asserting it The changed-code quality gate rejects any non-const type assertion, and `onStreamOpen: { current: null as (...) | null }` is one, so `static analysis` failed on this PR. A typed local carries the same intent. * fix(usage): force an automation lookup onto a scan already in flight `shouldForceAutomationUsageScan` keyed on `max(lastScanStartedAt, lastScanCompletedAt)`, so a scan that started after the run completed but is still running counted as a finished attempt. The lookup then called `refresh(false)`, which returns early inside the 5-minute staleness window instead of joining the scan, and the run's usage read `unavailable`. Forcing instead just awaits the shared `scanPromise`. While a scan is in flight its start time is no longer treated as an attempt, so the once-per-run bound still holds: a failed scan leaves `lastScanStartedAt` past the run and stops re-forcing. The two providers' copies of the predicate were byte-identical, so it now lives in `src/main/usage/automation-usage-scan-forcing.ts`.
…read (stablyai#21114) * perf(codex-usage): resume rollout scans at the last parsed byte Codex rollout files are append-only and grow all day, but any append changed both mtime and size, so `canReuse` discarded the cached entry and the scanner re-read the whole file from byte 0 on the Electron main process. On one real corpus that was 6.59 GB re-read per cycle across 26.63 GB / 21,110 files. Each parsed file now persists a resume point: the offset just past the last newline-terminated line, the parse context at that offset (session id, cwd, model, running totals), a sha256 of the 4 KiB before it, and the file's dev:ino. A grown file resumes there and merges the appended rollup into the cached one; anything unproven falls back to a full reparse — truncation, an in-place rewrite, rotation, a counted tail with no trailing newline, a legacy copied-session suffix offset, or a file that must reclaim deferred fork claims. Resume never depends on mtime equality, so a coarse-mtime filesystem cannot hide an append. Fixture: a 75,737-byte rollout with a 758-byte append re-read 76,495 bytes before and 8,950 after (the append plus two bounded 4 KiB boundary windows). Also bounds the automation-attribution force predicate for both Codex and Claude: it keyed on `lastScanError`, so a persistently failing scan forced a fresh full rescan on every single lookup. It now keys on the most recent scan attempt, which is one forced scan per run regardless of outcome. * perf(usage): run the Claude/Codex/OpenCode usage scans on a worker thread The three first-party usage scans walk whole rollout and transcript corpora and read OpenCode's SQLite synchronously, all on the Electron main process. They rarely produce a long stall — the JSONL reader streams, so it yields to the loop between chunks — but they pin the main-process event loop at ~95% utilization for the scan's whole duration, which is what every IPC message, timer and window event then queues behind. Move that work to one lazily-spawned, unref'd worker thread shared by all three providers, following the OpenCode SQLite scanner precedent (stablyai#8864). Measured on a synthetic 4,000-rollout corpus (25.8 MB cache): a cold scan drops from 2,147 ms of main-thread time to 31 ms, and a steady-state incremental scan from 165 ms to 64 ms. The worker is stateless and the cache crosses the boundary both ways. That costs ~64 ms of structured clone at this corpus size, against 2,147 ms saved on the cold path, and it keeps the persisted cache the single source of truth — a worker-owned copy would need an invalidation protocol and a second resident copy of the same multi-MB array. Failure is closed, never a silent empty result: a worker that cannot spawn, times out, or crash-loops rejects, and the store records the scan error and keeps the previous projection. Two clients already carried the same FIFO/timeout/crash-cap machinery, so extract it once as WorkerThreadRequestQueue (with the packaged entry-path resolver as worker-thread-entry-path) and move all three onto it, rather than adding a third copy. Their existing tests pass unchanged. The oracle is event-loop utilization on the calling thread, not a stopwatch: usage-scan-worker-event-loop.test.ts runs the same scan both ways and asserts the worker leg leaves the caller idle while the main-thread leg does not, so CI load moves both legs together (stablyai#18788). * test(usage): compare the two scan arms instead of two fixed thresholds The event-loop oracle claimed to be self-calibrating — its header said "the ratio is self-calibrating, so CI load moves both legs together (stablyai#18788) instead of tipping a fixed millisecond threshold." It computed no ratio. Two separate `it()` blocks each asserted an absolute threshold against its own arm, run separately, so load moved them independently. The comment described a test nobody wrote, and the flake it promised was impossible is the one that landed: `activeRatio > 0.8` on the calling-thread arm measured 0.764 on an ubuntu runner. Fixing the comment is not enough, because the fraction is the wrong quantity. CPU contention drags the calling-thread arm's active/wall fraction *down* toward the worker's, since the loop parks waiting on a contended libuv pool. A 4-vCPU Linux container measured that arm at 0.175-0.756 across twenty runs, idle and loaded — never once above 0.8. Active *milliseconds* move the other way: contention stretches the caller's JS time far more than it stretches the worker arm's fixed post-and-deserialize cost, so the gap widens under load. Merge the two arms into one case over one corpus and assert the worker arm costs the caller under a fifth of the inline arm's active milliseconds. Same twenty Linux runs: 10.9x-83.6x, passing throughout. Keep the presence preconditions on both arms — an arm that silently scanned nothing satisfies the comparison trivially — and extend them to the calling-thread arm, which previously checked only file and session counts. * fix(ports): name the dropped command when the probe queue is full The shared-queue extraction turned `Port scan command queue is full; dropped ${command}.` into a constant string, because `describeFull` was given no way to see the request. Pile-up is per-probe, so the name is the only thing in that log that identifies which of lsof/ps/netstat was shed. Pass the rejected request to `describeFull` and restore the name. The request is built before the cap check so it exists to be named; the id it burns is a correlation token, so a gap costs nothing. The existing overflow test asserted only the error class, which is why the regression escaped a 29-test suite. It now dispatches the overflow under a different command than the accepted ones and asserts the message text, so a message that names the wrong request fails too. Also add a direct WorkerThreadRequestQueue test. Three subsystems share the queue and each client test only sees the parts its own protocol exercises, with `queueCap` reachable from port-scan alone. Covers one-at-a-time FIFO dispatch, the deadline starting at dispatch rather than enqueue, the consecutive-death cap, and both points where that count clears. And record the child-process hazard at the usage worker entry. `terminate()` reaps nothing the thread spawned, and OpenCode discovery reaches a fork today: `wslGated*` forks the WSL transcript sidecar for a `\\wsl$\...` path, which a Windows `OPENCODE_DB` or `XDG_DATA_HOME` can be. One scan through that entry with a UNC `OPENCODE_DB` forked a sidecar that outlived `terminate()`. * test(ai-vault): assert the OpenCode worker messages exactly, not by fragment Checked every message string in the two clients the shared-queue extraction rewrote against origin/main. Only the port-scan queue-full one regressed (fixed in the previous commit); the OpenCode SQLite client's four messages render identically, the remaining source diffs being renames — `error.message` to `lastError`, `call.timeoutMs` and `CALL_DEADLINE_MS` to `timeoutMs`. `session-scanner-worker-client.ts` was not touched by the extraction. But its suite could not have caught it either. `/timed out/`, `/exited with code/` and a bare `rejects.toThrow()` all still match a message that has lost its interpolated value, which is the same blind spot that let the port-scan regression through. Assert the rendered text instead: the timeout names its deadline, the exit names its code, and the crash-loop drain still carries the text of the fault that killed the run. * fix(usage): correct the worker entry's child-process note The previous note said `worker.terminate()` leaves a forked sidecar orphaned. It does not, and the reproduction that appeared to show it used a stub sidecar missing the `process.on('disconnect', () => process.exit(0))` the real entry has. With a faithful one: the sidecar lives exactly as long as the thread and is gone within 2s of `terminate()`, because tearing the thread down closes the IPC channel it owned. Two worker lifecycles forked two sidecars and leaked neither, and the pre-worker main-thread path reaps its sidecar the same way, on host exit. What is true and worth recording: a fork is reachable from this bundle at all, which is easy to miss; it survives only as long as the channel does; and the sidecar is now re-forked per worker lifecycle instead of pooled for the app's life. State those, and warn that a future child which does not exit on channel close would not get the same free cleanup. * fix(usage): kill a wedged scan worker on no progress, not on wall clock `USAGE_SCAN_TIMEOUT_MS` was a 10-minute deadline on the whole scan. A cold scan of a real history is legitimately minutes — 637 s measured on a 30 GB corpus with 300 worktrees before the per-cwd memo, ~51 s after — so a larger corpus or a slower disk crosses it. Crossing it killed the worker, recorded a scan error and left the cache unadvanced, so the next refresh started cold and died at the same point, forever. The deadline is now a no-progress window. The worker posts a file counter as it walks the corpus (`UsageScanWorkerProgress`, rate-limited to one message a second), and `WorkerThreadRequestQueue` re-arms the active call's timer on each one via the new optional `isProgress`. Clients that do not pass it keep the plain wall-clock deadline. `MAX_CONSECUTIVE_DEATHS` and idle teardown are unchanged. * refactor(usage): report scan progress as a file count, not one call per file Claude's scanner walks batches, so a per-file callback made it loop just to bump a counter.
…auto-sync-fork-with-upstream-stablyai-orca-ru-20260917T1400 # Conflicts: # src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx
Resolve every differing path against config/fork-ownership.json: 2579 reset to the tag, 120 removed, 155 restored to the fork side, 1194 left to their three-way merge. Withdraw the worktree-groups seam in AgentMapWorkspaceContextMenu.test.ts: stablyai#20929 removed the agent map from the dashboard popout, so the file the seam lived in is gone at the tag. The fork's own fork-worktree-groups/WorktreeGroupMenuItems tests already cover the label the seam asserted, so nothing the fork ships is lost. Replay the upstream work the exceptions would otherwise discard: pr.yml's Git-2.25.5 build split and pre-pulled compat images, release-cut.yml's Windows relay process-table addon, package.json's anti-slop and dead-classes lint steps with their new devDependencies, and seven fork-owned scripts and tests. pr.yml's package_windows job conflicted because upstream edited a job the fork deleted outright in #52 — the fork's deletion stands. The README badge and release-mapping.json stay fork-side for the same reason as always. Restore the AGENT_LAUNCH_METHODS import that -X ours replaced with the fork's ASK_METHODS line while leaving stablyai#19849's registration in the array, and components.native-chat.copyCode, which the same pass dropped from en.json while keeping upstream's NativeChatCodeBlock that reads it. Replay the one tier-2 delta (the composer field's [contain:paint]) and advance every FORK-COPY-SHA to the new tag.
Eighteen seam files drifted from their budgets; none was a module split, and each had lost upstream lines the fork's side displaced. Re-home two seams upstream moved out from under: - emitEnrichedStatus left server-status-update.ts for server-listeners.ts, taking the session-info tap with it. Reset the old host to the tag and re-declare the seam on its new one. - isCommandGroup now derives groups from the specs instead of a hardcoded list, so the fork's 'ask'/'ledger' entries are covered by ASK_COMMAND_SPECS and LEDGER_COMMAND_SPECS. Only supportsBrowserPageFlag still needs its declared line; the fork's CLI registration test moves to the new signature. Restore the upstream lines the merge dropped: the search command's specs, help text and CLI flag modules; agent.launch in the parity union and the runtime capability list; the archive-hook and forbidden passthrough codes; the AI Vault search imports; the structured-session tab-close imports; paint containment on .xterm-container; and the renamed skills-CLI predicate. CommentMarkdown and its element renderers each grew a second parameter on the same signature — the fork's highlightCode on inline <code> and the tag's renderCodeBlock on <pre>. They compose, and NativeChatMessageRow already passes both. Regenerate the bundled skill guides and the bundle manifest rather than hand-merging the fork's orca-ask entry against upstream's rewritten guides.
stablyai#19849 replaced launchAgent's `tabId: string | null` with a surface union; host-published is the case that used to be null, which is the branch the handoff waits on for prompt delivery. Derive tabId from the surface and move the test's launch-result fixtures onto it. stablyai#20659 threads isVisible from the resolved view down to the message list so a hidden pane still drains its queue. The fork's relay conversation component sits between them, so it has to forward it.
v1.4.206 enables the rule repo-wide. Rename each symbol for the decision it carries rather than the structure it inspects, the way upstream's own adoption commit (231e805) renamed isSkillsCliAgentKeyShaped to isUsableSkillsCliAgentKey and RootShape to RootLayout: isCredentialShaped -> mentionsCredential validateRequestShape -> validateRequestTargeting itemShape -> currentTabItem Names only; the operator messages and every branch are untouched.
v1.4.206 enables the rule repo-wide. Both sites took a bare `object`
because they only needed identity or two members; name what they are
instead, which is what the rule asks for:
askServicesFor(runtime) -> AskServicesOwner, the runtime the roster
is keyed to, still compared by identity
focusTabWithSurface(...) -> TerminalHelperSurfaceStub, the two members
the dock redirect actually reads
The ask-services tests now key their rosters on a named owner. Two
distinct literals are still two distinct identities, so the
one-roster-per-runtime case keeps testing what it did.
v1.4.206 enables the rule repo-wide. Upstream's own adoption (6c03bb6) replaced Reflect.get with typed access rather than a cast, so narrow the case table's runtimeMethod to the three readers the dispatcher actually budgets and index the runtime with it directly. The table already only ever held those three.
… shards stablyai#20621 added src/main/pty/omp-shell-wrapper-alias-safety.test.ts to unit-tests.yml's exclude list, so it no longer runs in CI's unit lane. The fork's sandbox runner filtered it back in on the old assumption and its own contract test, which reads both lists straight out of the workflows, caught the divergence at 17 excludes against CI's 18.
getHourlyBuildIdentity reads this repo's own package.json, so the
unpublished-release case only holds while the tree sits at or below
1.4.203. This is upstream's defect, not the merge's: the test and both
implementation modules are byte-identical between upstream/main and the
v1.4.206 tag, and the tag carries "version": "1.4.206", so it fails there
too with 1.4.206-hourly.202609142000. Upstream does not see it because
its unit matrix runs on main, whose package.json is still 1.4.197.
Resolve the base through resolveDevChannelBaseVersion('1.4.203',
publishedVersions) and assert createHourlyBuildVersion and
nextHourlyBuildNumber against it. The same three behaviours are covered
and every assertion stays exact; only the accidental dependency on the
tree's own version goes. Declared pending-upstream with a ledger entry
(bug-148) so the next sync does not revert it.
zpyoung
deleted the
zpyoung/auto-sync-fork-with-upstream-stablyai-orca-ru-20260917T1400
branch
September 18, 2026 10:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Absorbs upstream stable v1.4.206 (
c464b1014972bd771324fed84b7de7fb100e837b) into the fork.Previous tag on
mainwas v1.4.204 — v1.4.205 was resolved and verified yesterday but never landed(see Lint below), so this run targets the newest stable and supersedes it.
Resolution
git mergeconflicted, so-X ourswith one tree conflict →resolution=auto-ours+tree.The tree conflict was
AgentMapWorkspaceContextMenu.test.tsx, deleted by stablyai#20929 and modified by thefork. Step 5's rule kept the fork's file; ownership resolution then found it is a declared
worktree-groupsseam whose host no longer exists at the tag, so the seam is withdrawn and thefile removed. Nothing the fork ships is lost —
fork-worktree-groups/WorktreeGroupMenuItems.test.tsxalready covers the label the seam asserted.
upstream/mainours.txtaudit — upstream moved on 14 of 155Eleven three-way merged with no conflict and were applied, including two that PR CI would never have
caught:
release-cut.ymlgains the Windows relay process-table addon (stablyai#20809 — every stableinstaller through v1.4.203 shipped Windows relays without it), and
package.jsongains theaudit:anti-slopandcheck:dead-classeslint steps with@oxlint/pluginsand@shadcn/lint.package.jsonconflicted only onversion, which stays fork-side.pr.ymlconflicted because upstream edited thepackage_windowsjob the fork deleted outright in#52 — the fork's deletion stands.
docs/assets/readme-downloads.svgandresources/skills/release-mapping.jsonconflicted and stay fork-side; both are fork artifacts whoseexception reason is exactly what upstream's copy undoes.
Seam repairs
Eighteen seam files drifted. Line-count triage found no module split; each had lost upstream lines
the fork's side displaced. Two seams needed re-homing:
emitEnrichedStatusmoved fromserver-status-update.tstoserver-listeners.ts, taking thesession-info tap with it. The old host resets to the tag; the seam is re-declared on the new one.
isCommandGroupnow derives groups from the specs instead of a hardcoded list, so the fork's'ask'/'ledger'entries are covered by their ownCOMMAND_SPECS. OnlysupportsBrowserPageFlagstill needs a declared line, and the fork's CLI registration test moves to the new signature.
Restored upstream lines the merge dropped: the
searchcommand's specs, help text and flag modules;agent.launchin the RPC parity union, the methods index and the runtime capability list; thearchive-hook and
forbiddenpassthrough codes; the AI Vault search imports; the structured-sessiontab-close imports; paint containment on
.xterm-container;components.native-chat.copyCodeinen.json; and the renamed skills-CLI predicate.CommentMarkdownand its element renderers each grew a second parameter on the same signature — thefork's
highlightCodeon inline<code>and the tag'srenderCodeBlockon<pre>. They compose,and
NativeChatMessageRowalready passes both. The bundled skill guides and bundle manifest wereregenerated rather than hand-merged.
Fork adaptations to changed upstream APIs
launchAgent'stabId: string | nullwith a surface union;host-publishedisthe case that used to be
null, which is the branch the handoff waits on for prompt delivery.isVisibledown to the message list so a hidden pane drains its queue; the fork'srelay conversation component sits between and forwards it.
Lint — the new
anti-sloprulesv1.4.206 enables eight
anti-sloprules through a newaudit:anti-slopstep. They fired on sixfork-owned files, and
oxlint --fixrewrites none of them. Each is adopted by mirroring upstream'sown adoption commit for that rule, one commit per rule:
no-shape-in-symbol-namesisCredentialShaped→mentionsCredential,validateRequestShape→validateRequestTargeting,itemShape→currentTabItemisSkillsCliAgentKeyShaped→isUsableSkillsCliAgentKey,RootShape→RootLayout)no-object-parametersAskServicesOwnerandTerminalHelperSurfaceStubno-reflect-getBudgetedRuntimeMethodunionAll are name- and type-level; no branch, message or assertion changed.
The one violation that was upstream's own —
Reflect.getinagent-status-legacy-adapter.ts, whichv1.4.205 shipped against its own new rule — is fixed on this release branch by stablyai#20872, so it
dissolved with the retarget (ledger
bug-137).Verification
pnpm install --frozen-lockfile--verify-seams--verify-residualstypecheck:node/:web/:clipnpm lintaudit:anti-slopincludedTests are left to this PR's own CI, per the skill.
Also fixes the ownership guard on every other PR
PR #82 was squash-merged, so v1.4.204's tag commit is not an ancestor of
main. The forkownership guard picks the newest stable tag reachable from HEAD, so it has been comparing every PR
against v1.4.203 and reporting ~25 false residual drifts — #83 is red for exactly this reason,
with no fault of its own.
main's tree verifies clean against v1.4.204.Merging this PR with a merge commit puts v1.4.206 back in
main's ancestry and clears that.--squashand--rebasewould both re-break it.Backup of the pre-merge
main:origin/backup/main-20260917-140138Zat86dc3caa11.