Skip to content

fix(native-chat): wait for the runtime capability probe before resolving the creation launch route - #19819

Merged
brennanb2025 merged 8 commits into
mainfrom
brennanb2025/nc-launch-route-hydration
Sep 13, 2026
Merged

brennanb2025 merged 8 commits into
mainfrom
brennanb2025/nc-launch-route-hydration

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

If you created a worktree in the first moments after launching Orca (Claude as default agent, structured native chat on), you got a terminal-backed surface instead of the structured Claude Chat. The app hadn't yet asked the local runtime what it supports, and it treated "haven't asked yet" as "not supported". Now it asks at renderer boot, ungated, so the answer is already there by the time any launch route is decided.

Why

readLocalRuntimeCapabilitiesOrUnknown() returns null until something writes the module-level capability cache, and resolveStructuredNativeChatSupport maps null to blocker runtime-capability-unknown. A creation submitted in that window silently degrades from structured-native-chat to legacy-native-chat — a terminal-backed surface instead of the structured chat.

The root cause was never the readers. It was that the cache's only writer — refreshLocalRuntimeCapabilities() inside useLocalStructuredSessionTabsSync — sat behind workspaceSessionReady && terminalStartupRestorationReady && experimentalStructuredNativeChat. None of those has anything to do with what the local host can do. runtime:getStatus is a synchronous in-memory handler in main, registered before the window loads, so the answer was available from the first millisecond and was being withheld for seconds.

What Changed

One production change, plus the primitive it calls.

app-shell/use-app-startup-hydration.ts starts the capability probe ungated as the first statement of the renderer boot effect, before every await in the startup chain. That removes the mechanism rather than guarding it: the answer no longer depends on unrelated readiness flags, and every reader — including lib/agent-launch-route-input.ts, which main introduced as "the one place that gathers what a launch route decision needs" and which still reads the cache synchronously — now sees a populated cache.

runtime/local-runtime-capabilities.ts gains ensureLocalRuntimeCapabilities(): returns the cached set, or awaits the deduplicated probe when no answer has landed. The probe call is also wrapped so a missing/broken preload bridge rejects into the existing catch instead of throwing synchronously out of a non-async function, and setLocalRuntimeCapabilitiesForTests accepts null so tests can model the unprobed state. A failed probe still leaves the cache null (unknown), never [] (unsupported).

Note on scope after merging main

This branch originally also awaited the probe at four creation-flow call sites as a backstop. main has since centralised route-input construction into lib/agent-launch-route-input.ts, and the composer paths now delegate to a synchronous planAgentSessionLaunch. Those per-call-site awaits no longer have a call site to live in, so the merge drops them and takes main's shape; the four route tests this branch added were deleted with them, since they pinned resolution at sites that no longer resolve. main's own agent-launch-route-input.test.ts already covers the null capability case centrally. The boot probe is unaffected by that refactor and is what carries the fix.

Making the centralised path async instead would ripple through planAgentSessionLaunch into launchAgentInNewTab, which is synchronous with 11 production callers — not worth it when boot hydration already covers them.

Linked Issue

Refs #19154 — deliberately not a closing reference.

This PR fixes a real, separate defect: the launch-route degradation described in that issue's opening paragraph (a create in the first moments after launch degrades from structured-native-chat to legacy-native-chat, a terminal-backed surface instead of the structured chat). It does not fix the issue's headline defect, which is the one in its title and its "Why it happens" section: a structured Claude chat session that can never be closed, because eviction fails at stop-provider-child.

That defect is still live on main and is untouched here. Its chain, verified against current code:

  • claude-child-exit-proof-ladder.ts:40 returns true only when input.exited() && tree.treeVerdict === 'exited'.
  • The descendant snapshot is armed lazily, on the child's first stderr byte (claude-stream-json-connection.ts:146-161) — the SDK's stdout/message stream does not arm it.
  • A session that dies before emitting stderr therefore latches treeVerdict: 'unverifiable' permanently (claude-agent-sdk-exit-proof.ts:121,143-148), which is pinned as intended by a currently-passing test.
  • stop-provider-child then throws provider child exit was not proven on every retry (structured-agent-session-eviction.ts:47-59), producing the exact error string in the report.
  • Nothing self-heals it: the unexpected-exit lifecycle is published only on a successful tree proof, the lease renewer's dead-owner branch is gated on runtimeKind: 'tui', and restart reconciliation is gated on unreconciled, which is false for a record admitted in-process.

The root cause there is that eviction conflates unverifiable ("we could not find out") with proven-live. agent-session-acquisition-failure-settlement.ts:17-20 already models the correct third answer for the sibling acquisition path — root exit observed first-hand, descendants not verifiable — so the fix is a consistency fix against existing architecture, and it is main-process only. Tracked as separate follow-up work.

Correction to an earlier note in this body: PR #19155 did target that headline defect, not "the wrong bug". Closing it was still the right call — it failed its own rendered-Electron QA and its store-only lease-renewer commit introduced an unclosable tab that plain main did not have — but its eviction and close-verdict commits are the right shape and should be salvaged rather than lost.

Testing

pnpm tc clean. Bare oxlint exit 0. pnpm run check:code-quality:changed: 0 findings across 4 changed files (code quality, casting, type-aware, React Doctor, casting-safety). Targeted sweep at the merged head: 6 files / 95 tests passed (app-startup-routing, local-runtime-capabilities, agent-launch-route-input, agent-session-launch-plan, folder-workspace-composer-submit, full-creation-execution).

Ablation, re-proven at the merged head. Removing the single boot-probe line fails exactly one test — app-startup-routing.test.ts "probes local runtime capabilities before any startup gate can hold the answer back" (1 failed | 26 passed) — and restoring it returns 27 passed. The pin is position-sensitive, not merely presence-sensitive: moving the call inside the effect's void (async () => { chain also reddens it.

Note the pin is a source-order ratchet, the established pattern for this file (no test in the repo renders useAppStartupHydration). It catches deletion and repositioning; it does not execute the effect.

Visual Proof

Rendered Electron QA on an isolated dev instance, hidden renderer over CDP, settings matching the incident report. Measured in the running app, varying only hostCapabilities: probed list (74 caps, includes agent-session.structured.v1) → structured-native-chat; null → legacy-native-chat; [] → legacy-native-chat.

Cold reload under 12x CPU throttling, sampling in-page — the capability cache is populated 13.9s in, while workspaceSessionReady lands at 28.3s and terminalStartupRestorationReady at 29.8s. The cache beats the gates by ~15.9s; before this change those gates were the only trigger for its sole writer, so every create in that window resolved against null.

A cold create driven through the real create-worktree dialog with both gates verifiably false at submit landed on activeTabType: "agent-session" — Claude Chat focused, Setup beside it and unfocused.

Full runs, screenshots and limitations are in the PR comments.

Not validated

  • No ablation against main in the live Electron rig — that would mean editing production source in the checkout under test. The pre-fix populate time is inferred from the code (the tabs-sync is the only other writer, gated behind those same flags), not measured.

  • The rendered-Electron evidence was captured at 0b11f4d257, before this merge. The merge changes which files carry the fix but not the boot probe itself, which is byte-identical; it is not a re-measurement.

  • Renderer reload gives a fresh renderer JS context with a warm main process, so true first-launch main-process latency is not modelled.

  • Windows/Linux/SSH unexercised. The change is renderer-only and host-agnostic; remote creates short-circuit on remote-execution-host before the capability check.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Renderer-only; no IPC or wire shape change; runtime:getStatus is an existing local channel. Performance: one extra local IPC per renderer boot to a synchronous in-memory handler, deduplicated across StrictMode's double effect invoke onto a single in-flight promise. No polling, no per-render IPC.

Checklist

  • This PR is small and focused
  • I explained what changed and why (including ELI5)

…ing 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 #19154

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • ensureLocalRuntimeCapabilities() — new async reader in local-runtime-capabilities.ts that returns the cached capability set or awaits the deduplicated probe when no answer has landed, returning null only after a genuinely failed probe.
  • Probe starts inside the promise chain — refreshLocalRuntimeCapabilities now kicks off getStatus() one microtask in, so a broken preload bridge rejects into the existing catch rather than throwing synchronously out of the ??= expression.
  • Three creation-submit call sites await the probe — quick-creation-execution.ts, full-creation-execution.ts, and folder-workspace-composer-submit.ts pass await ensureLocalRuntimeCapabilities() instead of the possibly-null synchronous read; QuickCreationExecutionInput is exported for the new test.
  • Tests — route-before-hydration tests for all three submit paths plus ensureLocalRuntimeCapabilities unit tests, with null support added to setLocalRuntimeCapabilitiesForTests.

The concurrency model checks out: the probe dedupes through refreshPromise, remote/WSL/SSH launches still short-circuit on remote-execution-host before the capability answer matters, and a failed probe degrades to legacy exactly as before. The tests assert concrete route values (structured-native-chat / legacy-native-chat) and providesInitialSurface, so they fail if the await is reverted.

ℹ️ Remaining pre-hydration readers stay unchanged

The four other readLocalRuntimeCapabilitiesOrUnknown() call sites — launch-agent-in-new-tab, launch-work-item-direct-route-preparation, onboarding-folder-agent-startup, and ai-vault-session-resume-in-chat-workspace — keep the same latent unknown-window behavior. That's a defensible scope line since none is the worktree-creation defect from #19154, but it may be worth a follow-up issue to confirm none of them can be triggered pre-hydration in practice.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: ec91d3d6-89e1-4de6-aa37-da97aefff7f1

📥 Commits

Reviewing files that changed from the base of the PR and between 5655523 and ad75db7.

📒 Files selected for processing (2)
  • src/renderer/src/hooks/composer-state/full-creation-execution.test.ts
  • src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

Agent creation flows now await ensureLocalRuntimeCapabilities() before selecting a launch route. The runtime module probes the host when no cached result exists, preserves null after probe failure, and copies test values defensively. Folder-workspace, full-creation, quick-creation, and direct work-item flows use the ensured result. Tests cover structured and legacy route selection, runtime probing, initial surface activation, cancellation during probing, direct launch preparation, and structured-launch settlement.

Merge Risk: ⚪ Minimal · up to ad75d

Creation flows now wait for local runtime capabilities before choosing structured chat or the legacy terminal route, preventing early launches from incorrectly falling back while preserving fallback behavior when probing fails. The current change is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.05% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 19 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, specific, and accurately describes the primary change: waiting for runtime capability hydration before resolving the creation launch route.
Description check ✅ Passed The description is mostly complete and covers the ELI5 summary, changes, rationale, linked issue, visual proof, testing, platform limitations, notes, and checklist items. It omits explicit Review and …
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 04a77ec2-b815-45e7-81af-418a8cea55df

📥 Commits

Reviewing files that changed from the base of the PR and between aac38d6 and 6189409.

📒 Files selected for processing (8)
  • src/renderer/src/components/sidebar/folder-workspace-composer-submit-launch-route.test.ts
  • src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts
  • src/renderer/src/hooks/composer-state/full-creation-execution.test.ts
  • src/renderer/src/hooks/composer-state/full-creation-execution.ts
  • src/renderer/src/hooks/composer-state/quick-creation-execution.test.ts
  • src/renderer/src/hooks/composer-state/quick-creation-execution.ts
  • src/renderer/src/runtime/local-runtime-capabilities.test.ts
  • src/renderer/src/runtime/local-runtime-capabilities.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment on lines 147 to 153
executionHostId: runtimeEnvironmentId
? `runtime:${encodeURIComponent(runtimeEnvironmentId)}`
: (projectGroup.connectionId ?? 'local'),
hostCapabilities: readLocalRuntimeCapabilitiesOrUnknown(),
hostCapabilities: await ensureLocalRuntimeCapabilities(),
workspaceKind: 'folder',
promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit',
launchText: launchDraftPrompt ?? note,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard cancellation after capability hydration. NewWorkspaceComposerModal.handleDismiss marks the submission cancelled, but submitFolderWorkspaceCreate does not recheck that state after await ensureLocalRuntimeCapabilities(). A dismissal while window.api.runtime.getStatus() is pending can therefore continue through route resolution, createFolderWorkspace, and workspace launch. Pass the cancellation predicate into this helper and return through a cancellation result immediately after the await. Ensure the orchestration layer does not report that cancellation as a creation failure.

…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.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status

Full review loop completed at fabcf84a84 (independent same-model review pass, start/end readiness checklists, precedent check). Verdict: PASS — ready pending the Electron UI validation pass.

What the review confirmed

  • Root cause and mechanism: launch-route decisions read a module-local capability cache whose population was gated behind workspaceSessionReady && terminalStartupRestorationReady, so a pre-hydration create read "not asked yet" as "unsupported" and silently degraded to legacy. For the async creation flows the change removes that mechanism (they await the single-flight probe and can no longer observe "not asked yet"); a genuinely failed probe still degrades to legacy, which is the right failure posture.
  • The design is the standard shape for this problem: an ensure-style async accessor over a deduplicated cached-promise probe, awaited at the decision site, with the in-flight promise dropped after settling so a transient failure retries on the next decision instead of being cached forever. The probe is one IPC round-trip to a synchronous main-process handler, so the new awaits cannot hang beyond IPC itself; on a warm renderer they are a no-op microtask.
  • No new reentrancy: all three composer submit paths already had real awaits before route resolution, and the folder submit is guarded by its orchestration's creating flag. Nothing read after the new awaits is re-read store state.
  • Concurrency in local-runtime-capabilities.ts is sound (shared in-flight promise for concurrent creates; finally reset; broken-bridge sync throw now rejects into the existing degrade path).

Added during review (fabcf84a84): the work-item direct launch (lib/launch-work-item-direct-route-preparation.ts) is the fourth creation-flow route owner and already async, so it now awaits the probe too, with its own route-before-hydration test. It was live only for submit-after-ready (fix-checks) launches — draft-delivery launches hit the draft-prompt blocker before the capability check.

Validation

  • 6 test files (the 4 touched + agent-launch-routing.test.ts + caller census): 34 passed, plus the new work-item test.
  • Ablations at the final head: with each call site reverted alone, its route-before-hydration test fails on the behavioral assertion (route degrades / probe never fires) and passes restored; same for the new work-item test.
  • pnpm tc clean; changed-code quality gate 0 findings (incl. type-aware + React Doctor); lockfile untouched.

Remaining gaps / follow-up

  • The module still exposes sync capability readers for launch funnels that cannot await (launch-agent-in-new-tab.ts:216, onboarding-folder-agent-startup.ts:138, ai-vault-session-resume-in-chat-workspace.ts:52). Their pre-hydration consequence is a legacy chat in an existing workspace or a briefly hidden affordance — not the creation-flow bare-terminal defect — and converting those funnels to async is the durable fix for the residual window. Tracked as follow-up on Structured Claude chat session can never be closed: eviction fails at stop-provider-child #19154; an eager startup warm probe was considered and deliberately left out of this PR (it shrinks that window but doesn't remove it, and adds an untested startup behavior change).
  • Live Electron UI validation pass is queued separately; true cold-app-launch end-to-end and Windows/Linux/SSH remain unexercised (renderer-only, host-agnostic change; remote hosts short-circuit before the capability check).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Work-item direct launch route now awaits the probe — prepareDirectWorkItemAgentLaunch passes await ensureLocalRuntimeCapabilities() instead of the possibly-null synchronous read, closing the fourth creation-flow route owner called out in the prior review.
  • New route-before-hydration test — launch-work-item-direct-route-preparation.test.ts asserts getStatus is probed and structuredLaunch resolves true; reverting the await makes both assertions fail, so it's real coverage rather than theatre.

The test mocks getExecutionHostIdForWorktree to 'local' (necessary because the call site resolves it directly from a minimal store) and exercises the real resolveAgentLaunchRoute, and submit-after-ready correctly skips the draft-prompt blocker that would short-circuit the capability check. The three remaining sync readers stay unchanged as documented follow-ups on #19154.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No blocking issues — one minor comment inline about a now-stale test comment.

Reviewed changes

  • Probe starts synchronously again — refreshLocalRuntimeCapabilities now dispatches getStatus() through a new startLocalRuntimeCapabilityProbe() helper that calls the bridge synchronously and converts a broken-bridge throw into a rejection, replacing the microtask-deferred start so callers that overlap the probe with their own RPC see it in flight on return.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Rendered Electron QA — true cold launches

Tested the current PR head 0b9aa09e8bc4d4b72234b49e9d729312aab501e6 (including the synchronous probe-initiation follow-up) with a fresh Electron process for each cold run. The dev instance used task-owned profiles under /tmp/orca-qa-19819-*-t97, renderer 127.0.0.1:5241, CDP 127.0.0.1:9401, and ORCA_BACKGROUND_LAUNCH=1; before trusting the captures I observed that the attached CDP target URL, app identity, and store repo/worktree belonged to this checkout and the task-owned test repo.

The profile matched the incident settings: Claude default agent, native chat + open-in-chat + structured native chat enabled, Claude args --dangerously-skip-permissions, setup launch mode new-tab, and a committed setup script. Each creation was exercised through the real create-worktree dialog as soon as the UI permitted, without reloads or CPU throttling. Times below are milliseconds from the Create click; “record” is first appearance in that run's agent-sessions/agent-sessions.json.

Run Capability cache at click Resolved route / ms Record Setup tab Structured tab Chat focused Completed surface Persistent bare terminal seeded / focused
Cold 1 (shakedown) loaded structured-native-chat / 22 not observed¹ not instrumented 2147 2150 2299 not instrumented
Cold 2 (shakedown) not observed² structured-native-chat / 13 783 not instrumented 1724 1729 1950 not instrumented
Cold 3 loaded structured-native-chat / 11 317 272 801 804 945 no / no
Cold 4 loaded structured-native-chat / 9 297 255 753 755 896 no / no
Cold 5 loaded structured-native-chat / 11 415 344 902 904 994 no / no
Cold 6 loaded structured-native-chat / 27 702 597 1590 1595 1811 no / no
Cold 7 loaded structured-native-chat / 10 637 510 1659 1664 1885 no / no
Warm control loaded structured-native-chat / 7 310 241 710 713 777 no / no

¹ The first shakedown's record watcher targeted the wrong profile path, so record appearance was not observed.
² The second shakedown's cache observer errored while summarizing the returned non-null array, so cache state is unobserved for that run.

What I observed

  • In all five fully instrumented cold launches and the warm control, pendingWorktreeCreations resolved agentLaunchRoute to structured-native-chat.
  • Their first focused post-creation surface was Claude Chat. The only terminal seeded was the separately named, unfocused Setup tab; there was no persistent bare terminal and no terminal ever took focus.
  • The committed setup command completed in each of the five fully instrumented cold profiles, confirmed by the orca-qa-19819-setup-complete marker in its terminal-history checkpoint.
  • All five fully instrumented cold runs had 74 runtime capabilities, including agent-session.structured.v1, already loaded at the Create click. Therefore 0/5 attempts landed inside the pre-hydration window. I did not observe the awaited-probe path directly and am not inferring its behavior from these runs.
  • No cold launch degraded to legacy-native-chat.

Result: the true-cold pass condition was observed on every fully instrumented attempt—structured-native-chat, Claude Chat initially focused, and no focused bare terminal—with the important limitation that the UI was not usable quickly enough to catch a pre-hydration capability cache.

Cold launch (Claude Chat focused, Setup separate and unfocused)

Cold launch — Claude Chat focused

Warm control

Warm control — Claude Chat focused

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 #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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Boot-time capability hydration — use-app-startup-hydration.ts now calls void ensureLocalRuntimeCapabilities() ungated at the top of the boot effect, so the local capability answer is normally cached before any launch route resolves — including the three synchronous readers that cannot await.
  • Cancel-recheck in the full-create route — full-creation-execution.ts hoists the probe into const hostCapabilities before the cancel gate and re-checks isSubmissionCancelled() after the await, closing the gap where a cancelled submit could proceed past the awaited probe.
  • Source-order regression test — app-startup-routing.test.ts pins the probe ahead of the boot chain and outside its gates, matching the existing readSource ordering-test convention in that file.
  • Unit-test cleanup and coverage — local-runtime-capabilities.test.ts drops the stale microtask-yield comment (prior feedback) and adds re-probe-after-failure, missing-bridge, and concurrent-caller-coalescing cases for ensureLocalRuntimeCapabilities.

The boot-time probe is sound: ensureLocalRuntimeCapabilities never rejects (the probe's failure is swallowed into null inside refreshLocalRuntimeCapabilities), it starts the getStatus() IPC synchronously on invocation, and it dedupes against the structured-session-tabs sync through refreshPromise — so StrictMode remounts and a racing hydration sync don't double-probe.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Cancel-gate regression tests — full-creation-execution.test.ts and quick-creation-execution.test.ts each gain a "does not create when the composer is dismissed while the probe is still pending" test that holds the getStatus probe open on a deferred promise, flips isSubmissionCancelled() mid-probe, then asserts no worktree is created. quick-creation-execution.test.ts also gains a local deferred<T>() helper to drive it.

Both tests are real coverage, not theatre. The full-create test targets the hoisted const hostCapabilities / post-await re-check in full-creation-execution.ts:136-139, and the quick-create test targets the existing cancel gate at quick-creation-execution.ts:264 that sits after the await ensureLocalRuntimeCapabilities() at line 208 — removing either gate makes the corresponding createWorktree / runBackgroundWorktreeCreation assertion fail. Verified both files pass locally (6 tests), matching the ablation table in the PR body.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Folder-create cancel window closed — folder-submit-orchestration.ts now awaits ensureLocalRuntimeCapabilities() into hostCapabilities above its isSubmissionCancelled() gate, so a composer dismissal during the probe returns before createFolderWorkspace can run. This resolves the CodeRabbit concern about submitFolderWorkspaceCreate rechecking cancellation after hydration.
  • Pre-resolved capabilities threaded through — submitFolderWorkspaceCreate gains an optional hostCapabilities param; it awaits the probe only when the param is omitted (undefined vs null correctly distinguishes "not provided" from "probe failed → unknown"). The single production caller passes the hoisted value, so no suspension remains between the gate and createFolderWorkspace.
  • Coverage — new folder-submit-orchestration.test.ts (dismiss-during-probe, plus capability pass-through) and a "uses pre-resolved capabilities without probing again" case in folder-workspace-composer-submit-launch-route.test.ts. All 4 new/changed tests pass, and the dismiss test holds the probe open on a deferred promise, so it fails if the gate is removed.

The hoist restores the invariant that nothing awaits between the final cancel check and workspace creation, and the null/undefined split keeps the degraded legacy route correct after a genuinely failed probe.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

…r 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • hostCapabilities made required on submitFolderWorkspaceCreate — the optional parameter and its inline await ensureLocalRuntimeCapabilities() fallback are removed, so the function now runs straight through to createFolderWorkspace with no suspension of its own, and the type forces a caller that gates on cancellation to resolve capabilities above its gate. The single production caller in folder-submit-orchestration.ts already passes the hoisted value.
  • Launch-route test reframed around pre-resolved capabilities — the folder launch-route test now pins the "degrades to legacy when the handed-in capabilities are unknown" contract (the bridge holds the structured capability, so only the handed-in null can produce the legacy outcome) and the "uses pre-resolved capabilities without probing again" contract, both asserted via getStatus not being called.
  • Non-route call-site tests hand in null explicitly — folder-workspace-composer-submit.test.ts routes through a submitWithUnknownCapabilities helper, matching the prior behavior where the unset bridge made the inline probe resolve to null anyway.

The required-param contract is a clean close of the footgun the optional fallback left open: a future caller without a cancel gate can no longer silently re-probe inline and reopen the window the folder hoist had just closed. Verified the three changed test files (28 tests) and pnpm tc pass against this head.

ℹ️ PR body now overstates the folder parameter

The "What Changed" section still describes hostCapabilities as optional ("a caller without a cancel gate keeps the inline probe"). This commit makes it required and deletes the inline probe, so that sentence is stale and worth a one-line edit before merge.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Rendered Electron UI validation (macOS) — cold-window create

Tested SHA f4920644321608c8420749db1ea7ecf8c172fd79 (this PR's head). Isolated dev instance, own fresh user-data profile, ORCA_BACKGROUND_LAUNCH=1, driven entirely over CDP/Playwright against a hidden renderer. Settings per the bug report: defaultTuiAgent=claude, experimentalNativeChat, openAgentTabsInChatByDefault, experimentalStructuredNativeChat all on, agentDefaultArgs.claude=--dangerously-skip-permissions, setupScriptLaunchMode=new-tab, against a fixture repo with a committed orca.yaml setup script.

The cold window was reproduced by reloading the renderer under CDP CPU throttling and driving the real "New workspace" composer entirely in-page (a CDP round trip under throttle is seconds wide and would push the submit past the gates). An in-page recorder installed via addInitScript samples the gate state, so the pre-hydration claim does not depend on screenshot timing.

Mechanism, measured live in the running renderer

Calling the app's own resolveAgentLaunchRoute with this profile's real settings, varying only hostCapabilities:

hostCapabilities resolved agentLaunchRoute
probed list (74 caps) structured-native-chat
null ("not asked yet") legacy-native-chat
[] legacy-native-chat

So null does degrade the route. Note for the description: under these settings the degraded route is legacy-native-chat (terminal-backed chat), not terminal-tui.

Cold-window passes (submit landed with both hydration gates false)

run throttle in-page timeline at submit outcome
coldB 12x caps +8.1s, create clicked +18.2s, workspaceSessionReady=false, terminalStartupRestorationReady=false active tab contentType: agent-session, label Claude Chat
coldC 16x caps +20.9s, create clicked +41.9s, both gates false active tab agent-session, Claude Chat
coldD 16x caps +12.3s, create clicked +22.1s, both gates false active tab agent-session, Claude Chat

cold-coldB-result.png
Cold pass (coldB, 12x): worktree created with the submit recorded at workspaceSessionReady=false / terminalStartupRestorationReady=false — the focused tab is the structured Claude Chat surface, not a terminal.

cold-coldC-result.png
Cold pass (coldC, 16x): second independent cold-window create, same result — structured Claude Chat tab focused.

cold-coldB-at-submit.png
Cold pass (coldB): frame captured while the create was in flight during early boot — sidebar shows "coldB — Creating worktree…" with no tabs mounted yet.

Awaited backstop

before-caps could not be reached: the ungated boot probe cached capabilities before the composer had even rendered on every attempt, which is the fix working as intended. To exercise the backstop anyway, the renderer cache was forced back to null through the module's own setLocalRuntimeCapabilitiesForTests(null) hook and a normal create was driven with hostCapabilities verified null at click time. The awaited ensureLocalRuntimeCapabilities() re-probed and the create still routed to structured chat.

nullcaps-nullcapsH-result.png
Backstop: cached capabilities forced to null immediately before submit — the create still lands on the structured Claude Chat tab.

Warm sanity pass

warm-warmE-result.png
Warm pass (warmE, no throttle): both hydration gates true at submit (+2.1s), structured Claude Chat tab focused. Ordinary path unaffected. Two further unthrottled-at-submit creates (coldF, coldG) behaved identically.

Gaps

  • No ablation against main. This run only shows the branch behaving correctly; it does not directly film the old failure, since that would have meant modifying production source. The resolveAgentLaunchRoute table above is the closest live evidence of the mechanism.
  • No screenshot is captured provably inside the pre-hydration window. Under CPU throttling a CDP screenshot takes long enough that the gates flip during the capture, so the pre-hydration evidence is the in-page recorded timeline rather than the image. The images show the resulting tab.
  • Renderer-only cold boot: the reload gives the renderer a fresh JS context while the main process stays warm, so the real first-launch main-process latency is not modelled.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review session status — 5 review loops + rendered Electron validation

Final head 0b11f4d257. Gates green at that SHA: pnpm tc exit 0, bare oxlint exit 0, check:code-quality:changed 0 findings across 15 files (code quality, type-aware, React Doctor), touched-file tests 8 files / 73 passed. CI verified per-SHA rather than via a bare "0 pending": 17 ran and succeeded, 11 correctly change-detection skipped, 0 pending, 0 mismatched head_sha. MERGEABLE / CLEAN; 8 behind main with zero file overlap against main's 8 commits.

The approach was superseded, not just reviewed

The PR originally awaited a lazy capability probe at four creation-flow call sites. That was a per-call-site guard, not a fix: the cache's only writer sat behind workspaceSessionReady && terminalStartupRestorationReady && experimentalStructuredNativeChat, none of which has anything to do with what the local host can do — and runtime:getStatus is a synchronous in-memory main handler registered before the window loads, so the answer was available from the first millisecond and was being withheld for seconds. Three of the seven readers are synchronous and could never adopt the guard.

The fix now starts the probe ungated at renderer boot, which collapses the unknown window for all seven readers. The four awaits remain as a narrow backstop.

Two defects found during review, both fixed

Inserting an await between a cancel gate and an irreversible side effect. Found on the git-worktree path, fixed, then found still open on the folder path — which this PR body had itself asserted was "the same defect" without anyone checking. At main, submitFolderWorkspaceCreate ran straight from its caller's isSubmissionCancelled() gate to createFolderWorkspace with no suspension of its own; the inline probe inserted a full IPC round-trip in between, so a composer dismissed mid-probe still created the workspace and launched an agent.

Because that class recurred twice, hostCapabilities is now a required parameter rather than an optional one documented by a comment. exactOptionalPropertyTypes is off and the caller census pins a different function, so nothing else would have caught a future caller that forgot it. A type-level ablation confirms it: the omitting caller compiles clean without the change and fails TS2741 with it. Runtime at the single production caller is unchanged.

A subsequent loop swept every await this PR inserted plus their upstream callers for a third instance and found none.

Rendered Electron validation at f492064432

Three cold-window creates submitted with both hydration gates verifiably false (in-page recorder via addInitScript, not screenshot timing) all resolved to structured-native-chat with a Claude Chat tab focused. Warm pass unaffected. Live mechanism check in the running renderer, varying only hostCapabilities: probed list → structured-native-chat; null → legacy-native-chat; [] → legacy-native-chat. Details and screenshots in the preceding comment.

Notably, the awaited backstop turned out to be unreachable via the cold path — the boot probe cached capabilities before the composer had even rendered, every time. That is the fix working, but it means the backstop was exercised separately through the module's test hook rather than by the cold path.

Honest gaps

  • No screenshot provably captured inside the pre-hydration window: under CPU throttling the CDP capture itself takes long enough that the gates flip mid-capture. The pre-hydration proof is the in-page timeline; the images show the resulting tab.
  • No ablation against main in the live rig (it would have required modifying production source).
  • Renderer reload gives a fresh renderer JS context but a warm main process, so true first-launch main-process latency is not modelled.
  • The folder-path cancel fix is covered by hook-level tests and ablation, not a live dismissal-during-probe run.
  • The boot-probe wiring is pinned by a source-order ratchet that asserts position but does not execute the effect.
  • Three synchronous readers remain structurally unfixed and now depend on the boot probe having landed. The window is one local IPC round-trip from the first renderer effect — practically unreachable by a human click, but not closed by construction.
  • Four creation paths now carry an unbounded suspension point. Deliberate: a deadline would fire precisely under main-process contention, which is the repro condition, and would reinstate the bug. In-repo precedent leaves the same local getStatus() unbounded while bounding remote RPCs at 15s.
  • Windows/Linux/SSH unexercised — renderer-only and host-agnostic; remote creates short-circuit on remote-execution-host before the capability check.

Issue linkage — please note

Fixes #19154 was changed to Refs #19154. That issue's headline defect is a different bug: a structured Claude chat session that can never be closed, because eviction fails at stop-provider-child. That defect is still live on main, verified link-by-link and untouched here. Merging this as a closing reference would have closed the issue with the titled bug still shipping. The body documents the full chain for follow-up.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Rendered Electron QA at the final head 0b11f4d257 — run directly, not delegated

Isolated dev instance from the pr19819-electron-qa worktree at 0b11f4d257, fresh user-data profile, ORCA_BACKGROUND_LAUNCH=1, renderer 127.0.0.1:5241, CDP 127.0.0.1:9401. Before trusting anything I confirmed the attached instance was this checkout (devRepoRoot: .../pr19819-electron-qa) and that the running renderer served branch code — use-app-startup-hydration.ts contains void ensureLocalRuntimeCapabilities(), and folder-workspace-composer-submit.ts no longer contains preResolvedHostCapabilities (the required-parameter commit is live). Settings matched the incident report: Claude default agent, native chat + open-in-chat + structured native chat on, Claude args --dangerously-skip-permissions, setup mode new-tab, fixture repo with a committed setup script.

1. The mechanism, measured in the running renderer

Calling the app's own resolveAgentLaunchRoute with this profile's real settings, varying only hostCapabilities:

hostCapabilities resolved route
probed list (74 caps, includes agent-session.structured.v1) structured-native-chat
null (not asked yet) legacy-native-chat
[] legacy-native-chat

So an unhydrated cache is exactly what degrades the route, and the degraded route is legacy-native-chat — a terminal-backed surface.

2. The boot probe beats the hydration gates — quantified

Cold reload under 12× CPU throttling, sampling the capability cache and both gates in-page:

event ms after reload
capability cache populated 13,937
workspaceSessionReady 28,291
terminalStartupRestorationReady 29,793

The cache was populated 15.9 s before the gates. That gap is the whole point: those gates were previously the only trigger for the cache's sole writer, so before this change every create in that window resolved against null and degraded. The window is now closed by construction rather than narrowed.

3. A cold-window create through the real dialog

Create driven in-page (a CDP round trip under throttle is seconds wide and would miss the window), through the actual create-worktree dialog — open, fill, submit:

  • gates at submit: workspaceSessionReady: false, terminalStartupRestorationReady: false — genuinely pre-hydration
  • capability cache at submit: populated (the boot probe had already landed)
  • resulting focused surface: activeTabType: "agent-session"

cold-window create result

Claude Chat focused for coldself-160972, with Setup beside it and unfocused — the structured surface, not a terminal.

What this does and does not establish

Establishes: the degradation mechanism is real and reproduces on demand (null → legacy-native-chat); the boot probe populates the cache well before the gates that used to gate it; and a create submitted with both gates false lands on the structured chat surface at the final head.

Does not establish:

  • No ablation against main in this rig — that would mean editing production source in the checkout under test. The pre-fix populate time is inferred from the code (the tabs-sync is the only other writer and is gated behind those same flags), not measured here.
  • The awaited backstop was not exercised by the cold path — the boot probe won every time, so the await at the four call sites never had a null cache to wait on. That is the fix working, but it means the backstop's own behaviour is covered by unit tests rather than by this run.
  • Renderer-only cold boot. Reload gives a fresh renderer JS context with a warm main process, so true first-launch main-process latency is not modelled.
  • One earlier warm create in this session landed on a terminal surface while I was still configuring the profile (settings applied post-boot, first-run setup-script trust prompt outstanding). I did not chase it down, so I am reporting it rather than omitting it; the clean cold run above was taken after the profile was fully configured.

Note for anyone reproducing this: a first-run "Run setup script from <repo>?" trust prompt blocks worktree creation and stalls automation silently — the create simply never starts, with no error. Grant it once with "Always trust".

main centralised launch-route input construction in
`lib/agent-launch-route-input.ts` ("the one place that gathers what a launch
route decision needs"), so the four per-call-site `await
ensureLocalRuntimeCapabilities()` backstops this branch added no longer have a
call site to live in: the composer paths now delegate to `planAgentSessionLaunch`
and the whole decision path is synchronous.

Resolution keeps the actual fix and drops what the refactor obsoleted:

- the ungated boot probe in `use-app-startup-hydration.ts` stays. It is what
  removes the mechanism, and it now also covers main's single centralised
  reader, which still calls `readLocalRuntimeCapabilitiesOrUnknown()`.
- the five conflicted call sites take main's shape.
- four route tests this branch added are deleted; they pinned resolution at call
  sites that no longer resolve. `agent-launch-route-input.test.ts` on main
  already covers the `null` capability case centrally.
- two pre-existing test files revert to main; their added cases targeted the
  removed per-call-site parameters.

Re-proven at the merge: removing the boot probe line fails exactly
`app-startup-routing.test.ts` "probes local runtime capabilities before any
startup gate can hold the answer back", and restoring it returns 27 passed.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No code defects found — the boot-hydration fix survives the merge intact, and all retained tests plus pnpm tc:web pass. One note on the merge's scope reduction and a stale doc line below.

Reviewed changes

Reviewed the delta since the last pullfrog review (0b11f4d → 43bb27b, a merge of origin/main), which substantially narrowed the PR.

  • Merge absorbed main's planAgentSessionLaunch refactor — main replaced the per-call-site resolveAgentLaunchRoute usage with the synchronous planAgentSessionLaunch / buildAgentLaunchRouteInput path, so the branch's four creation-flow await ensureLocalRuntimeCapabilities() call sites, the folder hostCapabilities parameter, and their tests are no longer in the diff.
  • Boot probe retained — use-app-startup-hydration.ts still starts void ensureLocalRuntimeCapabilities() first and ungated, and local-runtime-capabilities.ts keeps ensureLocalRuntimeCapabilities / startLocalRuntimeCapabilityProbe, so the module-level capability cache is warmed on mount for the synchronous route readers.
  • Tests retained — the five ensureLocalRuntimeCapabilities cases in local-runtime-capabilities.test.ts and the source-order ratchet in app-startup-routing.test.ts (38 tests pass; pnpm tc:web clean).

ℹ️ Merge reduced the PR's scope but the description still describes the removed changes

The net diff is now 4 files (was 15). The PR body's "What Changed" section, its ablation table, and the "folder-path cancel fix" narrative all describe the four creation-flow awaits and the required hostCapabilities parameter that the merge dropped — they no longer exist in the code being merged, so a reader is told the PR covers the folder cancel window and a residual-window backstop it does not.

Technical details
# PR description no longer matches the diff

## Affected sites
- `src/renderer/src/lib/agent-session-launch-plan.ts:116` — `planAgentSessionLaunch` is synchronous; it resolves the route through `buildAgentLaunchRouteInput`.
- `src/renderer/src/lib/agent-launch-route-input.ts:123` — capabilities are read synchronously via `readLocalRuntimeCapabilitiesOrUnknown()`.
- PR body "What Changed" / "Ablations" — describes `quick-creation-execution.ts`, `full-creation-execution.ts`, `folder-workspace-composer-submit.ts`, `launch-work-item-direct-route-preparation.ts`, `folder-submit-orchestration.ts`, and their tests, none of which appear in the diff.

## Required outcome
- Confirm boot-hydration-only scope is intended (it still fixes #19154 because the probe lands before any user-driven create).
- Update the body so it describes the diff that will actually be merged; otherwise the folder cancel-window and residual-window coverage claims mislead reviewers/mergers.

## Open questions for the human
- Is the plan to re-introduce the route-decision backstop against main's synchronous planner (which would require making it await-capable), or is boot hydration the accepted end state?

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +24 to +25
* and cannot await. Awaiting it at a route decision is the backstop for the residual window
* and for re-probing after a failed one. Still `null` after an actually failed probe, and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This backstop no longer exists after the merge: planAgentSessionLaunch is synchronous and reads the cache through readLocalRuntimeCapabilitiesOrUnknown(), so nothing awaits ensureLocalRuntimeCapabilities() at a route decision. The comment still presents an awaiting call site as covering the residual window and re-probing after failure — reword it so it doesn't imply a live caller.

@brennanb2025
brennanb2025 merged commit cf20e08 into main Sep 13, 2026
27 of 28 checks passed
zpyoung added a commit to zpyoung/orca that referenced this pull request Sep 16, 2026
* fix(native-chat): wait for the runtime capability probe before resolving the creation launch route (#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 #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 #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 f492064432 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>

* fix(worktrees): retire the chat tab of a chat with no child when its workspace goes (#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>

* feat(ai-vault-search): public session search contract and transports (#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 (#20499)

* 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

* fix(mobile): give native chat one tail-follow owner so streaming stops jumping (#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>

* fix(mobile): preserve delivery ambiguity across transport cutover (#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

* fix(runtime): reject malformed file Base64 padding (#20283)

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

* fix(tasks): preserve repository results under GitHub search quota (#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>

* fix(ai-vault): stream oversized remote session transcripts (#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>

* fix(grok): stop replayed Claude/Cursor hooks reporting Grok panes as Claude (#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

* fix(browser): press keys through a US-layout CDP key table instead of a subprocess per keystroke (#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>

* fix(rpc): validate provider-specific fields in TaskProviderIdentity (#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

* perf(terminal): batch file-link checks on their owning host (#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 file drag and drop (#20494)

* feat(native-chat): support workspace file drops

* fix(native-chat): report OS file drops that attach nothing

#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>

* fix: show unexpected signout notice only once across versions (#20526)

* test(mobile): consolidate the RPC migration's verification infrastructure (#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 bcba08b3e4 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 (22f56f7c2a). Two causes:

- #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.
- #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 #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

* Update README downloads badge

* refactor(attention): move the agent attention boundary off terminal panes (#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

* fix(grok): defer managed hook pane guard expansion to shell (#20534)

* fix(auth): report callback failures instead of cancellation (#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 (#12314)

* 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>

* fix(git): skip upstream remote probes when the remote is absent (#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 #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>

* refactor(orchestration): derive delivery eligibility from messages (#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): bound relay incumbent lsof probe (#18304)

* 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 10d7b0db375.

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>

* perf(remote): avoid repeated capability probes during file imports (#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>

* fix(grok): announce a completion once, when Grok is actually finished (#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 (#20506)

* 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.

* fix(claude): install SessionEnd for capable versions (#20530)

* refactor(mobile): send the source-control domain through typed RpcOperations (#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 …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant