Commit Graph
7461 Commits
Author SHA1 Message Date
Neil 8354f7ba02 test(vault): guard the workspace-switch regression #11303 fixed (#11311)
#11303 removed the O(sessions x roots) path normalization from the session
worktree map, but nothing fails if that shape comes back. Adds the two
checks that were missing, plus the tool that would have caught it.

- timing guard: 1200 worktrees x 400 sessions must build in <150ms.
  Verified fail-first — restoring the pre-#11303 per-session
  buildWorktreeCandidates call takes it to 238ms; it is ~15ms as merged.
- path boundary: '/repo/alpha-sibling' must not be attributed to
  '/repo/alpha'. Hoisting the root normalization out of the loop must not
  degrade containment into a bare startsWith.

Also adds tools/benchmarks/workspace-switch-paint-latency.mjs, which
attaches over CDP and measures first-paint-after-click and max frame gap.
The existing worktree-switch-responsiveness.spec.ts only times the
synchronous click task, which stays ~1ms because the highlight is a direct
DOM mutation — that is why a ~1s stall could ship without tripping a
budget. On the affected build it read maxFrameGap p50=973ms.

(cherry picked from commit c5102e1262)
2026-07-29 00:29:05 -07:00
Brennan Benson b85e267352 perf(vault): stop rebuilding session maps on every worktree switch (#11303)
Switching worktrees rebuilt two ~500-entry maps in the Agent Session
History panel because their memo deps included the active repo/worktree,
which the maps never read; the worktree map also rebuilt ~530 path
candidates (and re-normalized every root) per session, ~255k
isPathInsideOrEqual calls per switch.

- Drop activeRepo/activeWorktree from the sessionProjectById memo via
  buildAiVaultSessionProjectById, and activeWorktreeId from
  useAiVaultSessionWorktreeMap; 'current' is now stamped per row at read
  time (withAiVaultCurrentWorktreeStatus), so switches reuse both maps.
- Hoist candidate building out of the per-session loop and precompute a
  normalized-root matcher per candidate, so map rebuilds on data changes
  are O(sessions + roots) normalizations instead of O(sessions x roots).

NFC folding from #10841 is untouched; non-ASCII (NFD/CJK) matching is
covered by new tests. Warm switch with the panel on All/500 drops
425ms -> 125ms on the full-scale rig (cold 491ms -> 193ms); panel-closed
switches are unchanged.
2026-07-28 23:14:54 -07:00
github-actions[bot] 09a39c7b90 release: v1.4.160 v1.4.160 2026-07-29 04:47:59 +00:00
github-actions[bot] cb6afd3f44 release: v1.4.160-rc.4 v1.4.160-rc.4 2026-07-29 00:31:45 +00:00
Brennan Benson da6d56b2b9 fix(skills): stop calling the updater's own install a modified copy (#11249)
After a successful headless update the CLI installs source-repo HEAD,
which legitimately runs ahead of any shipped bundle. The scan classified
those bytes 'unrecognized', so the row went amber ('may be modified…
remove it') seconds after our own Update button ran, and the advice
looped: remove + reinstall lands the same newer content.

Scan half: a canonical/alias placement whose observed git tree sha
equals the updater lock's skillFolderHash is the CLI's own install, not
a user edit — reclassify it 'newer-known'. Display half: 'newer-known'
is recognized official content ahead of this build with nothing to fix,
so it no longer marks the copy blocked. Eligibility is deliberately
unchanged: ahead of the bundle means there is nothing this build can
update to, and offering one risks the provably-unperformable update
(#11110) when source HEAD still equals the lock.

Copies whose sha does not match the lock, copies with no lock entry,
same-name copies outside the placements the command writes, and
plugin-cache behavior all stay flagged exactly as before.
2026-07-28 17:28:28 -07:00
Neil f6bbfd8d3b perf(terminal): eliminate adverse control and frame-gate cases (#11045)
* perf(terminal): eliminate dense control and frame gate regressions

* test(terminal): keep gate labels in valid expect shape

* test(terminal): expose the surviving sub-threshold control-density case

The only adverse strip fixture sat at 50% control density, which is exactly
where the fallback fires and wins. A shape at 31 controls per 64-unit block
evades the trigger and still loses to the per-character legacy (0.67x), so the
benchmark structurally could not show it.

Add that fixture, pin both density literals in the staleness guard so a retune
fails loudly instead of silently measuring a boundary that moved, and export
the probe constant the equivalence test was hardcoding.
2026-07-28 17:28:28 -07:00
Brennan Benson d5ce73b277 perf(dashboard): stop re-sending repo icon data URLs on every republish (#11089)
* perf(dashboard): stop re-sending repo icon data URLs on every republish

#11012 put repo icons on the dashboard snapshot keyed by repoId. Image icons
are data URLs capped at MAX_REPO_ICON_DATA_URL_LENGTH (400KB) and every repo
contributing a card ships one, while the snapshot republishes up to 4x/sec
(PUBLISH_THROTTLE_MS = 250) for as long as the pop-out is open. Icons change
about never, so that structured-clones megabytes per second across the window
boundary for bytes the pop-out already has.

Publish the map only when it actually changed, comparing by reference since
icons come off immutable store repo records. The two paths where the pop-out
could be starting from nothing — it opened, or it mounted and asked — still
force a full send, so the retained copy can never be the only one.

The pop-out keeps the last map it was given when a republish omits the field.
An explicitly empty map still clears, so removing an icon works.

repoIconsByRepoId was already optional on DashboardSnapshot and
isDashboardRepoIcons already returns true for undefined, so the main-process
validator needed no change.

* fix(dashboard): keep repo icons in the main-process snapshot cache

The bridge now omits an unchanged repoIconsByRepoId from republishes, so the
cached snapshot main replays to a mounting pop-out could be icon-less, blanking
the board's repo glyphs until the forced publish landed. Carry the last map
into the cache; the forwarded payload is unchanged.

Also covers the forced full sends (open, reopen, snapshot request) that no test
exercised.

* test(dashboard): pin the icon omit on the throttled trailing republish

* fix(dashboard): keep the popout bridge effect off the react-doctor gate

The changed-code quality gate reports react-doctor findings that overlap
added lines, and effect-needs-cleanup spans the whole publish effect — so
this PR's edits inside it turned a pre-existing false positive into a red
static-analysis check. Hoisting the store subscriber leaves the effect
owning one disposable; behaviour is unchanged.

* docs(dashboard): correct why watchSnapshotInputs sits outside the effect

The effect owns four disposables (offOpenChanged, offRequested, the store
unsubscribe, and the trailing timer), not one. State the real reason the
subscribe is hoisted so nobody inlines it back and re-reds the gate.

* test(dashboard): pin that the bridge subscribes only while the pop-out is open

The lazy wiring exists so an enabled-but-closed pop-out costs nothing — a live
subscriber would rebuild a cross-worktree snapshot on unrelated store writes.
Nothing pinned the unsubscribe on close.
2026-07-28 17:15:45 -07:00
Brennan Benson 3afd715302 fix(release): stop packaging plugin authoring examples into app.asar (#11087)
* fix(release): stop packaging plugin authoring examples into app.asar

electron-builder's `files` is an all-negation list, so its default `**/*`
packs anything without an explicit `!` entry. examples/ arrived with the
plugin system in #8549 and never got one, so 1.4.160-rc.3 shipped
examples/plugins/hostile-panel/panel.html — the adversarial fixture the
panel containment tests point at, complete with its fetch-exfiltration
probe — plus hello-orca, inside every user's app.asar. Verified against the
installed 1.4.160-rc.3 artifact, not just the config.

The two orchestration design docs landed at the repo root in the same span
and shipped the same way; fold them into the existing root-doc negation.

Neither has a runtime consumer: bundled plugins ship via extraResources
from resources/plugins/launch/, which is already excluded from the asar
for exactly this reason.

* test(release): assert the examples exclusion through the real file matcher

The added case mapped each negation to a bare top-level token, so it passed
under '!examples/README.md' — a pattern that still ships the whole tree. Drive
app-builder-lib's FileMatcher instead so the assertion matches the test name,
and pin the root anchoring so the negation cannot grow into '!**/examples'.
2026-07-28 17:15:45 -07:00
Brennan Benson 73a61fff7f fix(updater): resume background checks after a local build session ends (#11223)
A local-build check (Option+click "Check for Updates" on macOS) pins
activeUpdateSource to 'local' for the rest of the process. The
'update-available' success path never restores it, and
runBackgroundUpdateCheck early-returns on it, so every wake-from-sleep
check, window-focus daily check and nudge poll became a no-op once a
local build reached 'available'. The one-shot automatic timer fired into
that early return and nothing re-armed it, so the scheduling chain died
too and lastUpdateCheckAt froze.

Restoring the source when 'update-available' fires would break the flow
the user just started — the pending download still needs the local feed
and allowDowngrade. Instead the release source is restored when the user
closes the offered card, which main previously never learned about, and
only while status is exactly 'available': downloadUpdate() flips status
to 'downloading' synchronously before it calls into electron-updater, so
this cannot fire once a download is under way.

The automatic timer now re-arms when a check is deferred rather than
launched, so a deferral can no longer end automatic checks for the
process lifetime.
2026-07-28 17:15:45 -07:00
Brennan Benson 5f3db15ac7 fix(codex): keep the stale-pane prompt when two accounts share a label (#11228)
The startup sweep asks main which panes are stale, and main answers by
account id. The renderer then threw that away: it resolved both ids to
labels and let the store's A -> B -> A collapse compare the strings. Two
accounts can share a label — doAddAccount has no duplicate-email check, so
one OpenAI login used in two ChatGPT workspaces gives both the same email,
and a failed roster read collapses every account to 'Codex account'. Either
way the notice was deleted for a pane that really is running under the
account the user switched away from.

The sweep then made it permanent: it marked every stale pane notified,
including the ones whose notice had just been dropped, and a notified pane
is suppressed for the rest of the session. Relaunching cleared the set but
the deletion recurred, so the prompt never came back and the pane kept
running on the other account's auth.json and quota, silently.

Carry the account ids into the notice and decide on them, falling back to
labels only for callers that have none; report which panes were left holding
a notice so a dropped one cannot claim suppression. The prompt also names
the ChatGPT workspace when that is what tells two same-email accounts apart,
which is what the two duplicated getCodexAccountLabel copies now share.
2026-07-28 17:15:44 -07:00
Brennan Benson 0c66b78a69 fix(ai-vault): resume a bridged Codex session under the selected account's home (#11224)
* fix(ai-vault): resume a bridged Codex session under the selected account's home

The account session bridge hardlinks every rollout into each per-account
CODEX_HOME, and vault dedup keeps the lexicographically-smallest alias, so
Resume could pin an inline CODEX_HOME naming a peer account — running the
session under that account's auth.json and quota. At resume time the owning
host now substitutes the selected account's home when it holds the same
rollout at the same sessions-relative path, declining on any uncertainty so
resume degrades to today's behavior instead of failing.

* fix(ai-vault): repin dropped sessions without a cwd instead of resuming under the wrong account

The drag payload only carried sessionCwd when session.cwd was truthy, so a
null-cwd codex session dropped onto a pane silently fell back to the prebuilt
command - which pins the wrong account's CODEX_HOME, the exact defect this PR
eliminates on the other resume surfaces.

- Serializer always sends sessionCwd (null when the session has no cwd), so
  absence now only means an older-serializer payload.
- The repin rebuild accepts a null cwd (the builders already omit the cd
  prefix), matching the sidebar Resume/Copy paths which repin regardless of cwd.
- An unrepinnable payload (absent sessionCwd) now fails loudly with guidance
  instead of silently resuming under the wrong account's home.
2026-07-28 17:15:41 -07:00
Brennan Benson 54017bd127 fix(skills): stop the scan issue budget evicting a read failure (#11221)
The per-scan issue budget kept an issue only when it explained a candidate
or truncated the walk. Neither set intersects the attention set, so
'io-error' — the sole reason a plugin-cache scan can raise "Needs
attention" — was droppable. Once 16 ordinary issues filled the budget (16
'outside-root' vendor symlinks is an install shape the scan itself
documents as normal), a later read failure was evicted for a generic
'issue-limit' row that raises neither attention nor truncation, and the
dialog headline read "All installed Orca skills are up to date" over a
path that could be hiding a stale copy.

Attention issues now outrank the budget, capped at a small reserve so an
adversarial tree of unreadable folders cannot pin one issue per folder.
2026-07-28 17:15:41 -07:00
Brennan Benson 3d54f2174f fix(skills): tell the user how to fix a skill the updater cannot converge (#11248)
Re-lands #11129, which was merged into #11128's branch rather than main and
so never reached main. Content is identical to the reviewed and live-QA'd
head ac5ec5b0b0 (1775d83cf6 + ac5ec5b0b0, minus the intermediate merge).
2026-07-28 17:15:41 -07:00
Brennan Benson f5216bcbaf fix(skills): stop the skill review dialog contradicting the badge that opens it (#11128)
* fix(skills): stop the skill review dialog contradicting the badge that opens it

A skill whose only fault was an edited copy or one Orca could not read turned the
setup-rail badge amber and offered Details — and Details opened a dialog headlined
"All installed Orca skills are up to date." over an empty list. The badge says
something is wrong, the dialog it points at says nothing is.

The grouping only returned skills with an out-of-date copy, so those two states
produced no row and the summary fell through to the all-clear headline. Include a
skill when a copy needs attention as well, using one shared predicate so the badge
and the dialog cannot disagree again. A plugin's own copy of a same-named skill
stays out: that is the vendor's, not the user's drift.

* test(skills): pin that a routine outdated copy raises no attention marker
2026-07-28 17:15:41 -07:00
Brennan Benson 644bb66266 fix(skills): trust the updater's lock when a run installs content newer than the bundle (#11220)
skills update installs source-repo HEAD, which routinely runs ahead of the
revisions a shipped build bundles. The post-run re-scan hashed that content
'unrecognized' (the registry has never seen it) and the verdict counted it as
a failure — so a clean update reported "The update didn't finish / Updated 0
of N", and Retry repeated the false failure forever because the CLI now
no-ops (lock == source). The 'newer-known' escape hatch never fires: the
generator always points the manifest at the registry's newest snapshot, so no
observed content can hash to a revision newer than the bundle.

The verdict now computes the git tree sha of the observed bytes (a port of
the generator's hashing, verified byte-for-byte against git write-tree and
against every shipped skill's manifest gitTreeSha) and accepts an
unrecognized placement when that sha equals the lock's skillFolderHash: the
lock is the CLI's own record of what it installed, so disk matching lock
means the command did its job — the bundled registry simply has not seen
that revision yet.

Half-written bundles (sha mismatch), unreadable copies, removed skills,
degraded aliases, and outdated copies at the lock hash all still fail.
2026-07-28 17:15:41 -07:00
Brennan Benson 82edd9f7e0 fix(skills): stop offering an update the CLI provably cannot perform (#11110)
* Revert "fix(skills): stop reporting a failed update when the CLI succeeded (#11105)"

This reverts commit a8660839ee.

* fix(skills): stop offering an update the CLI provably cannot perform

The Update skills dialog reported "The update didn't finish" / "Some skills
could not be updated" with an armed Retry, directly above the runner's own
"All global skills are up to date" log line, on a clean exit 0.

`skills update` decides what to do by comparing its lock's `skillFolderHash`
against the source tree and never reads disk (published CLI, dist/cli.mjs:
`latestHash !== entry.skillFolderHash`). So once the lock records a revision
the filesystem does not actually have, it reports up-to-date, exits 0 and
writes nothing. No retry converges it.

Gate eligibility on that instead of blaming the run afterwards: a name stays
updatable only while the lock's hash and the revision the DISK hashes to
agree. Those that disagree fall through to `needs-attention`, which
skill-freshness-display-status.ts already documents as the state for a copy
"out of date somewhere the update command cannot reach". The dialog no longer
offers them, so it can neither claim failure nor claim success.

Deliberately compared against disk, not the bundled manifest: the updater
pulls from the source repo, which legitimately runs ahead of what a build
ships, and gating on the bundle would withhold real updates. Both sides must
also be positively identified — an unplaceable lock hash is not evidence.

Also reverts a8660839ee, which forgave `outdated` post-run. That turned the
false failure into a false success ("Updated 2 skills" over copies nothing
wrote) and let an empty verdict swallow spawnError, so an offline run
published green.

Reachable by anyone who updated during the stub-conversion window — every
bundled skill has a stub -> full -> stub oscillation in its last three
registry revisions.

* fix(skills): do not gate a skill when a placement is unidentifiable

`diskTreeShas` drops digests matching no known revision, so `every` ran over
only the resolved half — one stale copy beside one unidentifiable copy gated
the name, contradicting the unknown-stays-eligible rule the function documents.
Require every observed digest to resolve before treating all placements as
mismatched.

Caught by CodeRabbit on #11110.

* fix(skills): judge convergence only over placements the update command writes

A same-name plugin-cache or repo copy could defeat the gate two ways: an
unidentifiable repack read as an unresolved placement, and a cache copy
parked at the lock's own revision read as an anchor — both re-arming the
unwinnable update on a drifted canonical. Filter to
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, matching eligibility and outcome.
2026-07-28 17:15:40 -07:00
NeilandOrca a6bb57a6d9 fix(sidebar): restore legible selected-workspace fill in dark mode (#11139)
#8321 mixed the selected card's wash into the opaque --worktree-sidebar
surface, lifting dark mode to 16% (#4b4b4b). Card text lost too much
contrast against it.

Return both modes to a translucent wash (light 8%, dark 10%) so the
brighter selection border added by #8321 carries the selected state
instead of the fill.

Co-authored-by: Orca <help@stably.ai>
2026-07-28 17:15:40 -07:00
Neil 7cc453e68b fix(terminal): gate the developer menu behind Option and unblock manual parking (#11091)
The Developer submenu shipped visible on every worktree right-click, and its
Park terminal action always refused with "These terminals cannot be parked
safely."

- reveal the Developer submenu only when Option/Alt is held at right-click,
  captured at open time so it can't shift rows mid-menu
- stop a settled pendingActivationSpawn tag from refusing a manual park: first
  activation stamps it on every tab and only a fresh updateTabPtyId consumes it,
  so a reattached tab kept it forever
- resolve the single leaf of a rootless layout for parked watcher coverage, so a
  workspace whose panes never mounted is no longer permanently uncoverable
- restore the !isVisible park guard dropped in #11016, which let the workspace
  being viewed unmount its own terminals
- split manual-park eligibility out of the automatic cold-park policy module
2026-07-28 17:15:40 -07:00
Brennan Benson bf27227437 Revert "Implement robust orchestration primitives and connected-server workers (#9925)"
This reverts commit cd05f2ff93 for the
v1.4.160-rc.4 cut.

Three files needed manual resolution because later commits touched the same
regions. Their post-9925 work is preserved:

- src/main/runtime/rpc/core.ts and dispatcher.ts keep clientCapabilities and
  the RuntimeCapability import from #10129 (close-intent negotiation for
  paired runtimes).
- src/cli/runtime/client.ts keeps the deferred websocket-transport import
  from #10919.
2026-07-28 15:57:26 -07:00
github-actions[bot] 9a3b348e82 release: v1.4.160-rc.3 2026-07-28 03:28:33 +00:00
NeilandBrennan Benson e104010593 fix(skills): require updater registration (#11051)
* fix(skills): require updater registration

* fix(skills): reject incomplete updater registrations

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-27 20:18:29 -07:00
Wooseong KimandBrennan Benson f10b6de2c7 fix(relay): tolerate wall-clock skew in host-proof validation (#10474)
* fix(relay): tolerate wall-clock skew in host-proof validation

A few seconds of local clock lag made challenge issuedAt appear in the
future, so host-proof rejected every handshake and Mobile Relay looped
connecting forever. Allow ±30s skew while keeping the 10s challenge window (#10401).

* fix(relay): preserve host-proof challenge bounds

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-27 19:49:18 -07:00
Brennan Benson ee7ec43149 fix(codex): keep a host account switch inside the host lane (#10992)
* fix(codex): keep a host account switch inside the host lane

markLiveCodexSessionsForRestart walked every tab's PTYs and carded any pane
whose foreground looked like Codex. There was no lane check anywhere in that
path, so a host account switch raised a restart notice on live SSH/relay panes
— and a notice mutes the pane, so the user's remote terminal went deaf.

The notice was provably spurious: a remote spawn carries a connectionId, so
isDaemonHostSpawn is false and no CODEX_HOME is ever injected. The remote Codex
uses the remote machine's own credentials; a local selection cannot reach it.

Scope marking by lane instead. A pane's lane is (machine, runtime): `host`,
`wsl:<distro>`, `env:<id>` for a relay environment, or an SSH connection that
no managed selection can name. A switch made while a runtime environment is
active still cards that environment's panes, which is the case that made the
old "mark everything" behaviour look right.

WSL was the same defect, not a separate one. A Windows run saw a WSL pane
correctly escape a host switch, but only because its foreground read `wsl.exe`,
which fails the Codex-foreground test — the Win32 process table cannot see into
a WSL2 VM. That is incidental: `codex`, `node` and `python3` foregrounds are all
eligible today, so a WSL pane that surfaces one (WSL1 pico-processes are in
Win32_Process) would be carded by a host switch. The lane is now what decides.

Also stop queueing remote and SSH panes into the bind-driven stale sweep at all.
recordCodexPaneAccountForSpawn bails on anything that is not a daemon host
spawn, so listStalePanes can never report one stale, yet each pane still spent
every rung on a 15s-timeout remote RPC — ~75s per pane since the ladder widened
to five rungs.

The lane vocabulary moves to shared/ so the renderer keys panes exactly as a
launch does rather than growing a third copy of the rules.

Refs #10757

* fix(codex): key a WSL pane by the distro its launch actually used

The lane guard derived a pane's WSL distro from the workspace UNC path alone.
A launch does not: pty.ts hands getCodexSelectionTargetForPty a third argument,
the resolved runtime's distro, so a wsl.exe pane on an ordinary Windows-path
worktree launches under `wsl:Ubuntu`. The renderer keyed that same pane
`wsl:__default__`, so the Ubuntu switch never reached it — the pane kept the old
account with no notice, which is #10757 returning by a new route on the exact
platform the issue was reported from.

Resolve the distro the way the spawn does: the project execution runtime first,
then terminalWindowsWslDistro. Both are already in renderer state.

Also match a distro-less WSL switch against the whole `wsl:` family. Two
mutations reach the renderer as `{runtime:'wsl', wslDistro:null}` while writing
concrete distro slots: selecting the system default clears EVERY wsl slot
(setSelectedCodexAccountIdForTarget), and `add` stores the distro it discovered
from the machine. Keying those to `__default__` missed the very panes they
re-pointed. The residual cost is over-marking a sibling distro after an add,
bounded to this machine's WSL panes and far cheaper than a stranded pane.

An owner-less remote pane colliding with the host lane was untested — that
collision is what would mute a working remote terminal, so pin the disjointness
rather than the literal key.

Refs #10757

* fix(codex): resolve a pane's lane the way its launch resolved it

Three more places where the renderer's lane and the launch's lane disagreed.
Each disagreement is silent: too narrow and a stranded pane never gets its
notice (#10757 returns), too wide and a healthy pane is muted, because a notice
makes onData drop every keystroke.

Shell: main runs the request through resolveLocalWindowsTerminalRuntimeOptions,
so an unset shellOverride still lands on WSL when that is the Windows default.
Reading tab.shellOverride alone called such a pane `host` — a host switch would
have muted a working WSL terminal. Gate on the renderer platform, as pty.ts
gates on process.platform.

Cwd: a terminal's startup cwd is deliberately not constrained to the worktree
(resolveTerminalStartupCwd, #7685), and main keys the lane off that cwd. Follow
it through the same shared call instead of reading the workspace root, so a pane
split after `cd \\wsl.localhost\...` is keyed where it actually runs. The comment
claiming a pane can never start outside its workspace was simply wrong.

Family match: narrow the previous commit. setSelectedCodexAccountIdForTarget
only nulls every WSL slot when the account is null AND no distro is named; any
other write lands in one slot. So claim the family only when the change actually
cleared them all, and let `add` pass the created account's concrete target
rather than the row's "WSL default". Both call sites already knew which case
they were in.

Refs #10757

* fix(codex): derive the pane's project runtime the way main does

The previous commit reached for getLocalProjectExecutionRuntimeContext as a
stand-in for main's resolveLocalProjectRuntimeForWorktreeId. They are not the
same function, and the differences both produce wrong lanes:

- It falls back to `state.activeRepoId` when the worktree is not a git worktree,
  so a folder-workspace pane inherited whichever repo happened to be selected.
  That is not a property of the pane at all — the lane moved when the sidebar
  selection moved. On a WSL project it both muted a healthy host pane and hid
  the notice a host switch owed it.
- It synthesizes a runtime from `inherit-global` where main returns undefined,
  and its host branch rewrites an explicit `wsl.exe` to powershell.exe, keying a
  live WSL pane `host`.

Walk repo -> project directly instead, which is what resolveLocalProjectRuntimeForRepo
does, and use it only to supply a distro — never to downgrade a shell. That also
drops the throwing call out of this path entirely; the lane runs outside
scanCodexPanes' inspection guard, so a throw there would have lost the notice
for every pane in the batch, not just one.

Also find the added account by diffing the roster. Reading it back through the
row's active id returns null once two distro slots are filled, which sent the
notice to `wsl:__default__` while `add` had written a concrete distro.

Refs #10757

* fix(codex): key the lane off the runtime the renderer actually shipped

Reverses the project-runtime half of the previous commit. That commit assumed
main resolved the project runtime itself, so it re-derived one by hand. It does
not: for a local pane the RENDERER computes it with
getLocalProjectExecutionRuntimeContext and ships it with the spawn
(pty-connection.ts), and pty.ts feeds that straight to getCodexSelectionTargetForPty.
So the helper is not an approximation to be improved on — it is the launch.

The hand walk dropped the global Windows runtime default, which is what turns an
`inherit-global` project preference into WSL. A user who set their runtime
default to WSL but left terminalWindowsShell alone would have had every live WSL
pane keyed `host`: muted by a host switch, and missed by their own. It also
disagreed on folder workspaces, where the launch really does resolve through the
active repo.

Keep the repair-required early return: that call throws, and it sits outside the
scan's per-pane failure guard, so a throw would lose the notice for every pane in
the batch rather than one.

Separately, floating terminals have no workspace root, so their startup cwd is
used verbatim (resolveTerminalStartupCwdForWorkspace). Resolving one against a
root that does not exist yielded no cwd at all, keying a floating Codex pane on
a WSL filesystem as `host`. Read its cwd directly.

Require exactly one new account before trusting the roster diff — an unloaded
prior roster makes every account look new, and Add Account is not gated on it.

Refs #10757

* fix(codex): stop claiming a floating-terminal cwd the tab never has

The floating-terminal branch read tab.startupCwd, which no floating creation
path ever sets (FloatingTerminalPanel, FloatingTerminalWindowControls,
floating-workspace-tab-creation all pass none). Its cwd is resolved over IPC
from settings.floatingTerminalCwd and handed to the transport as a prop, so it
never reaches the store at all. The branch was inert and its comment described
main's handling of args.cwd rather than what the code read.

Say what is actually true: a floating pane is keyed by its shell, and the
configured-WSL-cwd-under-a-host-shell case is a known gap. Guessing from the
unresolved setting would risk the mute direction, which is the expensive one.

Also pin the repair-required early return. resolveLocalWindowsTerminalRuntimeOptions
throws there, and the lane runs outside scanCodexPanes' per-pane failure guard,
so without it Promise.all rejects and every pane in the batch loses its notice.
That guard had no coverage; removing it now fails with the spawn error.

Refs #10757

* fix(codex): trust the lane main recorded at spawn over a re-derived one

The switch path re-derived each pane's Codex lane from current state while
main had already written the resolved shell, cwd and distro at spawn. Four
review rounds each found another divergence between the two, and the
derivation still answers for a launch that never happened once the user
edits a runtime preference.

Prefer the recorded lane where one exists; keep the derivation for the panes
main never records — pre-feature panes, LocalPtyProvider spawns and remote
ids — and log when the two disagree.

* refactor(codex): drop a redundant guard around the recorded-lane lookup
2026-07-27 19:47:07 -07:00
Hansss 2cf91b8e69 fix(skills): complete plugin-cache scans without false attention (#10865)
Fixes the P0 where skill cards showed an unclearable amber "Needs attention"
while the Details dialog reported everything up to date.

Root cause: when the plugin-cache scan tripped one of its own bounds it recorded
an incomplete path, and inventorySkillFreshness expanded that into one fabricated
placement per manifest skill at a path it never stat'ed. Those synthetic
"inaccessible" copies lit the pill, were filtered out of the dialog, and could
never be cleared because plugin-cache is not an updatable topology.

- Removes the fabrication; reports typed scan issues instead.
- Requires readable SKILL.md evidence before promoting a directory to a candidate,
  so a same-named foreign plugin (Codex's own computer-use) no longer flags.
- Prunes skill payloads and node_modules so ordinary vendor caches stop tripping
  the depth and entry bounds.
- Partitions scan reasons: only a real read failure raises a pill; bounds that
  ended the walk block an all-clear claim; the rest are Details-only.

Fixes #10633. Refs #10659, #10904, #10918, #10775, #10791, #10813.
2026-07-27 19:11:57 -07:00
Neil 025ceec04e test(e2e): harden release latch and teardown coverage (#11036) 2026-07-27 19:04:56 -07:00
Neil e4b113b11d fix(native-chat): keep known sessions loading until flush (#11032) 2026-07-27 19:04:35 -07:00
Neil dbbeae3512 fix(terminal): clear retained overlay for editor tabs (#11030) 2026-07-27 19:04:17 -07:00
Brennan Benson 84c70d6e29 fix(mobile): heal a stale input line before sending diff-review notes (#11035)
* fix(mobile): heal a stale input line before sending diff-review notes

The stale-input marker is keyed by terminal handle, not by surface, so a
paste orphaned on a terminal by native chat is still marked when the user
sends diff-review notes to that same terminal — and those notes were
submitted on top of it. Gate the send on the heal, as the native-chat
answer send already does; when the clear fails, surface the error instead
of dropping the note silently.

Completes a follow-up deliberately deferred by #10480.

* test(mobile): assert the post-heal send carries the notes, not another clear

* style(mobile): trim the stale-heal comment to its non-obvious why

Keeps the terminal-handle keying and the #10228 link (why a NativeChat-named
helper runs in diff review) and the deviceToken rationale; drops the clause
that restated the call. Addresses CodeRabbit review feedback.
2026-07-27 19:00:40 -07:00
Neil 12ef12c55b chore(quality): ratchet Oxlint, React Doctor, and Zustand performance (#11034)
* chore(quality): ratchet lint and Zustand performance

* fix(ci): stabilize React peer lock snapshot

* fix(ci): isolate PR diff and React Doctor CLI
2026-07-27 18:58:36 -07:00
Jinjing 9a8e21a47e fix(workspace-space): bound traversal memory and serialize local disk scans (#11026)
* fix(workspace-space): serialize local disk and cap traversal memory

Prevent resource exhaustion during large workspace scans by limiting
local disk access to one concurrent `du` call and capping portable
traversal memory to 100k entries or 64 MiB per worktree. Fixes July 27
incident with 298 worktrees causing host stalls and renderer OOM.

Portable traversals now use fixed-worker iterative frames instead of
recursive promises. Capacity failures become unavailable rows. Behavior
below limits is unchanged.

* fix(workspace-space): bound concurrent SSH fallback traversals

Desktop-side SSH fallback traversals run in the main process with independent admission
budgets. Without limiting, up to six concurrent traversals could stack six 64 MiB budgets.
Cap remote fallback traversals to 2 concurrent, keeping aggregate admission at 2 × 64 MiB.

Also make capacity error messages reflect configured limits instead of hardcoded defaults.
2026-07-27 18:50:52 -07:00
Kyle 55e98e8182 fix(native-chat): auto-grow the composer input up to 8 lines (#10848)
Reviewed with an independent reproduction. Replaced the JS measure pass with layout-native CSS on both surfaces and removed the shared hook, fixing 72-142px of Linear issue title hidden after a window resize.
2026-07-27 18:47:11 -07:00
Luiz Eduardo de Oliveira Fonseca 60c7faf930 fix(browser): restore configured zoom after page reload (#10800)
Reviewed with an independent reproduction. Rewrote the reload-zoom reassert to be per-pane instead of sharing the value zoom in/out writes, fixing Cmd/Ctrl+0 reset and cross-tab zoom leakage, with E2E coverage proven to fail on revert.
2026-07-27 18:42:51 -07:00
badaverse 872a9c3930 fix(terminal): make copy work in the HTTP web client (#10534)
Reviewed with an independent reproduction over a real plain-HTTP origin. Replaced the hidden-textarea fallback with a capture-phase copy event so clipboard text never enters the DOM, and added rejection handlers to the surfaces that had none.
2026-07-27 18:42:47 -07:00
JeongUk Park b601643960 fix(sidebar): stop worktree cycling from reopening collapsed groups (#10513)
Reviewed with an independent reproduction. Replaced a redundant per-keypress layout rebuild with cycling over rendered sidebar rows; verified the chord is not hardcoded to metaKey.
2026-07-27 18:42:35 -07:00
ye4241 f1d54c123b fix(file-explorer): commit inline rename on outside click and stop double-click rename flicker (#10867)
Reviewed with an independent reproduction. Both fixes verified; added a flush for a dropped directory toggle and an integration test connecting blur/Escape through to renameFileOnDisk.
2026-07-27 18:42:32 -07:00
Neil c6076a507c ci(release): detach non-blocking E2E (#11031)
* ci(release): detach non-blocking E2E

* test(release): pin E2E dispatch retries
2026-07-27 18:40:22 -07:00
Brennan Benson b316174525 fix(panes): stop a stray mouse hijacking a touch divider drag (#11021)
The drag predicate recorded only the active pointerId, never its type, so
during a touch-started drag any primary mouse or pen event failed the id
match but passed the non-touch fallback and was accepted as the active
pointer. Record the type at pointerdown and require both ends to be
non-touch, keeping the WSLg mouse-press/pen-motion relay working.
2026-07-27 18:17:49 -07:00
Neil edc6cc007d fix(new-workspace): center agent selection in create dialog (#11020)
* fix(new-workspace): center agent selection in create dialog

Pin the Agent combobox mark to 14px, drop residual button padding, and
use a full-width min-w-0 trigger so icon, label, and chevron align with
Project/Name in the new worktree dialog.

* fix(new-workspace): optically align agent picker content

* fix(new-workspace): center selected agent content
2026-07-27 18:11:40 -07:00
Brennan Benson dc45f79465 fix(runtime): drain the session-tabs coalescer when a listener subscribes (#11022)
multi-client-navigation-isolation.integration.test.ts fails intermittently on
clean main — measured at 4-8/24 with a flush-window sweep — and has been taxing
unrelated PRs across the repo.

Mechanism, confirmed by instrumented trace (schedule t=23412, listener REGISTER
23460/23461, coalescer fire 23462): a ~4ms race between the 50ms session-tabs
notify coalescer and listener registration in onMobileSessionTabsChanged. When
the pending timer fires after the listener registers but before
session.tabs.activate is handled, it emits a stale
{type:'updated', activeTabId:'host-tab'} that the test consumes.

flushAll() at the top of onMobileSessionTabsChanged, before the listener joins the
set — mirroring the flushAll() its own unsubscribe closure already performs.

DRAINING rather than cancelling is load-bearing: cancel/dispose has no emit, so it
would silence the stale frame but DELETE the pending update for subscribers
already registered, leaving them stale until an unrelated next schedule(). That
would trade a flaky test for a real lost-update bug. The newcomer cannot miss an
update by having it drained — listMobileSessionTabs and the coalesced emit read
the same mobileSessionTabsByWorktree map, and snapshot-read to listener-add is
macrotask-atomic.

Sweep: 4-5/24 before, 0/24 after. The regression test uses fake timers and is
two-sided — it fails if flushAll is removed (stale frame delivered) and fails if
flush is swapped for dispose (nothing drained), on different assertions, so it
pins the specific choice rather than merely the presence of a change.

Reviewed independently and returned clean with both mutations re-run by the
reviewer. A latent flushAll re-entrancy double-emit exists in principle — fire()
does not re-check pending.has the way flush() does — but is unreachable: both
production callers' listeners only invoke the dispatcher reply, a shape that has
shipped since #8141 on the symmetric unsubscribe flush.

Not verified: live paired-device mobile/relay behaviour (no device available); the
mobile conclusion rests on a code trace.
2026-07-27 18:07:57 -07:00
Brennan Benson 4340781c9f fix(codex): drop the resume argv when session provenance is unverifiable (#10805)
Closes #10793.

When Orca could not verify the originating Codex session file it either threw — a
red per-pane toast and a failed spawn, reported as constant spam on #10757 — or
returned null. Returning null did NOT start a fresh session: the renderer had
already baked ['codex','resume',<id>] into the command and pty.ts never rewrote
it, so CODEX_HOME simply fell through to whichever account was selected.

The resume argv is now dropped so a plain `codex` launches, with a banner telling
the user. The invariant — never run `codex resume <id>` under an account that does
not own that rollout — is now satisfied by construction rather than by refusing to
spawn. A verified resume is unchanged and still pins CODEX_HOME to the
originating home.

Reviewed over two adversarial rounds; seven defects found and fixed, including a
HIGH where local-provider (non-daemon) spawns still carried
ORCA_SEQUENCED_STARTUP_COMMAND with `resume <id>` — the wrong account behind a
banner claiming it started fresh. `env` is now declared after the strip so no
point in the handler can reach the pre-strip value.

Live-validated in a real Orca dev build: all five cases proven on the SPAWNED
PROCESS, including a real rollout under an untrusted home (the only shape that
discriminates) and the local-provider path forced by stopping the daemon.

An earlier CI failure on multi-client-navigation-isolation.integration.test.ts was
investigated and is a PRE-EXISTING flake — a ~4ms race in the session-tabs notify
coalescer that fails 5-8/24 on clean main, more often than on this branch. Fixed
separately in #11022.

Not verified: no Windows execution — its POSIX-only tests skip there and the
#10757 reporter is on Windows. SSH is partial: no spurious banner or drop observed
against a real target, but headless spawn does not deliver startup commands so the
remote argv could not be read. The relay/mobile notice channel deliberately has no
banner; the argv drop does happen there, so the invariant holds.
2026-07-27 18:06:30 -07:00
Neil bd5a991ce7 fix(e2e): launch plugins with real app identity (#11024) 2026-07-27 17:56:07 -07:00
Brennan Benson 9c5d827d6a fix(codex): keep history, restarts, and account identity across an account switch (#10770)
Fixes #10757. Switching Codex accounts broke three ways, all rooted in the
self-contained per-account CODEX_HOME from #9501.

HISTORY DISAPPEARED. Codex's own /resume picker only lists rollouts under the
launch CODEX_HOME, and nothing bridged history into a per-account home — only
the AI Vault's discovery scan knew about the other homes. Every other
Orca-visible home's rollouts are now hardlinked in, on selection and again at
launch, so one physical log is listed everywhere.

THE RESTART PANEL STUCK. A queued restart was only drained by a mounted
TerminalPane, but the prompt covered every stale pane in the worktree including
parked and cold-deferred tabs. Requesting a restart now answers the prompt
immediately while the pane keeps its pending restart, and a pane drains it when
its reconnected PTY binds.

PANES STAYED ON THE OLD ACCOUNT. CODEX_HOME is fixed in a shell's environment at
spawn and the daemon keeps those shells alive across app restarts, while the
restart notices are renderer state and are discarded. Each PTY's launch account
is now recorded on disk and compared against the current selection at startup.

Also merged in: #10802 (a dismissed notice no longer kills the pane's keyboard),
#10803 (the sweep arms on real PTY binds, and launcher Codex panes are no longer
filtered out by Windows deepest-process reporting), #10804 (a resume-pinned pane
now says which account it is on), #10870 (the restart card no longer parks focus
on its destructive Restart button), #10853 (the retry ladder is widened past the
Windows worst case).

Six independent reviews found real defects in every original PR, several of them
dead-keyboard bugs and three introduced by the fix for another defect in the same
loop. Live QA on macOS covered every PR; Windows was validated three times.

WINDOWS: pass 1 found two defects that made the stale-account fix a no-op there
(the sweep fired before any PTY was bound and never retried; launcher panes were
filtered out). Pass 3 at the merged head: the prompt appears on its own after a
restart — warm ~3.7-4.2s, cold ~21s needing rung 4, so #10853's widening was
load-bearing rather than precautionary; an ordinary sentence typed into a healthy
pane while another pane's card is up reaches that pane and kills nothing; a pane
running vim after exiting Codex gets no card, still none 45s later. auth.json
byte-identical across every pass.

KNOWN GAPS, stated rather than implied: #10804 is unverified on Windows
(auto-resume could not be manufactured there); cross-volume Windows is untested
and expected to yield no bridged history (EXDEV, and Codex ignores symlinked
rollouts); a cold-parked pane never binds so the sweep never covers it; the
subagent-deepest launcher shape could not be reproduced on Windows, so that
branch is fixture-verified only; WSL passed isolation but the resume mechanism is
host-lane only. A host-account switch also marks and mutes live SSH remote panes
— confirmed pre-existing on main by two independent QA runs — tracked separately
in #10992. Related pre-existing defect filed as #10863.
2026-07-27 17:53:41 -07:00
Brennan Benson 0956d5ca3a feat(skills): run skill updates in the background without a terminal (#10843)
* feat(skills): run skill updates in the background without a terminal

The Update skills dialog had no primary action at all — its footer was only
Re-check and Close, and the real action was a pre-filled command in an embedded
PTY that the user had to press Enter on. Orca already builds and validates that
command, so it now runs it.

- Add a headless runner for `npx --yes skills update <names> --global -y`. Both
  --yes flags are load-bearing: npx's skips the package-install prompt, and the
  skills CLI's takes its own non-interactive branch. stdin is ignored so
  `process.stdin.isTTY` stays falsy, which is the other half of that gate.
- Own the run in main so closing the dialog backgrounds it instead of killing
  it, and surface it in the status bar: spinner while running, a green check on
  success that clears itself, and a failure that persists until acted on.
- Derive per-skill outcomes by re-scanning the freshness inventory after exit
  rather than parsing stdout. `skills update` has no --json (that flag exists
  only on `list`) and reports progress per-source, not per-skill, so the run
  bar is deliberately indeterminate instead of faking a percentage. When the
  re-scan has a verdict it outranks the exit code.
- Drop the version trail from the rows and surface the skill list and skip
  reasons directly instead of hiding them behind a disclosure.

Also fixes a width bug the collapsed disclosure used to hide: deep plugin-cache
paths set the dialog's width and pushed the footer actions off-screen.

* refactor(skills): use one row component across every update state

The ready and running views were separate components with different row
shapes, so pressing Update swapped the dialog's body for a different layout.
They are now the same `SkillUpdateRow` instances throughout — only the status
slot's contents change — and a test asserts the row is literally the same DOM
node from "update available" through pending to the result.

- Collapse each skill's locations behind its own disclosure. A skill with
  several plugin-cache copies was dumping every path inline and burying the
  actions; the row now shows a location count and expands on demand.
- Put status in a single slot between the name and the count rather than a
  leading icon column. A leading icon has nothing to show in the resting state
  and reserving its box just indented every name past an empty gap.
- Pin the running/finished run's names in `groupSkillFreshness` so a successful
  update doesn't drop its own rows the instant the re-scan lands.

`skill-freshness-group.tsx` becomes `skill-location-chip-copy.ts` — only its
chip label/tooltip helpers survived, and it no longer holds JSX.

* fix(skills): place the status glyph left of the skill name

Review feedback on the row header: the badge belongs immediately right of the
name so it reads as part of it, and the run's status circle/check belongs to
the left of the name rather than sharing the badge's slot on the far right.

Name, glyph and badge are now one left-aligned group; the location count and
chevron stay right-aligned. `available` still has no leading glyph — an empty
reserved box only indents the name past a gap with nothing in it.

* fix(skills): correct the headless update run's verdict, cancel path, and stopping copy

Review fixes for the headless skill-update runner.

Main process:
- Judge per-skill outcomes on a positive signal. "Absent from
  eligibleUpdateNames" is not success: a deleted, half-written, or unreadable
  skill also leaves that list, so a corrupt update reported a green check.
  skillUpdateFailedNames now requires every convergent placement to come back
  current or newer-known.
- Retire a child's handlers with a per-run token. A failed spawn emits error
  *and* close, so the second settle clobbered the real spawn ENOENT; a
  cancelled child could also settle, or write output into, the run that
  replaced it. The token guards the rescan's finish closure too.
- Hold the run `running` until the killed process tree is actually dead.
  Releasing on the synchronous path let an immediate re-Update spawn a second
  npx writing the same bundles, with a watchdog so a sweep that never settles
  cannot wedge the run.
- Kill the tree, not just the npx wrapper, via killWithDescendantSweep.
- Publish an error instead of a silent `started: false` when the cmd.exe rail
  rejects the resolved npx path, which a profile directory containing & or %
  is enough to trigger on Windows.
- Coalesce captured output into one push per tick instead of structured-cloning
  the whole buffer to every window on each progress frame.

Renderer:
- Keep rows on screen while the settling re-scan runs. Refreshing the inventory
  nulls it synchronously, so every row vanished at the moment the result
  appeared. Rows render off the last good scan; eligibility stays on the live
  snapshot so nothing is authorized off stale bytes.
- Retry the names that failed, not the eligibility list that same re-scan has
  just emptied.
- Add Stop, restoring the escape hatch the embedded terminal used to provide,
  and say "stopping" on every surface rather than claiming the update keeps
  running in the background.
- Show a skipped skill's reason outside the disclosure, so it no longer depends
  on a mount-time defaultOpen a later re-scan can never re-fire.
- Drop the summary line telling users to open "Update details", a control this
  PR removes; it was translated into four languages.
- Keep the success linger from retiring a result the open dialog is showing.
- Delete skill-location-chip-copy.tsx: an unreferenced copy of the old row
  component, colliding on basename with the module that is actually imported.

* fix(skills): divide update list from summary
2026-07-27 17:43:17 -07:00
Brennan Benson 72875bda24 fix(ci): stabilize flaky terminal and SFTP tests (#11018) 2026-07-27 17:40:46 -07:00
Neil 3d98cda5b2 fix(release): accept lowered telemetry declarations (#11019) 2026-07-27 17:38:54 -07:00
Brennan Benson c75c04eaae fix(runtime): reclaim orca-runtime.json when it stops describing this runtime (#10840)
* fix(runtime): reclaim orca-runtime.json when it stops describing this runtime

On macOS the Chromium single-instance lock is silently defeated whenever
`SingletonSocket`/`SingletonCookie` go missing from the profile — and the
socket they point at lives under `$TMPDIR` (`/var/folders/.../T`), which
macOS purges after 3 days (`com.apple.bsd.dirhelper`,
CLEAN_FILES_OLDER_THAN_DAYS=3). A launch that slips past the lock runs a
full startup, republishes `orca-runtime.json` with its own pid, and leaves
the CLI on a dead pid once it exits: `orca status` reports
`stale_bootstrap` and every terminal command fails `runtime_unavailable`
while the original app keeps serving.

The owner now watches its own discovery record and republishes once no live
runtime is described. Reclaiming only a dead pid is deliberate: two live
runtimes sharing a profile would otherwise fight over the file.

Reproduced on macOS with two real Orca main processes on one profile: the
second instance took the lock and clobbered the record, and killing it left
`stale_bootstrap` against the still-healthy first instance. With this
change the owner reclaimed the record in ~2s and the CLI returned to
`ready`.

Refs #7848

* test(runtime): assert stop() clears the metadata ownership timer

The republish guard alone kept the shutdown test green, so the watch teardown was unasserted. Also drop the doc claim of startup/activation callers that do not exist.

* test(runtime): stand in a real live pid for the sibling-runtime case

Windows never assigns pid 1, so the hardcoded sibling read as dead there and
the watch would reclaim the record. Own a synthetic pid instead and let
process.pid play the live sibling.
2026-07-27 17:32:55 -07:00
github-actions[bot] c25a130236 release: v1.4.160-rc.2 v1.4.160-rc.2 2026-07-28 00:31:09 +00:00
NeilandOrca 0edc95fa35 perf(editor): cut per-keystroke work on two rich-markdown paths (#10862)
* perf(editor): cut per-keystroke work on two rich-markdown paths

Doc links: both plugins walked every text node and ran matchAll on each — the
auto-convert appendTransaction once per keystroke, the preview decorations once
per keystroke and again per caret move. A link needs `[[`, so gate on a native
substring check first. The two walks had duplicated their guard sequence; they
now share one predicate. 3.1x-3.8x over the repo's own markdown.

Annotations: resolving a comment's block re-serializes the whole document (every
node, plus every adjacent pair), and both the highlight-range and
comment-at-position paths did that once per comment — O(comments x document).
Build the blocks once and pass them down. On a 12-node fixture with 8 comments
that is 184 serializations down to 23.

* test(editor): pin the one-build serialization baseline

Review feedback, all four points:

- The serialize-count assertions compared many-comments against one-comment, so
  they would have passed if BOTH built blocks twice. Pin the absolute count
  (23 = 12 nodes + 11 adjacent pairs) derived from the fixture size, so a
  regression to per-comment building fails instead of comparing equal. Verified
  by reverting the hoist: 2 tests fail.
- Skip an empty benchmark corpus instead of evaluating `index % 0` and
  dereferencing undefined.
- Build fixture paths with path.join.
- Condense the benchmark header to purpose plus parity guarantee.

Co-authored-by: Orca <help@stably.ai>

* test(editor): harden doc-link performance evidence

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:18:31 -07:00
NeilandOrca d547e278f9 fix(mobile): deliver the notifications a reconnect missed, and never persist a watermark past them (#10816)
* fix(mobile): keep the reconnect watermark alive across the app's own teardown

The catch-up added in #8690 could never run. app/index.tsx unsubscribes the
notification stream on every non-'connected' state and builds a fresh
subscription on reconnect, so the closure holding the ready-counter, the
delivered watermark and the seen-set is destroyed exactly when a reconnect
needs them. Every reconnect looked like a cold open, `reconnectReadyCount`
was always 1, and notifications dispatched while the socket was down were
never fetched.

Move that state to a per-host module-scope session so it survives the
teardown.

Refs #8591

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): tag the notification watermark with a counter epoch so a desktop restart can't kill catch-up

The desktop's notification `seq` is a per-process in-memory counter that starts
at 0 on every launch. The mobile client's watermark is persisted in AsyncStorage
and monotonic. After a desktop restart the two index different counters, so a
client holding seq 57 meets a fresh counter at 2, `57 >= 2` cuts everything, and
reconnect catch-up dies silently until the new process out-dispatches the old
watermark — 57 notifications later. Users see nothing and get no error (#8591).

Stamp every dispatched notification with an epoch identifying the counter
lifetime, ride it on the `ready` frame and the getMissedSince response, and
persist it beside the watermark. A watermark whose epoch doesn't match the live
counter is void: the client resets to 0 and the desktop returns its retained
buffer instead of nothing.

The epoch param is optional on the wire in both directions, so a client or
daemon that predates it degrades to today's seq-only cut rather than erroring.

Also extracts the OS-permission helpers to notification-permissions.ts (re-
exported, so no importer changes) to keep mobile-notifications.ts under its
max-lines budget.

Mutation-tested: 3 mutations applied to the epoch logic, 3 killed — including
the storage-seed race guard, whose first mutant survived until the deferred-read
test was added.

* fix(mobile): make the notification watermark atomic and counter-scoped

Round-1 review found four ways the epoch fix could still lose notifications.
All four are addressed here.

1. Seen-set survived an epoch change. Seen-keys are seq-derived, and terminal
   bells carry no notificationId (they key on `seq:N` alone). After a restart
   the fresh counter re-issues low seqs, so a replayed post-restart bell was
   dropped as a duplicate of a bell from the previous counter. The dedup window
   belongs to one counter lifetime, so it is cleared on epoch change.

2. Legacy watermarks were trusted. Pre-upgrade installs stored a bare seq with
   no epoch. Adopting the first observed epoch as "nothing changed" left that
   unprovenanced seq cutting a counter it was never measured against — #8591
   through the upgrade path. An epoch-less seq no longer survives adoption.

3. seq and epoch were separate storage keys. A process death between the two
   writes left epoch-B beside seq-57-from-A: a pair that looks internally valid
   on the next launch and is therefore trusted. They are now one JSON value,
   which cannot tear, with a read-only migration from the legacy key.

4. Sessions were never retired. They live at module scope so they survive the
   subscription teardown a reconnect performs, so host removal is the only
   thing that can drop them. Removal now retires the session and its watermark.

Mutation-tested: 3 mutations, 3 killed. The first version of the bell test
passed with the fix removed — it exercised the live path, which only adds to
the seen-set; only the replay path consults it. Rewritten against the replay
path, it fails with `expected 1 to be 2`: the literal lost notification.

Mobile notifications + transport: 355 passed. Desktop replay: 11/11.

* fix(mobile): catch up on the first connection after a cold open

Catch-up hung off 'has this process connected before', which is false on the
first ready of a fresh launch — exactly the post-upgrade / post-eviction case
that loses everything between the stored watermark and the next live seq. Wait
for the persisted read, then catch up whenever this device has delivered for
the host before; a first-ever pairing still gets no replay.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): serialize live delivery behind the watermark seed, and key catch-up on the record

Co-authored-by: Orca <help@stably.ai>

* test(mobile): pin the two catch-up mechanisms mutation testing found unguarded

Mutating each mechanism of the #8591 fix in turn showed two survived with the
suite still green: the seed's epoch-provenance check, and the host session
outliving the subscription teardown. Both are load-bearing, so pin them.

- seen-set survives teardown: the desktop's retained buffer replays a
  notification already delivered live, and only the session-scoped seen-set
  stops a duplicate banner.
- a seed resolving after a live epoch was adopted must not reinstate the dead
  watermark. Not reachable through subscribeToDesktopNotifications today
  ('ready' awaits the seed first), so it asserts on the exported pair and says
  so.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): serialize notification delivery per host so the watermark can't outrun what was shown

Addresses two MAJOR findings from review of this branch.

MAJOR #1 — the watermark could be persisted past a notification the user never
saw. `deliverLive` advanced `lastDeliveredSeq` before awaiting the local show,
and replay + live delivery ran concurrently, so a live seq 11 handled while
catch-up was still showing seq 6 persisted 11. A process death before 7..10 were
shown lost them permanently: the next launch asks the desktop for seq > 11.

This predates the branch — `origin/main` advances the watermark at the same point
— so it is a residual this fix closes, not a regression the branch introduced. It
is fixed here because the branch is what makes the watermark load-bearing.

Three changes:
  - the advance moves AFTER the show/dismiss await, so the watermark means
    "everything up to here reached the user" rather than "was dispatched"
  - a per-host `deliveryTail` promise chain (`enqueueHostDelivery`) serializes
    deliveries, so a monotonic advance is also an in-order one
  - the catch-up batch is ONE queue entry, not one per event. Awaiting per event
    returns to the event loop between replays and let a live event slot in
    between seq 6 and 7 — which is exactly the interleave being fixed. The RPC
    stays outside the queue: `sendRequest` waits up to 30s and holding the chain
    for that would stall live delivery on a slow link.

MAJOR #2 — every delivery awaits the persisted read, so an AsyncStorage read that
never settled disabled the host's notifications for the whole app lifetime, with
no error and nothing to see. The seed is now bounded at 3s; a late seed still
applies when it lands. Proceeding unseeded is strictly better: the watermark
stays 0, so catch-up over-fetches and the seen-set de-duplicates.

Serializing removed an overlap the duplicate-suppression relied on:
`showLocalNotification` deduped two same-id events by observing the first still
pending when the second arrived. With deliveries serialized the first completes
first, so the second saw no pending state and scheduled a second banner for the
same notification. The claim moves to enqueue time, where the overlap is still
observable. Dismisses are deliberately not claimed — a dismiss for a shown id is
what retires it.

Evidence — each mechanism disabled individually against the unchanged suite:
  - batch-as-one-entry -> reverted to per-item enqueue: ordering test fails
  - watermark advance -> moved back before the await: ordering test fails
  - seed timeout -> removed: wedged-read test fails
  - live-path claim -> removed: concurrent-dedup test fails
  - replay-path claim -> removed: cross-path dedup test fails
Each kills exactly one test, so no mechanism is unguarded and none is redundant.

`mobile-notifications.test.ts`'s local `flushAsync` drained 10 microtask ticks.
Deliveries are now several awaits deeper, so a fixed tick count under-drains; it
yields to the macrotask queue instead. Verified with real timers that the
behavior it asserts is unchanged — only the drain depth was wrong.

Full mobile suite: 344 files, 2499 passed, 2 skipped. tsc clean, oxlint clean.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:18:05 -07:00
NeilandOrca 2dac0741b4 fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew (#10817)
* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs #9993

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse

The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.

* fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact

The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — #9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).

* fix(daemon): refuse 2031 authority from a daemon that cannot retract it

Round-2 review found a wire-compatibility hole in the original #9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- #9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.

* fix(daemon): gate background delegation, not just the fact stream

A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

Co-authored-by: Orca <help@stably.ai>

* fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.

* fix(terminal): preserve mode 2031 reply decisions

* fix(build): record daemon protocol v29 compatibility

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:47 -07:00