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>
…lyai#20551) Co-authored-by: Orca Worker <orca-worker@localhost>
…nt selection (stablyai#20182) * fix(workspaces): re-seed a gate-reported empty workspace after an agent selection stablyai#19940 routed two different questions through one predicate. An agent selection justifies skipping the *pre-emptive* shell at create time, but it was also suppressing the async activation gate's fail-closed re-seed. The gate returns `empty` only after adoption, structured inventory and resume all produced nothing, so it is positive evidence the agent surface never arrived (dead PTY, unreadable census, null startup plan) — exactly when the re-seed is needed. Suppressing it there left the workspace with zero tabs and no recovery: ensureWebRuntimeWorktreeTerminalAfterWake returns early for a plain local workspace, so nothing else seeds one. `gatedEmptyOutcomeReseedSuppressed` now answers the re-seed question on its own, honouring only an explicit `providesInitialSurface` caller promise — restoring v1.4.200's behaviour for that path. The create-time skip keeps using the agent-inclusive `activationProvidesInitialSurface` unchanged. * refactor(workspaces): make gated reseed policy explicit * fix(workspaces): harden gated empty reseeding * chore(workspaces): split duplicate-folder routing fix * refactor(workspaces): keep reseed helper outside caller census --------- Co-authored-by: Merge Sim <sim@local>
… planless agent create (stablyai#20190) * fix(workspaces): recover a real terminal when activation throws for a planless agent create stablyai#20175's activation-failure recovery re-seeds the workspace surface, but passed callerProvidesSurface: true whenever an agent was selected. Activation is the caller that would have provided that surface, and it just threw. With the flag set and no startup plan, worktree-initial-terminal-seeding takes its zero-tab pre-seed branch: it queues setup/issue commands and returns null instead of creating a terminal. The recovery then recovers nothing and the freshly created workspace opens with no tabs. Dropping the flag from the recovery call is the whole fix. The create-time call keeps it: there, the agent pane really is the surface the caller provides. The enclosing object literal becomes a ternary because oxlint's unicorn/no-useless-spread rejects a lone conditional spread. * test(workspaces): reuse activation recovery coverage * test(workspaces): keep recovery assertion focused --------- Co-authored-by: Merge Sim <sim@local>
…i#20561) * feat(native-chat): show a drop target on the whole chat pane Dropping a file into a chat only worked if you hit the input box, and nothing on screen said so. A drag aimed at the transcript fell through to the terminal behind the chat, which pastes the paths into the hidden TUI. The chat pane shell is now the drop surface. While a drag carrying files is over it, the pane dims behind a card naming what the drop will do; the composer's existing attach logic — workspace, execution-host and SSH-owner checks included — runs unchanged from the wider element. Two routes reach the composer and they are widened differently: - an in-app drag is claimed in the renderer, so the pane calls the composer's handlers through a claim the composer publishes to the surface around it. - an OS drag is delivered by the preload drop route, which consumes the event at `document` before React sees it. The pane widens that route by publishing the composer's scope key as the nearest drop marker, and learns the drag ended from a document-level listener rather than a React drop. The surface lives in the chat portal because that is the one place wrapping both the bridge and structured panes, so neither pane root grows a second copy of this wiring. No composer mounted (a question card owns the input region) means no marker and no overlay, so that drag stays the terminal's. A guarded composer still answers for the drag — that refusal is what keeps it out of the terminal — but the pane does not invite a drop it will refuse. * Fix native chat drop ownership across session surfaces * Keep native drop completion listeners ready before hover renders
…self (stablyai#20518) * fix(native-chat): let the provider reopen a Claude turn it resumed itself A Claude turn could only be opened by Orca's own send echo, while any `result` frame closed it. The provider resumes work on its own — a background task reports in and wakes the agent after `result` settled the turn — and nothing Orca sent ever arrives to reopen one, so the session projected `idle` for the rest of the work. The model's own output is the evidence a turn is running, the way Codex's `turn/start` is, so it opens one; whichever opened it, the next `result` settles it. Subagent frames still open nothing: children outlive the turn that spawned them, and their work is their parent turn's, never a turn of its own. * fix(native-chat): bracket a resumed turn around the output that opened it A resumed turn was published after the frame's own rows, so its first tool call sat above the turn record. Every reader that scans back to the turn record and stops — the active-tool reader behind the sidebar's tool line, and the turn-window activity selector — looked straight past it, and the row showed working with no tool until a second call landed. The turn now opens before its frame is journaled. A send's turn keeps its existing order: the user echo is that turn's anchor and is written first. Prompt journaling moves to its own module, verbatim, to keep the translator clear of the line cap. * refactor(native-chat): declare the turn open at each content site The resumed-turn rule was a predicate that re-derived whether a frame had produced anything, duplicating work the frame handler had already done. The content sites know: each one now calls an idempotent ensureTurnOpen before it journals, and the guard against reopening a live turn lives in that one place. Behaviour is unchanged; claude-turn-opening.ts is left owning only the send echo, which is the one opener that anchors its turn to a user row. * fix(native-chat): gate both turn edges on root-ness The reopen path already refused nested output; the guard now reads before the already-running check so both edges state root-ness first. The close path had no nesting check at all, so a child's result would have ended the turn that spawned it. The two edges read parent_tool_use_id differently on purpose, and both fail towards not over-claiming: opening needs proof of root-ness, so an absent field opens nothing; closing needs proof of nesting, so an absent field still closes. No real Claude stream has been observed carrying a nested result — the session that prompted this work has none in any subagent stream — so the close-side guard is symmetry, not a demonstrated fix. * fix(native-chat): stop provider output reopening a turn nothing can close Self-audit found two paths the reopen rule opened where no event could ever settle the turn it created, leaving the row working for the life of the session. Both now suppress reopening until an accepted send lifts it: - a frame arriving after the session ended, when no event will settle anything - a turn the provider failed, or the user stopped, where the next thing the provider says is not a resumption Each has a one-lever ablation: removing the suppression read alone fails exactly those two tests, and both fail as working-instead-of-idle. * fix(native-chat): open a resumed turn from its first streamed delta Streamed deltas short-circuit before the frame handler, so a resumed turn whose first output is streamed text — the common case, since partial messages are a pinned launch contract — kept reading idle while its partial text was already journaled and visible. The streamed path now opens the turn too. Also from the same audit: - a nested result no longer swallows its own failure diagnostic; the turn gate now guards only settlement, and the provider-fallback row is written either way - root-ness treats an absent parent_tool_use_id as root, so a build that omits the field cannot silently stop opening turns - the suppression latch only ever sets on a failed result; a later clean result cannot lift it, and only an accepted send does The opener moves into claude-turn-opening.ts so both entry points share one root-then-suppression-then-idempotency order. * fix(native-chat): preserve resumed-turn lifecycle semantics
…lyai#20509) * fix(native-chat): retire provider ownership on restart * fix(native-chat): stop showing a restart eviction as a provider death Restarting Orca turned a resumable structured chat into a user-visible `Provider exited: recorded pid absent on host`. Quit never released the durable lease, so restart probed the recorded pid, adjudicated the session evicted, and wrote a synthetic status row against a chat that was perfectly resumable. The fix is the missing teardown phase plus the missing fence check: quit now evicts every provider child this host owns — stopping it, settling its journal and handing the lease back — and the release compare-and-swaps on the fence it expected. Restart then finds a released lease and reopens the chat silently. What the user sees is decided by the typed death evidence rather than the shape of a settlement id: only an `exit-observed` death writes copy, and that copy now carries its cause so an auth failure and an OOM kill do not read alike. The reassuring wording stays. Historical synthetic rows are filtered out of the render projection, which needs no schema change and leaves every real provider-exit row alone. Also: - Bound the new eviction phase well below the quit deadline; a quit that dies mid-eviction leaves the lease unreleased, which is the original bug. - Scope the interruption verdict to work that was mid-response. A provider that died while waiting on an approval interrupted nothing. - Keep host bookkeeping in step with the adapter: the provider-child flag clears when the child is proven stopped, not seven steps later. - Drop the router's duplicate shutdown gate and acquisition drain — both adapters already own theirs — and latch the router closed so a late acquire cannot fan a session back out to closed adapters. - Attach the real cause to the settlement failure a quit reports, and remove a recovery-ticket field that was hardcoded at its only construction site. * fix(native-chat): scope the legacy status filter to the copy it retires The read-time filter hid every status row carrying a `restart-eviction:` identity. That identity is still minted, so a genuine provider death settled under it would have been dropped from every rendered page. Match the retired `Provider exited` copy as well, so only the legacy rows are hidden. Three smaller corrections alongside it: - The settlement retry path now applies the same unfinished-work check the live exit path uses, so a provider that died waiting on an approval no longer gets told a response was in progress. - Bound the exit reason before composing the outcome copy, so a stderr dump in the reason cannot push the "you can continue" sentence past the row's byte cap. - Correct the teardown comment: tail rows are protected by eviction's own per-session ordering, and `closeAll` is a backstop for children eviction never took, including one whose eviction was refused. * fix(native-chat): retire legacy status rows at the projection source The read-time filter that hides the retired `Provider exited …` rows ran on the way OUT of the page builder, after the paging math had already measured the unfiltered timeline. A backward window landing entirely on those rows returned an empty page that still reported `hasOlder: true` with a null `window.oldest`, so the renderer's backfill loop re-asked from the same anchor forever. Its only no-progress guard compares `window.oldest?.sequence` to the anchor, and `undefined === n` never breaks. The live subscription opens behind that loop, so the transcript never finished loading either. Filter where items ENTER the page pipeline instead: the reduced snapshot gets one renderable timeline, the forward path gets one renderable batch, and the window bound, effective limit, `hasOlder`, `window.oldest` and `nextCursor` are all computed over that single array. A window with nothing left behind it now reports end-of-history. Also restore the eviction retry contract. Clearing `hasProviderChild` as soon as the adapter proves the child gone is honest, but it is a different fact from the wind-down this host still owes. A retry after a step aborted between the two was reading "no child here" and skipping both the dead-generation settlement and the lease release the aborted attempt had promised to repeat. The obligation is now tracked separately and cleared only by a release that actually landed. And rename the filter to the copy it retires: it drops only rows carrying the retired `Provider exited` text, not restart-eviction status rows in general. * fix(native-chat): read the wind-down a close owes from the live child An eviction recorded "nothing owed" whenever it ran over a session with no provider child of its own, and the retry then read that record in preference to the child in front of it. A session suspended to an agent terminal is exactly that shape, and the trip back to native re-acquires into the SAME session object rather than replacing it, so the next close skipped both the dead-generation settlement and the lease release — leaving the record claiming a live owner this host had just stopped, and a pending send unsettled. The obligation is now derived the way the quit sweep already derived it, from one shared predicate: a live child always owes a wind-down, and a remembered `false` only carries the obligation forward, never cancels it. Also drops a memoization in the history page that could never hit. Its key was the snapshot's items array, which the reducer rebuilds on every `snapshot()` call, so each backward page allocated a fresh key; the one reader that does share a snapshot across pages reads forward and never calls it. The comment claimed a multi-page read filtered once, which was not true of either path. Tests: the handoff round trip that strands the lease, and the quit sweep picking up an eviction whose close retry never came. * chore(native-chat): scope three helpers to their file and pin the teardown order retryUnexpectedExitSettlement, hasUnfinishedStructuredAgentSessionWork and isRetiredProviderExitStatusItem each have no consumer outside the file that defines them, so they no longer advertise an external contract. The quit-path phase list documents its order as load-bearing, but nothing asserted it. Pin the phase names so evict-owned-sessions cannot drift out of its slot between drain-attaches and flush-event-sinks. * fix(native-chat): stop the router reporting a stop it never observed `closeAll` cleared the route table and set one boolean, after which that boolean was the only surviving evidence about any session. Two call sites then spent it: `releaseAcquisition` and the stop path each turned a route-lookup MISS into reported success. Eviction reads a `true` from the stop path as proof the provider child is gone and releases the durable lease on it, so a session the router never routed could have its lease handed back on the strength of "I have no record, but everything is closed." Loss of contact is not evidence of process death. The fix keeps the evidence instead of the inference: adapter shutdown only resolves once every child is proven stopped, so `closeAll` now marks each routed session `stopped` rather than forgetting it. A routed session still answers `true` from its own retained proof; a session with no route answers `false`, which leaves it indexed for a real retry. `releaseAcquisition` drops its short-circuit and asks the adapters, which answer from their own session maps. The acquire-side latch is unchanged: once closed, the router stays closed and refuses new work. Behaviour that changed: a post-`closeAll` stop for a session the router never routed, or one the host already acknowledged as released, now reports unproven instead of proven. That matches what the same call already answered before `closeAll`, and no real flow reaches it — quit evicts every owned session before `closeAll` runs, and eviction only asks the adapter for sessions whose provider child this host acquired through the router. * test(native-chat): ratchet the retired provider-exit copy out of production The retirement filter hides a status row on two facts: a restart-eviction item id and copy that opens with the retired prefix. The identity half is still minted today, so the filter cannot tell a new producer's row from the legacy row it exists to hide — any future writer of that copy would be dropped from every transcript with no trace. Until now that safety property lived only in a doc comment. Scan the shipped tree for a string literal that OPENS with the retired prefix, which is exactly what the filter's `startsWith` reads. Comments are stripped first, so prose about the retirement is not a producer, and the filter's own constant is exempt. Tests are excluded: writing the copy is how the filter is exercised. * revert(native-chat): drop the read-time retired provider-exit filter Fix forward instead. The lifecycle change in this branch stops any new `Provider exited: <reason>` row from being written; rows a previous build already persisted stay in those transcripts and age out with them. A permanent read-time filter for a cosmetic, shrinking set was not worth its maintenance cost, and its paging seam was the only place a backward window could land entirely on hidden rows. Removes the filter module and its test, restores agent-session-history-page.ts to its pre-branch form, and drops the tests that only existed to prove the filter did not over-match or wedge the backfill loop. The copy ratchet stays and now carries the whole guarantee: with no filter in front of it, any production writer that resurrects the retired prefix reaches the user's transcript directly.
… a filter (stablyai#20588) * fix(github): skip Projects search index for unfiltered views Empty query still used items(query:\$q), which routes through GitHub's Projects search index and can return totalCount 0 while the board is full during index lag. Omit the query argument when the view filter is empty. Fixes stablyai#12648. * docs(github): drop the false stable-shape claim for empty project filters Unfiltered item fetches omit items(query:) so boards skip search-index lag. The View.filter field is still '' when GitHub returns null. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(github): name an unfiltered empty project view instead of blaming a filter The search-index workaround in this branch was a no-op. Live introspection of ProjectV2.items shows `query` is declared `String = ""`, so omitting the argument and sending `$q = ""` coerce to the identical resolver input; GitHub applies declared defaults for omitted args (verified against its own endpoint). There is no non-search item field on ProjectV2 and ProjectV2View has no `items` at all, so no request shape can dodge the index. Revert the branching query construction and the module it added. What the user actually reported in stablyai#12648 is the copy: a view with no filter rendered "No items match this view's filter", which reads as data loss when a freshly populated board momentarily comes back empty. Word the empty state from the view's own filter — the filter message only when there is a filter, and an honest "no items yet" plus a transience hint when there is not — and share the one implementation between the table and roadmap surfaces. Refs stablyai#12648. --------- Co-authored-by: bbingz <zzb@gxsmjx.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…stablyai#13118) Six scan roots took a directory from an environment variable and used it verbatim. A relative value is resolved by whichever Orca process reads it — main sits at `/` when Finder-launched, the terminal daemon chdirs itself to the user data dir, the AI Vault service inherits main's cwd — so one value names a different directory in each, and walkSessionFiles walks it with no depth cap, no entry cap and no time budget, about once a minute per the session-list cache TTL. The agent CLIs do accept a relative home (verified against real Grok 1.0.30: `GROK_HOME=myhome grok du` creates `<grok-cwd>/myhome`), but they resolve it against their own per-terminal cwd, which no Orca reader shares. Falling back to the default home is therefore not a lost configuration — it replaces an unbounded walk of an arbitrary tree with a bounded read of a known one, and matches what readGrokHomeEnvelope, skill-provider normalizedRoot and absoluteConfiguredDir already do with the same values. Add resolveAbsoluteDirOverride and apply it to CODEX_HOME, COPILOT_HOME, OPENCLAW_STATE_DIR, DEVIN_HOME, KIMI_CODE_HOME and GROK_HOME. It takes an explicit platform so the Windows shapes are provable from a POSIX CI box: `C:\...`, `C:/...` and UNC roots are kept, while the drive-relative `C:foo` and bare `C:` fall back. Tilde expansion stays out of it — Grok creates a literal `~` directory rather than expanding one — so absoluteConfiguredDir keeps its own Pi/Prime-specific expansion and delegates the absolute check. isAbsolute is syntactic only, so `/..` still collapses to `/`. That is fine for read-only discovery; these roots never gate renderer-supplied paths. Tests assert at the call sites, not just on the helper: the four session-scanner-agent-sources roots are module-level consts evaluated at import time, so they are exercised through AI_VAULT_AGENT_SOURCES with vi.stubEnv plus vi.resetModules. Reverting any one of the six call sites fails them (11-33 cases each). Closes stablyai#13082 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ai#20090) * fix(grok): stop SessionStart orca-status hook hanging for 10s Grok writes one JSON hook payload and waits for the process to exit without closing stdin. The POSIX hook used `cat`, which waits for EOF, so SessionStart deadlocked until Grok's 10s timeout: session_start hook (global/orca-status) failed, ignored: timed out after 10000ms Read one JSON object with raw_decode instead; return as soon as the object is complete. Fall back to cat when Python is missing. * fix(grok): decode the hook payload incrementally instead of per chunk The JSON stdin reader strict-decoded the whole accumulated buffer after every read and caught only json.JSONDecodeError. A multi-byte character split across two os.read calls therefore raised UnicodeDecodeError, which is a ValueError but not a JSONDecodeError, so the interpreter died — after consuming stdin. The `python3 || python || cat` chain then handed the next reader a truncated stream, and `cat` blocked on a pipe the caller never closes, reinstating the exact 10s SessionStart timeout this reader exists to avoid. Measured: a CJK+emoji payload written byte by byte hung until it was killed; a 200KB payload split mid-character arrived as 4 bytes. Hold the decoder across reads, treat any ValueError as "not complete yet", and guard the whole program so a non-zero exit implies stdin was never read — only then is the `||` fallback safe. Emit the object's own text rather than re-serialising it, which was rewriting non-ASCII as \uXXXX. Skip leading whitespace, which raw_decode does not. Separate the first-byte wait (5s) from the idle wait (1.5s) so a writer that is merely late is no longer dropped. Also unset a HOME that does not exist before spawning the interpreter: macOS resolves /usr/bin/python3 through an Xcode stub that re-runs its whole tool lookup without a reachable cache, costing 6.6s per spawn and overrunning Grok's budget on its own. This is what made the existing large-payload and empty-PATH lifecycle cases fail. The Python program now lives in a shell variable instead of being inlined twice, which halves the generated script and keeps it readable. --------- Co-authored-by: Neil <neil@stably.ai>
…f one subprocess per event (stablyai#20593)
…ate the daemon thread (stablyai#15319) node-pty's CustomWriteStream retries an EAGAIN write with setImmediate, which re-attempts within microseconds. A pty whose child has stopped draining stdin keeps that branch EAGAIN-ing, so the retry becomes a busy-loop on the thread that owns every pty on the runtime. Measured against this commit's parent on macOS arm64: 121,316 EAGAIN/s at 101.6% CPU, versus 805/s at 4.1% with the retry paced to 1ms. The delay is 1ms rather than longer because the cost lands on readers that drain in bursts -- what an agent does between event-loop ticks. Delivering 2MB to a reader that drains 20ms out of every 100ms: 689ms unpaced, 907ms at 1ms, 1414ms at 5ms. 1ms keeps essentially all of the CPU saving without the delivery regression. clearImmediate -> clearTimeout in dispose() is required, not cosmetic: once the handle is a Timeout, clearImmediate does not cancel it and a pending retry can fire after dispose. The disposal guards that make that harmless (_fd = -1, queue drop) are already on main; this mirrors them into src/unixTerminal.ts so the TypeScript twin no longer drifts from the compiled lib. Scope: this fixes the CPU saturation. It does not stop other terminals from being serviced -- a second live pty kept answering echo round-trips throughout the storm in every configuration tested (1 and 8 stalled writers, macOS and Linux, 8 CPUs and 1), with throughput down ~20-50% rather than hung. The "every terminal froze" symptom in stablyai#11178 has another cause and that issue stays open. Upstream chose setImmediate deliberately (microsoft/node-pty#831, stablyai#833) to fix large-paste latency, and rejected polling POLLOUT because it reports writable rather than flushed. That reasoning targets a per-write delay in an interactive terminal; this delays only the EAGAIN branch in a long-lived daemon. Pastes to a draining reader are unaffected (0-3 EAGAINs per MB in every arm). Verified: patch applies to a pristine node-pty@1.1.0 tarball, the patched src/unixTerminal.ts compiles byte-identical to the patched lib/unixTerminal.js, patch_hash matches the file, and on Windows the changed code never executes (WindowsTerminal, 0 EAGAINs on a 300KB conpty write).
…i#20563) * fix(mobile): two known main bugs the RPC migration preserved A malformed host `error` and a null settings result both reach a property read that throws. Both are deliberate behaviour changes; the goldens move in the follow-up commit. `hostReplyErrorTextOrFallback` passed a truthy non-string through under a `string` annotation. Its one caller is the in-band `git.commit` failure, and every consumer of that text is display or prompt copy: `use-mobile-create-pr-runner` and `PrSidebarCreateEmptyState` record it as a commit failure, `use-mobile-commit-failure-recovery` hands it to `summarizeCommitFailure`, which starts with `raw.slice(...).replace(...)`. So no consumer needs the value, and the decision is the fallback rather than `String(value)` — the relay handler declares `commit(): Promise<{ success: boolean; error?: string }>`, so a non-string is a malformed reply, and `generatedCommitMessageReader` in the same domain already reads a non-string host error as absent. The parameter stays `unknown`, which it honestly is, and the `SAFETY` cast is gone. `useNewWorkspaceRuntimeContext` read settings through `settingsRead`, whose reader preserves main's `boxed!.settings` throw, so a `null` or absent result threw a TypeError out of the effect — losing the trusted-hooks publish and the available-provider computation that follow it, not just the settings. It now uses `optionalSettingsRead`, the operation that already reads a null or absent result as absent settings, so the reply degrades exactly the way a reply with no `settings` member does. Reply-side only: same method, same params, same barrier, no wire change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens the two bug fixes move Baseline bumped to 3f71999. Two goldens move an observation; the other 151 move only `baseline` and `recorderSha256`, which `pilot-scenarios.json` is still digested into. Observation moves, one claim each: - `matrix-hostedreview.create-intent-git.commit-1`, partition `inner-false-object-error`: `settlements.run.value.error` and `state.outcome.error` go from `{"message":"inner refused"}` to `"Commit failed"`. A non-string in-band `git.commit` error is a malformed reply and now reads as the screen's copy, converging with `result-absent`, `result-null` and `outer-refused-no-message`, which already reported the fallback. The other ten partitions at this site are unchanged. - `matrix-settings.workspace-context-settings.get-1`, partitions `result-null` and `result-absent`: the `unhandled-rejection` TypeError effect (`reading 'settings'`) is gone and `state.providers` goes from `[]` to `["github"]`. The effect no longer aborts the rest of the hook, so the provider computation runs; `state.settings` stays null because nothing was published, which is how a reply with no `settings` member already degraded. The other nine partitions are unchanged. Header-only moves: - 9 goldens of the `settings.workspace-context` family rename `namedDeltas` from `new-workspace-runtime-context-null-settings-typeerror` — the name now lies, the TypeError is fixed — to `new-workspace-runtime-context-null-settings-degrades-to-absent`. - All 153 move `baseline` and `recorderSha256`. The digest covers `pilot-scenarios.json`, so the baseline bump and the rename re-digest every file. No sender recording moved: both fixes are reply-side, and no golden's `sender` or `payloads` field differs. The README paragraph that claimed the settings TypeError was preserved is updated, and now records that the `ui.get` leg of the same hook still is. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): degrade a null ui.get result the way the settings leg now does One host answers both legs of useNewWorkspaceRuntimeContext, so fixing only settings.get left the likelier failure in place: a null or absent ui.get result still threw `reading 'ui'` out of the effect, skipping the provider commit. Review follow-ups on the same files: reply() returns the literal uncast and the stub client is FakeSession, dropping two assertions and their SAFETY disables; the degradation cases now assert absolute state instead of comparing mounts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens the ui.get leg fix moves Observational move, 1 golden: - matrix-settings.workspace-context-ui.get-1: the `result-absent` and `result-null` partitions drop their `reading 'ui'` unhandled-rejection effect and their state commits `providers: ["github"]` instead of `[]`, because the effect no longer throws before the provider commit. Header-only moves, 153 goldens: `baseline` to the fix commit and `recorderSha256`, which covers `pilot-scenarios.json` and so re-digests on the delta rename. The delta is renamed `new-workspace-runtime-context-null-settings-degrades-to-absent` -> `new-workspace-runtime-context-null-results-degrade-to-absent` (9 goldens): it now covers both reads, not just settings. README updated to match. No sender recording moved: resolving the value pool across all 153 goldens shows `sender` and `payloads` byte-identical everywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): name the ui.get result shape so the changed cast carries a rationale The inline union wrapped over four lines and tripped the changed-code casting gate as a new assertion; a named alias keeps the cast on one line under a SAFETY note. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the golden baseline to the cast-rationale commit Header-only: `baseline` on all 153 goldens. The re-record is inert — no golden moves observationally and no field other than `baseline` changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ui.get trust blank, and correct two stale acceptance comments The goldens cannot catch a regression to `if (uiResult?.result)`: the scenario's success reply is `{"ui":{}}`, so every partition of matrix-settings.workspace-context-ui.get-1 records the same `trust:{}` state. The new case answers once with real trust and again with a null result on a fresh client, which is the only shape where skipping the blank is observable — trustedOrcaHooks gates the setup-hook approval prompt in use-new-workspace-create-submit.ts, so a stale value would skip it. settingsRead's comment still claimed workspace context, which this branch moved to optionalSettingsRead; both comments now name their real callers. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the golden baseline to the trust-blank commit The record fence rejected the previous pin ("Product sources or lockfile differ from the pinned main baseline"), so the branch was no longer re-recordable. Header-only: `baseline` and `recorderSha256` on all 153 goldens — the digest covers pilot-scenarios.json, whose only edit is that baseline. The re-record is inert: 0 goldens move observationally and no other field changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the right operation per refuse-after-data probe Three of the five probes read through optionalSettingsRead, not settingsRead: repo metadata and resume metadata already did, and workspace context does as of this branch. The sentence now splits them and states why the split does not move what the probes record. Markdown is excluded from recorderSha256, so no re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the recorder's unhandled-rejection capture This branch removed the last two goldens that recorded an unhandled-rejection effect, so nothing exercised unhandled-recording.ts any more: gutting the emit to `void captureError(error)` leaves all 153 goldens comparing clean. The unit test drives a detached rejection through the window and asserts both the effect and the listener restore. README says so where it describes the capture. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-digest the goldens for the new recorder test Header-only: `recorderSha256` on all 153 goldens, which covers every non-markdown file under rpc-recording/ and so moves for the added test file. The re-record is inert: 0 goldens move observationally and no other field changes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…whole manifest (stablyai#20562) * refactor(mobile): pin each golden to its own scenario input, not the whole manifest `recorderSha256` covered the recorder directory plus `pilot-scenarios.json`, so every golden's header was a function of every other family's scenarios. Adding a family for one domain re-digested all 153 goldens and put a conflict on that line in every domain branch in flight, which serialized the step-4 fan-out. Split the two things it conflated. `recorderSha256` now covers the recorder directory only, with unchanged semantics: a recorder edit still forces a full, deliberate re-record. A new `scenarioSha256` pins the scenario input that golden was recorded from — every scenario `runRecording` consumed for it, in order — canonicalised through `captureValue` so an explicit-undefined param stays distinct from an absent one. `goldenRecording` takes that list instead of just its first member. The variants are hashed rather than the base they expand from because they are what was recorded: a matrix site, its replayed normal result and its partition replies are all visible in them without the derivation having to be restated. `derived-goldens.ts` is that derivation, extracted from `family-recordings.test.ts` so the digest and the recording agree by construction — a property test that restated how a matrix or schedule expands could agree with itself and with nothing else. It reproduces exactly the 153 golden ids on disk, and the census the suite already ran (every family matrixed, no stale normal-result inventory entry) now reads off its output. `golden-header-digest.test.ts` pins the four properties: - a new family in the manifest moves zero existing goldens' headers, and derives two of its own - editing one field of `b1` moves exactly `b1` and its family's four matrix goldens — not the two other `legacy-inventory` scenarios, and not the goldens that expand from `inventory-lifecycle` - editing a recorder file still moves every golden's `recorderSha256`, and no `scenarioSha256` - `recorderSha256` is unchanged by the manifest's contents, and no longer reads the file at all `GOLDEN_FORMAT_VERSION` goes to 4: a version-3 header has no `scenarioSha256`, and `compareGolden` walks the expected header's keys, so a reader that accepted one would compare that golden's own scenarios as though they were unpinned. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 RPC goldens for the split digest Recorder edit, so every golden needs rewriting. Recorded from the pinned baseline `16d1ab81d3` with this branch's recorder overlaid, per the README's procedure: main has moved past the baseline, so recording in place would have failed the product-source fence. Three header fields moved and nothing else did: - `recorderSha256` 6a12160a87… -> 2fda557f58…, one value across all 153 files - `scenarioSha256` added, 153 distinct values - `goldenFormatVersion` 3 -> 4 No observation, checkpoint, value-pool entry, `baseline`, `lockfileSha256` or `platform` changed: git diff -U0 -- mobile/rpc-foundation | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \ | grep -vcE 'recorderSha256|scenarioSha256|goldenFormatVersion' 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): certify the pilot goldens from the derivation that digests them `pilot-recordings.test.ts` restated `[scenario]` instead of consuming `pilotGoldens`, so the claim that a golden's `scenarioSha256` is a function of the same derivation that records the file held only for the 75 family goldens: dropping a scenario from `pilotGoldens` left the whole suite green and put that golden outside the header oracle. The pilot suite now iterates `pilotGoldens`, and a census fails if the derivation and the goldens directory disagree in either direction — which also closes the pre-existing orphan-golden gap. Also from review: pin the cross-sibling replay that hashing the generated variants buys (a matrix golden's `normal` partition replays a sibling's recorded reply, so editing that sibling must move it); state the real reason for the format bump, which is the diagnosis a version check gives rather than a rejection the byte compare already made; drop the fourth property test, which re-proved what tests 1 and 2 and `recording-runner`'s digest test already fail on; and drop a guard in `scenarioSha256` that its only caller reaches after an identical one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 RPC goldens for the review edits Recorder files changed, so `recorderSha256` moved. Recorded from the pinned baseline with this branch's recorder laid over it, per the README's migration-branch procedure. That one header field is the only line that moved in all 153 files: `scenarioSha256` and `goldenFormatVersion` are unchanged, and no observation moved. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct the recording suite's test count Round-2 review: the README said 200 tests; the suite is 209 after the five added here. Markdown is outside recorderSha256, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the 153 goldens on the merged baseline Four header fields moved and nothing else. Proven against origin/main: every changed line in all 153 files is one of these, and the file set is unchanged. - `recorderSha256` 70aa6f59e0 -> 58a461dbc9: this branch's recorder, and it now digests only the recorder directory, not the scenario manifest. - `scenarioSha256` added, 153 distinct values over 153 goldens. - `goldenFormatVersion` 3 -> 4 for that added field. - `baseline` 5ec0b26 -> e53f155, the merge's repoint onto the real main commit. stablyai#20563's value was a branch commit the squash left unreachable, so the record fence's `git diff <baseline>` could not resolve it. No checkpoint, value pool, effect or settlement byte moved, so main's recorded behaviour is carried over intact. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): account for main's added recorder test in the suite count The merge brought in `unhandled-recording.test.ts`, one test, so the recording suite is 210 rather than the 209 this branch documented. Markdown is excluded from `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…r past unapplied rows (stablyai#20581) * fix(native-chat): stop a bounded tail read from moving the chat cursor past unapplied rows A structured chat pane could latch "Working for N" forever after the agent had finished, showing the send arrow rather than Stop, while the sidebar and `worktree ps` correctly read idle. The client replica has one position (`state.cursor`) and one body. Two operations keep those consistent: replace (both from one host snapshot) and append (rows contiguous with the cursor). The `tail-page` branch was a third thing: it took the cursor from the journal head, the items from a bounded page (200 items, byte-capped), then merged retained client submissions over the page's. Under continuous journal writes the client is always slightly behind, so the branch ran on every window focus and on every pane re-activation. When more than a page of rows had landed since a send, that send's user item fell off the page, its submission was not carried, the retained `pending` survived, and the cursor jumped past the dispatch-acceptance row. Nothing re-sends it: a batch carries only touched items and that submission is never touched again. Delete the third operation rather than guard it. A live subscription is now the only thing that moves the cursor, and `subscribe({ cursor })` already replays exactly the missed rows. - remove the window `focus` listener and the owner/transport `refresh` contract - skip warm hydration: a retained owner subscribes at its applied cursor - cold hydration keeps its history read, applied as the existing `snapshot` (replace) event rather than `tail-page` - delete the `tail-page` action and its reducer branch - delete `resumeCursor` and `shouldAdvanceStructuredResumeCursor`; two cursors with two advancement rules were how position and body drifted apart `older-page`/`loadOlder`, the unattached-refusal grace, generation guards and the coalescer are unchanged. No host, wire or schema change. Also fixes a second cost of the same branch: focus during a busy turn discarded paged-in older items, shrinking the transcript to one bounded page mid-turn. * fix(native-chat): preserve unavailable mixed-version session fences
…ed RpcOperations (stablyai#20568) * test(mobile): record main's task workspace-creation RPC behaviour before migrating it 28 scenarios over nine task senders, recorded from main so the step-4 migration of the workspace-creation half of src/tasks/ has a frozen answer to compare against. Four senders mount as plain exported functions; three are model-chained hooks mounted the way the settings adapters mount theirs. The 153 existing goldens change header-only (`baseline`, `recorderSha256`): any new scenario re-digests the recorder, and the pinned baseline had drifted from main because the source-control migration landed. Content is byte-identical on all 153 — verified field-by-field against HEAD. `operation-module-loader.ts` now shares src/transport/rpc-delivery-ambiguity.ts with mounted modules instead of evaluating a second copy. The mark is a WeakSet keyed on the rejection object, so the copy the loader built had an empty registry and every delivery-unknown rejection read as a definite failure inside the operation under test — worktree.create's whole replay path was unreachable. With one registry, `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms, while the unstamped-create scenario records the same rejection surfacing at 0 ms. No existing golden moves: no other mounted module consumes the mark. `task-preferences-optimistic` is re-anchored above the send rather than across it, so migrating this file does not have to move the anchor. It still kills, and for the same reason: the preset the screen shows no longer follows the tap. Scenarios deliberately pin the empty-message refusals (`*-refused-empty-message`, `*-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. Goldens: 153 -> 201, 2.9M -> 3.7M. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): format the recording manifest and re-digest the goldens `oxfmt --check` from mobile/ collapses a one-element `sites` array in each new scenario. The JSON value is unchanged — verified by comparing both files parsed and key-sorted — but the manifest is inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field, header and observation alike, is byte-identical. Re-recorded in a separate worktree at the previous commit so the goldens stay attributable to main's product source rather than to the migration that follows. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): separate the goldens from the migration, and re-digest The previous commit accidentally carried the product migration alongside the manifest format, which both broke the commit that is supposed to prove parity and left the suite red: the digest was recorded without a comment move that a lint fix had made inside the adapter, so all 201 goldens failed their `recorderSha256` header. This backs the product half straight out again — the next commit re-applies it byte-for-byte — and re-records from the pinned baseline in a separate worktree carrying this branch's recorder, per the procedure in the recording README. Every field except `recorderSha256` is byte-identical to the previous commit's goldens on all 201 files, so no observation moved in either direction. The suite is green here with main's product source, which is what makes the next commit's "no golden changed" claim mean something. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the task workspace-creation domain through typed RpcOperations 12 of src/tasks/'s 37 raw-port files now send through a declared operation instead of the raw request port: 36 references to 0, leaving 25 files and 73 references for the provider item/detail/mutation half. No golden moved — `git show --stat` on this commit touches nothing under mobile/rpc-foundation/, which is the parity claim. Twenty-two operations over twenty methods, in four modules named for what they send: workspace create (create, PR/MR base resolution, create-time capabilities), workspace source (SSH connect/state, agent detection, repo hooks, sparse presets, ref search), task runtime (status, ui.get/ui.set, preflight, Linear status, settings.update) and the Smart picker's provider reads. Two methods carry two policies each, and both pairs are named. `status.get`: the Tasks screen cannot hydrate without it and surfaces the host's message, while create-time capability probing degrades to "no capabilities" and creates anyway — so one throws on refusal and one skips. `ui.set`: two sites await it, one is fire-and-forget and never interprets the reply at all. Both pairs share one reader, so no method has two readers. No new acceptance policy. worktree.create keeps its delivery-unknown contract. `request` returns the transport promise itself, so the retry loop catches the object the transport marked; two new tests assert `toBe(marked)` in one direction and that an unmarked rejection stays unmarked in the other, because a mark added on the way out would replay a create the host never received. `tw-create-retry-ambiguous-after-drop` records the create still pending at the reconnect wait and abandoning at exactly 20000 ms. Three sites still read the raw refusal envelope before interpreting, because the code or the message decides the route and no acceptance policy carries either through: the create retry needs the message for `isRetryableWorktreeCreateConflict`, and the paste lookup needs `method_not_found` to retire the slug probe host-wide. Both are documented at the site. The hydration barrier keeps raw requests inside its `Promise.all`. main's group rejects as soon as one leg rejects; `startRpcOperation` + `interpretAtRpcBarrier` would wait for the slowest peer and let a later policy surface a different error. Interpretation stays after the `stale` guard, where it was. `WorkspaceCreateParams` is now `RpcSendParams<'worktree.create'>` rather than `Record<string, unknown>`, which types the builder and the operation together; every field the three builders already sent typechecks against the host schema unchanged. `RpcSendArguments` now also makes params optional for a method whose params type has no required field, because `preflight.check` is such a method and main sent it none — requiring `{}` would have put a new object on the wire. The Mobile Tasks source-parity hashes move for the same reason bound settings requests moved them: the method string and the envelope read leave the screen. The signature diff is evidence rather than a re-pin — `semantics` is a pure deletion of 22 `rpc:` call signatures and 22 method literals with nothing added, statement/declaration/render/style counts are unchanged, and render tokens, styles and declarations are byte-identical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the task workspace adapters at the sender/hook seam The single adapter file reached 344 lines against mobile's 300-line limit. CI lints every file, so this is red there even though the changed-code gate does not report it. Split along the seam the recording README already draws: exported async senders that take a client and need no React host, and the drawer's three model-chained hooks. No adapter body changed. Both files are inside `recorderSha256`, so all 201 goldens carry a new digest. Every other field is byte-identical, verified file by file. Re-recorded from the pinned baseline in a separate worktree carrying this branch's recorder, so the goldens stay attributable to main's product source. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the one-key unchecked reader a name Four readers were the same three lines: read one property off the reply, wrap it unchecked. `rpcUncheckedMemberReader` is the one-key sibling of the existing `rpcUncheckedPayloadReader`, so the annotation and the closure go away at each site. The pilot's `commitCompareEntriesReader` is converted too, so the helper has no longhand twin left to copy from. No behaviour change: the helper composes the same `rpcReadUnchecked` over `rpcPayloadMember`, including the property-read exception on a null result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the local arm of workspace agent detection `preflight.detectAgents` was the one migrated operation with no recorded coverage: the ssh adapter hardcoded `connectionId: 'ssh-1'`, so the detection effect's ternary only ever took the remote arm and the local call site could be repointed at another method without a golden noticing. The adapter now takes the connectionId as a parameter and registers twice; `tasks.workspace-ssh-local` mounts the same hook with no connection, which is the only difference the effect branches on. Recorded at the pinned baseline with this branch's recorder laid over it, so the new golden is main's behaviour and the migrated code has to reproduce it — it does. Goldens: two added (`tw-workspace-ssh-local-agents` and its reply matrix). The other 201 changed on `recorderSha256` only, because the adapter edit moves the recorder digest every golden pins. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drive workspace create and the Linear list to a recorded wire Two operations passed a policy swap unnoticed, both because no golden reached their acceptance branch. `worktree.create`: the create hook's fixture resolved setup to a prompt, so all three settings.task-workspace scenarios stopped before the request and the only consumer that hands a refusal to interpret was never recorded. The adapter now takes the setup resolution as a parameter and registers a second family that resolves it, so createWorkspace runs to the wire. Two scenarios: a Linear item that creates directly, and a GitHub pull request that resolves its base first, which also puts this hook's built params — start point, generated display name, agent launch fields — in a golden for the first time. The existing prompt family is untouched, so its recordings still pin that branch. `linear.listIssues`: it appeared only in a non-base scenario, and the matrix reads the family base, so the family had no partition for it. The base now lists assigned issues after searching. Goldens: five added. Five moved beyond the digest, all derived from the smart-search base that gained the list leg. The other 198 changed on `recorderSha256` only. Recorded at the pinned baseline with this branch's recorder laid over it, so every new golden is main's behaviour. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): drop the unreachable unmount branches from the task adapters Nothing dispatches `unmount` to these three adapters: the only producer is `lifecycleSchedules`, driven from a hardcoded five-id list that names no task-workspace family, and it pushes a `remount` right after, which these adapters would throw on. The branch read as lifecycle coverage that was never wired up. `dispose: hook.unmount` already tears the mount down. Goldens re-recorded at the pinned baseline because the recorder digest moved; `recorderSha256` is the only line that changed in all 208. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at main's post-squash baseline Recorded from a detached checkout of e53f155 (main's unmigrated product code) with this branch's recorder laid over it, so the parity claim stays non-circular. - `baseline` repinned to e53f155 on all 208 goldens; main pinned 5ec0b26, a pre-squash branch commit not reachable from main. - `recorderSha256` moved on all 208 because this branch's adapters are in the whole-manifest digest. - 55 task-workspace goldens re-recorded at the new baseline. - No other line in any of main's 153 goldens changed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens under stablyai#20562's per-scenario digest Baseline repinned to 50e752f and all 208 goldens recorded from that commit's unmigrated product tree with this branch's recorder laid over it. recorderSha256 moves on every golden because the task-workspace adapters live in the recorder directory. scenarioSha256 does not move on any of main's 153: the manifest only adds 31 scenarios and edits none, which is the property stablyai#20562 was built to give. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
…anner service behind a setting (stablyai#20516) * feat(ai-vault-search): persist agent-session search consent and retention Two booleans and nothing else: `enabled` and `historyDays`, off by default because building the index reads every transcript on the machine. No `paused` -- the PR 3 indexer is immutable, so every change is close-and-construct. The settings IPC normalizes a write like every other field and hands the change to the index; there is no UI for it until PR 8. * feat(ai-vault-search): hold one indexer and engine pair per host The object that owns a host's live index and the three recipes that change it. The indexer is immutable, so a settings change is close-and-construct, disabling is close with no replacement, and clearing is close, remove the database, construct. The new instance's first sweep purges a narrowed window and admits a widened one, so neither needs a code path. The database sits beside the scanner's parse cache, one file per host. A runtime with no node:sqlite can hold no index at all, which the Node 18 floor on orcad and the relay makes a real case rather than a hypothetical one. * feat(ai-vault): let the scanner child own the session search index The transcript reader runs in that child, so the index consumer has to as well: one read serves both the session list and the index. Three request operations (search, status, reconcile) and one fire-and-forget settings message carry everything a parent needs; main never opens the database file. The init frame becomes a factory because it is read at every spawn, so a respawned child sees current consent rather than the first frame's. A child holding a running index is never idle from the parent's side, so idle retirement is suppressed while the index is on -- retiring it would stop the reconcile loop until some later scan happened to respawn one. Both files this lands in were already at the max-lines ceiling, so three collaborators move to where they belong rather than being disabled around: the invalidation deadline into the class that owns invalidations, call cancellation and the start requeue into the call-state module, and orcad's flag parsing into its own file. * feat(ai-vault-search): register a search service on every host that answers Without a registered service a host answers no-service, which means "this host does not have the feature" rather than "the index is off". All three hosts now answer the second thing. The desktop forwards to the scanner child. orcad and the SSH relay daemon have no such child -- orcad ships only the watcher and daemon entries, and the relay's AI Vault sidecar runs the remote scanner, which publishes nothing to the transcript channel -- so on those two the index lives in the process that would drive its reads, gated on a runtime that has node:sqlite at all. The relay registers with consent off and no way to turn it on: nothing carries a setting to a remote host yet. That is the honest state, and it is still worth registering, because it is what tells a client the difference between off and too old. * test(ai-vault-search): price a warm pass over five thousand transcripts The number the reconcile interval will be revisited against, measured rather than argued: a warm sweep stats every file under every root, a warm cycle stats the newest N per agent, and neither reads what the index already holds. It does not tune the interval. * fix(ai-vault-search): answer the casting gate without assertions main's new type-assertion rule reaches every file this branch touches. All nine sites drop the cast rather than carry a SAFETY: rationale: the operation guard narrows with `in`, the sqlite probe narrows the builtin it loads, the child test keeps the discriminated reply instead of widening it, and the settings resolver takes `unknown` -- which is what it really reads, since a persisted profile can hold a value no version of this code wrote. * fix(ai-vault-search): let a refreshed scan root reach the live index The parent re-resolves scan roots before every policy push, precisely so a WSL distro or extra Codex home that appeared since the child spawned enters the window. The child forwarded only the settings to a live instance and used the roots solely in its `??=` initializer, so those roots were dropped for the child's lifetime. The indexer stays immutable: a structurally different root set closes the pair and constructs a new one, the same way a changed databasePath already does. Compare via `sameSessionSearchRoots` rather than a plain JSON compare, because nothing fixes the key order two producers write; lists are sorted too, since the indexer walks every root and a re-enumeration that reorders is not a change. An unchanged set still never restarts a running index. The orcad and relay in-process hosts resolve roots once at install and never re-apply, so they have no such seam. * fix(ai-vault): restart the scanner child the index is holding Three review items. The hold keeps a child alive for the index, but only a queued call ever started one: `pump()` skipped a hold with an empty queue, so an idle indexing child that crashed, or an `ensureChild()` that failed at start, left indexing stopped until an unrelated request happened to arrive. `pump()` now starts the child the hold requires, which is also the restart callback the fault policy already schedules, so the existing delay and circuit bound the retry exactly as they bound a queued call's start. `updateSessionSearch` goes through the same seam instead of its own `ensureChild` call. A search registers no AbortController, so a cancel sent for a search id was added to the `cancelled` set and never consumed. Nothing can reach that today -- no caller passes a signal and the child answers in milliseconds -- so this is only a leak of ids: consume it when the search settles. The orcad argument doc claimed a `--`-prefixed value stays a flag. The parser takes the next token regardless, and orcad-launch-contract.test.ts pins that, so the doc is what was wrong. Behaviour is unchanged. * fix(ai-vault): recover search indexing and refresh scan roots * fix(ai-vault): defer search refresh policy reads * fix(session-search): stabilize paging and host enablement * fix(session-search): refresh host roots within full sweeps * docs(session-search): clarify initial root fallback
* fix(native-chat): hide activity while awaiting input * fix(native-chat): keep approval turns cancellable * test(native-chat): satisfy split PR quality gate * fix(native-chat): catalog approval cancellation label * fix(native-chat): include approval cancellation runtime label * fix(codex): settle prompts when cancelled turns complete * fix(codex): settle prompt registry fallbacks * test(native-chat): cover pending interaction fallbacks * test(native-chat): split prompt state coverage * test(native-chat): keep prompt state isolated * fix(native-chat): bound prompt turn backfill * refactor(codex): centralize prompt registry bounds --------- Co-authored-by: Merge Sim <sim@local>
…auto-sync-fork-with-upstream-stablyai-orca-ru-20260916T1400
Resolve every differing path to its declared owner from config/fork-ownership.json: 904 reset to the tag, 17 removed, 155 restored fork-side, 1191 three-way merged. Ownership work beyond the mechanical lists: - Withdraw two `deleted: true` exceptions. v1.4.204's new native-chat-composer-workspace-file-drop.test.tsx imports NativeChatComposerActions and NativeChatComposerField, which the fork deleted. Restore both from the tag and drop their exceptions. Costs no fork behaviour: NativeChatComposer.tsx renders AgentComposerField from fork-agent-composer, so upstream's copies are live only for upstream's own test. - Wire upstream's new useNativeChatWorkspaceFileDrop into the fork's composer. The fork restructured NativeChatComposer.tsx, so `-X ours` dropped the hook. The fork has no workspace-file-drop of its own, so this is additive. - Re-home the skill-discovery seam. Upstream moved the WSL parse loop into the new skill-discovery-wsl-observation.ts, and `-X ours` left both implementations live with a duplicate discoverSkillsInWsl and unbalanced parens. Restore the tag and replay the fork's delta across both files; repoint the attribution seam and both residuals. - Re-home the ask-question notification seam. Upstream extracted the dispatch body into the new notification-delivery-service.ts; notifications.ts returns to the tag exactly. - Repair five more seams `-X ours` damaged by defending the fork's stale copy of code upstream reworked: notifications.ts, register-core-handlers.test.ts, discovery.ts, main-process-runtime-service.ts, use-native-chat-composer-attachments.ts, protocol-version.ts and tsconfig.cli.json. Each is a real three-way merge against v1.4.203, and every one lands back on its recorded residual budget. use-native-chat-composer-attachments.ts was carrying upstream's old 97-line attachResolvedPaths implementation verbatim; it now uses upstream's extracted useNativeChatResolvedPathAttachments (3-arg, which the drop hook needs) and keeps only the fork's restoreImageAttachments. - Keep upstream's @streamparser/json dependency in package.json. The fork exception owns the version line only; pnpm-lock.yaml is upstream-owned and resolves to the tag, so dropping the dependency would fail --frozen-lockfile. - Regenerate rpc-params-catalog.generated.ts so the fork's ask.*/ledger.* methods survive. - Tier-2: no recorded source changed between v1.4.203 and v1.4.204; advance all 16 FORK-COPY-SHA headers to the tag. Verified: --verify-seams 910/910 across 427 files, --verify-residuals clean.
Three typecheck failures from the v1.4.204 resolution, all in the native-chat composer's attachment path. - use-native-chat-composer-attachments.ts: drop the now-unused useLayoutEffect. Upstream moved the disabledRef bookkeeping into the extracted useNativeChatResolvedPathAttachments, so the fork's copy of that effect went with it. - use-native-chat-composer-attachments.test.tsx: the fork was carrying a simplified copy of upstream's probe harness, which v1.4.204's new ownership cases cannot drive — they need draft(), notice(), and renderProbe's third options argument. Restore upstream's harness and its ten cases, and re-apply the fork's own delta on top: the terminal-tab-scoped resolved target and the four attachment-cache cases (restore, rescope, cross-host handoff, throwing subscriber). Coverage goes from 11 cases to 21; no fork case is lost and upstream's composition cases come back. - native-chat-composer-workspace-file-drop.test.tsx: v1.4.204's new drop test builds a NativeChatResolvedTarget without terminalTabId, which this fork requires. Supply it, matching the terminal-tab-1 the same test already passes to the drop hook, and declare the edit as an input-quarantine seam with its measured +7/-1 residual. Gate after the fixes: install, --verify-seams (911 lines / 428 files), --verify-residuals (409 files), typecheck:node, typecheck:web, typecheck:cli, and the full pnpm lint all pass.
…test
`test / tests node 24 2/8` failed on
skill-discovery-wsl-alias-sharing.test.ts > "refresh, cache clear, distro and broader
root requirements are isolated":
expected "vi.fn()" to be called 1 times, but got 0 times
src/main/skills/skill-discovery-wsl-alias-sharing.test.ts:102:22
The test is new in v1.4.204 and mocks './claude-plugin-skill-sources-wsl', then asserts the
spy ran once. This fork's skill-discovery-wsl.ts carries a declared import-swap seam that
calls discoverLiveClaudePluginSkillSourcesInWsl from fork-live-plugin-marketplaces/ instead,
so upstream's mock never intercepted it: the spy sat at zero calls while the real fork reader
ran behind it. The sibling assertion on the same line (io.run called 5 times) passed, which is
why only this one case failed.
Mock the fork module onto the same spy beside upstream's, and declare it as a
live-plugin-marketplaces import-swap seam with its measured +5/-0 residual. The assertion is
untouched: the fork's call sits inside the same sourceKinds guard as upstream's, so the
expected call count is upstream's.
Also correct one exception reason this sync invalidated. native-chat-composer-composition.test.tsx
said NativeChatComposerField is a module "this fork deletes"; the fork restored it earlier in this
sync, so the reason now says the fork does not render it in the chat composer. The deletion of the
test itself still stands — the fork's coverage lives in fork-agent-composer.
--verify-seams 913 lines / 429 files, --verify-residuals 410 files, both pass.
zpyoung
added a commit
that referenced
this pull request
Sep 17, 2026
… resolution Four rules this run had to derive, each in the step that owns it. Step 2: check that $PREV_TAG is still an ancestor of main. #82 was squash-merged, so v1.4.204's content is on main but its commit is not in the lineage, and `git merge` silently resolved v1.4.205 against a v1.4.203-era base — 2,536 differing paths and 287 conflicted files for a one-release step. Nothing else reports it. Step 11: count the parents of the merged tip. That is the only check a squash fails; `merge-base --is-ancestor $UPSTREAM_TARGET origin/main` passes either way, which is why #82 went unnoticed for a day. Step 8: tag the resolution before the abort reset. An escalating run has usually produced a complete resolution that only a human decision blocks, and the tag outlives the worktree, so the rerun starts from it. Step 4: the ambient-pnpm lockfile rewrite is machine noise, not someone's work. package.json's packageManager pin is not self-enforcing here, so the setup install can rewrite the pnpm-12 lockfile with pnpm 10; restore that one file and run behind a corepack shim rather than stopping the day. Also repairs a contradiction the lint-rule commit before this one left in place: that section's own get-ahead rehearsal still swapped only .oxlintrc.json, the thing it had just finished saying is not the question. And records that an upstream-side violation can be proved deterministic from three byte-identical inputs, without checking out the tag.
This was referenced Sep 17, 2026
zpyoung
added a commit
that referenced
this pull request
Sep 18, 2026
A stable tag can enable a whole rule family that `oxlint --fix` cannot rewrite. v1.4.206's anti-slop plugin turned on eight such rules; not one is auto-fixable, so the previous "only what --fix rewrites on its own" limit left a run with no legal move and stalled the v1.4.205 sync with every other check green. Adopting a new rule in fork-owned files is now in scope and is never an escalation by itself. The guardrail is upstream's own adoption commit for that rule, which the run has to read first — it ran the same rule over its own tree in the same release, so its renames are the reference answer for what the rule wants. v1.4.206 gave the whole vocabulary that way: isSkillsCliAgentKeyShaped -> isUsableSkillsCliAgentKey, RootShape -> RootLayout, and typed property access in place of Reflect.get. The limits now bound how the fix is written rather than whether it happens: names and types only, never a branch or a message; no --fix-suggestions or --fix-dangerously; never a config edit or a suppression entry. Two cases still need a person, and both are one file rather than the run — a rule upstream's own release violates in its own file (retarget to a newer stable tag first, which usually dissolves it), and a violation that cannot be cleared without changing what the code does. Also make Step 11's is-ancestor check load-bearing. PR #82 was squash-merged, so v1.4.204's commit is not in main's ancestry; the fork ownership guard falls back to the newest tag it can reach and has been comparing every PR against v1.4.203, reporting ~25 false residual drifts on PRs that touch nothing.
zpyoung
added a commit
that referenced
this pull request
Sep 27, 2026
…ge ancestry check load-bearing (#83) * docs(sync-skill): look for new lint rules outside .oxlintrc.json v1.4.205 left .oxlintrc.json byte-identical and added its eight new rules in config/oxlint-anti-slop.json, behind a new audit:anti-slop step in package.json's lint script. The documented diagnostic diffs only .oxlintrc.json, so it answers "no new rules" when there are eight, and the run reads the lint failure as merge damage instead. Diff every config/oxlint-*.json and the lint script too. The lint script matters twice over: package.json is a whole-file fork exception, so a new sub-step only reaches the fork's tree if the ours.txt audit three-way-merges it. Also record two hard limits the run had to derive under pressure: a rule family can be entirely un-fixable, so "only what --fix rewrites" can leave no legal move at all; and a violation in a file identical to the tag is upstream's release failing its own rule, which is a different decision from the fork-side ones and has to be named separately or the rerun re-escalates on it alone. * docs(sync-skill): catch a squashed predecessor, and keep an escalated resolution Four rules this run had to derive, each in the step that owns it. Step 2: check that $PREV_TAG is still an ancestor of main. #82 was squash-merged, so v1.4.204's content is on main but its commit is not in the lineage, and `git merge` silently resolved v1.4.205 against a v1.4.203-era base — 2,536 differing paths and 287 conflicted files for a one-release step. Nothing else reports it. Step 11: count the parents of the merged tip. That is the only check a squash fails; `merge-base --is-ancestor $UPSTREAM_TARGET origin/main` passes either way, which is why #82 went unnoticed for a day. Step 8: tag the resolution before the abort reset. An escalating run has usually produced a complete resolution that only a human decision blocks, and the tag outlives the worktree, so the rerun starts from it. Step 4: the ambient-pnpm lockfile rewrite is machine noise, not someone's work. package.json's packageManager pin is not self-enforcing here, so the setup install can rewrite the pnpm-12 lockfile with pnpm 10; restore that one file and run behind a corepack shim rather than stopping the day. Also repairs a contradiction the lint-rule commit before this one left in place: that section's own get-ahead rehearsal still swapped only .oxlintrc.json, the thing it had just finished saying is not the question. And records that an upstream-side violation can be proved deterministic from three byte-identical inputs, without checking out the tag. * docs(sync-skill): make lint-rule adoption the run's job, not a stop A stable tag can enable a whole rule family that `oxlint --fix` cannot rewrite. v1.4.206's anti-slop plugin turned on eight such rules; not one is auto-fixable, so the previous "only what --fix rewrites on its own" limit left a run with no legal move and stalled the v1.4.205 sync with every other check green. Adopting a new rule in fork-owned files is now in scope and is never an escalation by itself. The guardrail is upstream's own adoption commit for that rule, which the run has to read first — it ran the same rule over its own tree in the same release, so its renames are the reference answer for what the rule wants. v1.4.206 gave the whole vocabulary that way: isSkillsCliAgentKeyShaped -> isUsableSkillsCliAgentKey, RootShape -> RootLayout, and typed property access in place of Reflect.get. The limits now bound how the fix is written rather than whether it happens: names and types only, never a branch or a message; no --fix-suggestions or --fix-dangerously; never a config edit or a suppression entry. Two cases still need a person, and both are one file rather than the run — a rule upstream's own release violates in its own file (retarget to a newer stable tag first, which usually dissolves it), and a violation that cannot be cleared without changing what the code does. Also make Step 11's is-ancestor check load-bearing. PR #82 was squash-merged, so v1.4.204's commit is not in main's ancestry; the fork ownership guard falls back to the newest tag it can reach and has been comparing every PR against v1.4.203, reporting ~25 false residual drifts on PRs that touch nothing.
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 release v1.4.204 (
4f7baefc4f5c49181d54046763e083a4628662d8) into the fork, resolving every differing path againstconfig/fork-ownership.json.Previous tag on
mainwas v1.4.203, so the range is v1.4.203..v1.4.204: 939 files, +193,249/-5,209, most of it additive upstream-ownedmobile/rpc-foundation/goldens.Resolution
auto-ours. A plain merge stopped on 12 content conflicts (no tree conflicts), so it was retried with-X oursand then resolved path-by-path against the manifest. No conflict markers survive.checkout.txtremove.txtours.txtmerge-review.txtFork commits: 434, every one still reachable at its original SHA (set-compared against
git rev-list HEAD). The Step 2 range re-run reports 435 — the extra is this PR's own ownership commit.Ownership work beyond the mechanical lists
Two
deleted: trueexceptions withdrawn. v1.4.204's newnative-chat-composer-workspace-file-drop.test.tsximportsNativeChatComposerActionsandNativeChatComposerField, which the fork deletes. Both restored from the tag and their exceptions dropped. This costs no fork behaviour:NativeChatComposer.tsxrendersAgentComposerFieldfromfork-agent-composer, so upstream's copies are live only for upstream's own test. The exceptions' stated risk — "would reintroduce a second, chat-only composer field" — is about the product path, which is unchanged.Upstream's new workspace file drop wired into the fork's composer.
-X oursdroppeduseNativeChatWorkspaceFileDropbecause the fork restructuredNativeChatComposer.tsx. The fork has no workspace-file-drop of its own anywhere underfork-*/, so adopting upstream's is purely additive.Two seams re-homed into modules upstream split out this release.
skill-discovery-wsl.ts: upstream moved the WSL parse loop into a newskill-discovery-wsl-observation.ts.-X oursleft both implementations live — a duplicatediscoverSkillsInWsland unbalanced parens, which esbuild caught buttscwould have reported far from the cause. File restored to the tag and the fork's +6/-3 replayed across both modules; theskill-plugin-attributionseam now points at the observation module, and both residuals were re-measured (+2/-2 and +4/-1).notifications.ts: upstream extracted the dispatch body into a newnotification-delivery-service.ts. Theask-question-toolseam moved with it;src/main/ipc/notifications.tsnow matches the tag byte-for-byte.Seven seams repaired where
-X oursdefended a stale fork copy. Each was re-merged three-way against v1.4.203, and every one landed back on its already-recorded residual budget — the budgets were right and the merge was wrong. The largest,use-native-chat-composer-attachments.ts, was carrying upstream's old 97-lineattachResolvedPathsimplementation verbatim plus a 9-line fork addition; it now uses upstream's extracteduseNativeChatResolvedPathAttachments(whose 3-argument signature the new drop hook requires) and keeps only the fork'srestoreImageAttachments.package.jsonkeeps upstream's new@streamparser/jsondependency. The fork exception owns the version line only.pnpm-lock.yamlis upstream-owned and resolves to the tag, so dropping the dependency would have failed--frozen-lockfilewith an error naming nothing about ownership.rpc-params-catalog.generated.tsregenerated so the fork'sask.*/ledger.*methods survive; the ownership reset had returned it to upstream's copy.Checklists
FORK-COPY-SHAheaders advanced to the tag.bug-1/ input-quarantine —pane-pty-visibility-bind.ts,NativeChatComposer.tsx,use-native-chat-composer-attachments.test.tsx. All resolved in content; manifest and ledger entries left consistent.agent-composeris possible — upstream landed its own native-chat drag-and-drop (5e70014da8,0c812843ef) beside the fork's composer. Resolved additively with zero fork cost, but flagged for a human. Every other feature: none.Fixes made to clear the gate
useLayoutEffectfrom the attachments hook (upstream moved that bookkeeping into the extracted hook).draft(),notice(), andrenderProbe's third argument. Upstream's harness and its ten cases are back, with the fork's terminal-tab-scoped target and four attachment-cache cases re-applied on top: 11 cases → 21, none of the fork's lost.terminalTabIdin upstream's new drop-test fixture, matching theterminal-tab-1the same test already passes to the drop hook, and declared it as aninput-quarantineseam with its measured +7/-1 residual.Verification
pnpm install --frozen-lockfile@streamparser/json)--verify-seams--verify-residualstypecheck:node/:web/:clipnpm lint(all 15 gates)Typechecks were run as the three projects CI's
pnpm typecheckactually covers (tsconfig.node,tsconfig.tc.cli,tsconfig.tc.web), sequentially, clearingconfig/*.tsbuildinfobefore each, perAGENTS.md.Tests are left to this PR's CI, per the sync skill — vitest does not run on the machine this sync ran on.
Pre-existing, not from this sync:
pnpm typecheck:e2ereports 203 errors across 101 files. It is not part of CI'spnpm typecheck(which runs only the three projects above) and not part ofpnpm tc. 100 of the 101 files are byte-identical to the tag and none were touched by this resolution; the one that differs,tests/e2e/cross-version-wire/release-checkout.ts, fails on an unrelatedTS5097import-extension rule. Filed to the Orca ledger rather than fixed here.Backup
origin/backup/main-20260916-140502Zatb9b5429f9c3358eeaa3477a1e78a946827a1ee7d.