* fix(browser): keep the address bar editable in a narrow toolbar
Every other browser toolbar control is shrink-0, so the address bar was
the only flexible item and absorbed the entire squeeze: below roughly
420px of pane width it collapsed to the leading globe icon with a
zero-width input. Clicking it only opened the suggestion dropdown, which
inherits `--radix-popover-trigger-width` and so rendered at icon width —
there was no way to type or edit a URL in that tab.
Focusing a squeezed bar now lifts the form out of the toolbar flow and
overlays the row edge to edge, giving a full-width editable field that
navigates on Enter (and a full-width suggestion list for free). A
measured slot stays in flow so the overlay cannot feed back into its own
width, and the slot keeps a min width so the globe remains a real hit
target instead of being overlapped by neighbouring buttons.
Fixes#11090
Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE
* fix(browser): use the documented floating shadow for the expanded bar
STYLEGUIDE.md defines exactly three elevation levels and forbids a
fourth; shadow-md was not one of them. The overlaid address bar is a
floating surface, so it takes the documented floating shadow already
used by the other floating surfaces in this pane.
Claude-Session: https://claude.ai/code/session_01Mx53f7erbtw5NraS8HdXKE
* test(browser): make the narrow-toolbar regression deterministic
The spec passed only from a clean profile. Two preconditions it set once are
actively undone by the app:
- BrowserPane re-focuses a blank tab's address bar across several animation
frames plus the blank-url did-finish-load handler, so a single blur() was
reverted and the bar never reached its squeezed resting state.
- Startup paths re-open the right sidebar. At a fixed 700px window that leaves
the pane ~70px, so the overlay had nowhere to go and the field measured 0px.
Settling these separately let whichever settled first drift back while the next
one ran. Re-assert them in one loop until they hold simultaneously, and size the
window from the chrome actually measured instead of assuming a fixed 700px.
Verified 8/8 green, and still fails at the overlay assertion when the fix is
disabled, so the regression coverage stays real.
---------
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* 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.
* fix(agent-status): track Codex rollout subagents
* fix(agent-status): resolve cross-day Codex child rollouts and unblock CI gate
Codex files each rollout under its own local start date, so a session that
runs past midnight spawns children into a sibling day directory. Scanning
only the parent's directory left 13% of real subagent spawns (48/371 across
local rollouts) permanently unresolved, which pinned a phantom "working" row
and re-ran readdirSync every poll tick forever. Resolve the child's own day
directory from occurred_at_ms, and time-box a child whose rollout stays
unreadable so a deleted or never-written file can't leak a working row.
Also make the hook HTTP handler return void: the changed-code quality gate
keys findings by span overlap, so this PR's added line inside the pre-existing
async createServer callback resurfaced no-misused-promises as a new finding.
Tests cover cross-day resolution, grace-period retirement, and that the poll
re-arms across successive roster changes (the prior tests passed even when
the poll died after its first change).
* fix(agent-status): keep the Codex subagent poll alive across nested hooks
A nested non-codex CLI inherits its parent's ORCA_PANE_KEY, so its hook
POST reached scheduleCodexSubagentPoll and tore the timer down before the
source guard, silently ending polling while a rollout child was still live.
* 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.
The Update skills dialog showed "The update didn't finish" / "Some skills
could not be updated" with an armed Retry, directly above the runner's own
log line saying "All global skills are up to date" — on a clean exit 0.
`skills update` compares its lock's recorded hash against the source and
never reads disk (dist/cli.mjs: `latestHash !== entry.skillFolderHash`).
Once the lock has advanced past the installed bytes it prints up-to-date,
exits 0 and writes nothing. The copy left behind is a recognised older
revision, so the post-run re-scan sees `outdated`, skillUpdateFailedNames
counted that as a failed run, and skill-update-run settled to state 'error'.
Retry re-ran the same command, which no-op'd again — a closed loop.
Reclassify `outdated` as "the command did not converge this", not "the run
failed". The freshness badge still marks the copy not-current, so nothing is
hidden; the run just stops being blamed for it.
A botched write is still caught: a half-written bundle hashes to
`unrecognized`, a wholly-degraded or removed copy leaves no convergent
placement, and process-level failure still surfaces via the spawn error.
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.
Add translations for OrchestrationPage coordinator and child PR names, plus agent workflow status messages (initial states and progress beats) across all supported languages (English, Spanish, Japanese, Korean, Simplified Chinese).
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
* feat(new-workspace): make the project picker a type-ahead field
The Create-worktree Project slot read as bulky and unpolished: a label row,
an add-project icon, a 36px outline trigger and a chevron, all spent before
choosing anything — then a popover carrying its own *second* search box,
two-line rows, and a footer that scrolled out of reach.
The field is now the search. Typing filters in place, so the nested search
box is gone. Exactly one row is armed at any time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor rather than two
competing highlights. Armed is tracked by row key, not index, so a list
arriving late over SSH cannot slide a different project under a keypress
the user already aimed.
Rows are single-line at 28px with an on-row Enter cap that takes space only
while armed, and "Add a new project" is pinned to the popover edge so it
survives every state — scrolled, filtered to nothing, or no projects at all.
Long names and deep paths degrade deliberately: the name keeps up to half
the row and the path elides from its middle, so two monorepo siblings stay
distinguishable as …/services/checkout-api vs -web where a flat truncate
rendered both identically.
Recency is derived from when each project last had a workspace created,
which is the action this picker is about to repeat — no new store field.
The shell keeps data-project-combobox-root + role=combobox and stays
focusable, so the composer's initial-focus and project-required handlers
still land on it.
* fix(new-workspace): align, scroll and loosen the project picker
Five fixes to the type-ahead picker, three reported and two found while
checking for related breakage.
Alignment: the name and its smaller detail line were centred as boxes, so
the 12px path sat visibly high against the 14px name. Both now share a
baseline, in the committed field and in every row. The dot mark and the
Enter cap are chips rather than text, so they stay centred on the row.
Scrolling: the mouse wheel did nothing over the list. The composer is a
Radix Dialog, and react-remove-scroll cancels wheel events for portaled
content outside the dialog's DOM tree — the scrollbar dragged fine but the
wheel was dead. The old cmdk list carried a shim for exactly this; the
plain scroll pane that replaced it did not, so it has its own now.
Density: rows go 28px -> 32px, row text 13px -> 14px and detail 11px ->
12px, with a taller Add row and more air above section headings.
Escape stranded a query: with the list closed but text still typed, Escape
was ignored (it was gated on the list being open), leaving the field
showing text that matched nothing and hid the committed project. Escape now
always restores the committed display, and only bubbles when there is
nothing to undo.
Listbox ownership: options sat inside unroled section and scroll wrappers,
which breaks the listbox -> option relationship assistive tech relies on.
Sections are groups carrying the heading as their label, and the scroll
pane is presentational.
Both new behaviours are covered by tests verified to fail without the fix.
* chore(tools): keep the project-picker design lab
The exploration harness behind the picker rewrite: 16 interactive design
variants rendered against the app's real tokens and shadcn primitives, so a
prototype is a drop-in ProjectCombobox rather than a mockup.
Worth keeping because the frames encode bugs that only reproduce in
context. DialogFrame renders the picker inside a real Radix Dialog, which
is the only way the react-remove-scroll wheel bug shows up; the fixtures
carry duplicate display names and deep sibling paths that a naive truncate
renders identically.
Run with: npx vite --config tools/wt-picker-lab/vite.config.ts
* fix(new-workspace): stop the project list flashing open, shrink its empty state
Opening the picker read as a double flash. The shared popover surface is
translucent and fades 0 -> 1, which is right over the app canvas but wrong
here: this popover lands directly on the composer dialog, so for the length
of the fade the Name field underneath showed straight through the list and
you saw two layers at once. The list now uses an opaque surface and zooms
without fading, so it is solid from the first frame. Every other popover
keeps the blur and fade.
The "No projects match your search." state was a 60px centred block sitting
next to 32px rows, which read as a different kind of surface and made an
empty result feel like an error. It is now sized and aligned like a row.
The lab's dialog frame focused whatever Radix picked first, which popped the
Add-project tooltip on open and masked the real problem; it now focuses the
name field the way the real composer does.
* fix(new-workspace): square mark, centred empty state, and keep the list open on tab-focus
Four fixes, three reported and one found while sweeping for others.
Square mark: the option dot had a `rounded-full` override, so a project read
as a circle here and a square everywhere else (jump palette, sidebar). Drop
the override and use RepoBadgeMark's own shape.
Centred empty state: "No projects match your search." was left-aligned after
being shrunk to row height; centre it.
Tab-focus blinked the list shut: the field lives in the popover's anchor, not
inside its content, so Radix's dismissable layer saw focus land "outside" and
closed the list the instant you tabbed in. Focus and pointer events within
this control no longer dismiss it; genuine outside events still do.
Junk text could strand the field: typing a query that matched nothing and
then clicking away left the text sitting there with the list closed, showing
no project and no error. A query only means something while the list is open,
so closing without committing now clears it.
On pressing Create with no project: no change needed. The create gate has not
depended on project selection since #4991, and both submit paths already call
showProjectRequiredError(), which sets the inline message and turns the field
red via aria-invalid. Verified end to end: the button is pressable, the press
paints the field destructive, and the message appears beneath it.
* feat(new-workspace): rebuild the Run-on picker to match the project picker
"Run on" was the last composer field still built the old way: an outline
trigger wrapping a cmdk list, two-line rows, and no way to search. It now
matches the project picker, so the two fields in the same form read as one
control.
The field is the search — type to filter hosts, paths and recipes with no
nested search box. Exactly one row is armed at a time and Enter takes it;
hovering arms, so pointer and keyboard drive one cursor. Rows are 32px with
the label and its path on a shared baseline, the path eliding from its
middle so two deep sibling paths stay distinguishable. The popover surface
is opaque and unfaded because it lands on the composer dialog, where a
translucent fade shows the form underneath.
Two behaviours the project picker doesn't have are preserved. Disconnected
hosts keep their inline Connect action, tracked per host so one stalled
connect never blocks the others, and the list stays open so the connecting
state is visible. Two rows open nested lists rather than committing: VM
recipes, and "Add host" pinned to the popover edge so it survives every
state — scrolled, filtered to nothing, or with no hosts at all. Enter and
ArrowRight open a submenu; Escape backs out one layer at a time.
Extracted from NewWorkspaceComposerCard (-563 lines) into files that each
stay under the line limit without a suppression.
Tests: the run-target cases asserted cmdk internals (`[cmdk-item]`,
aria-disabled, cmdk-separator) that no longer exist. Rewritten against
behaviour and the listbox roles instead. All 22 composer tests pass,
plus a live sweep of 11 interactions in a real dialog.
* fix(new-workspace): drop the Enter cap, fix submenu hover, match the Add rows
Three follow-ups on the two composer pickers.
The ↵ cap on the hovered row is gone from both. On a run-target row it sat
next to the Connect action and read as a second, competing affordance; the
highlight already says what Enter will take.
Submenu rows never highlighted under the pointer. They passed a hardcoded
`armed={false}`, so the recipe list and the Add-host choices were the only
rows in either picker with no hover state. They now track their own hover.
"Add a new project" used a chunky FolderPlus where "Add host" uses a plain
Plus. Both rows were already the same height and type, so matching the glyph
is the whole difference.
* fix(new-workspace): restore the folder glyph, two-line Add-host cards, quiet Connect rows
Three follow-ups.
"Add a new project" goes back to FolderPlus — matching "Add host"'s plain
Plus made the two consistent but lost the glyph that says which kind of
thing is being added.
A disconnected host row no longer repeats its status. The Connect button
already says the host isn't connected, so "Connect this host to set up
projects" beside it was saying it twice. Rows without a Connect action keep
their detail, since there it explains why the host can't run.
The Add-host choices go back to two-line cards. Their descriptions explain
what you're picking ("Use an existing machine over SSH" vs "Pair another
Orca runtime"), unlike a host row's detail, which just labels a host you
already recognise. RunTargetRow grows a `stacked` variant for that rather
than making the single-line row do both jobs.
* fix(new-workspace): give Run on the same vertical rhythm as the other fields
Run on is nested inside the Project block so the two share its error and
empty states, which also put it on that block's 4px internal spacing. It
reads as its own field, so it sat noticeably tighter than the 16px gap
every other field in the composer gets. Pad it to match.
* refactor(new-workspace): share the type-ahead machinery between both pickers
Project and Run on were built one after the other, so each grew its own copy
of the same mechanics: query and open state, arming by row key, the
arrow-key walk, scroll-the-armed-row-into-view, the react-remove-scroll
wheel shim, and the closes-drops-the-query rule. Two copies of subtle
behaviour is two places for it to drift.
useTypeAheadCombobox now owns all of it. Callers pass a function that turns
a query into row keys and get back the query, the armed key, and the
movement helpers. Run on layers its submenu state on top by wrapping
`close`, which is the only part that isn't shared.
The two long class strings both files repeated verbatim — the field shell
and the opaque unfaded popover surface — are named constants now, so the
reason they differ from the stock popover recipe is written down once
instead of implied by a duplicated literal.
No behaviour change: 16,456 renderer tests pass, plus the 22-check live
interaction sweep across both pickers in a real dialog.
* fix(new-workspace): drop aria-expanded from option rows, remove the design lab
`aria-expanded` isn't a supported prop on `role="option"`, so the submenu
rows were claiming a state screen readers can't interpret there.
`aria-haspopup` alone already says the row opens a menu.
Removes tools/wt-picker-lab. It was the harness for exploring this redesign
— 12 interactive variants — and it did its job, but the 11 that lost are
dead code, and its prototypes were the only thing failing the react-doctor
gate (5 errors, all in throwaway variants; the shipped pickers had none).
Refresh the persisted Windows PATH during preflight without blocking Electron's main thread. Bound and deduplicate registry reads, preserve the last good cache on failure, skip host refresh for WSL, and add Windows regression coverage.
* 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>
* 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
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.
* 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.
* 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.
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.
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.
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.
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.
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.
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.
* 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
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.
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.
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.
* 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
* 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.
* 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>
* 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>
* 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>
* perf(git): overlap getBranchCompare's head-of-chain reads
Four git spawns ran strictly in series before any compare work began:
branch --show-current, the base-ref probe, rev-parse HEAD, and rev-parse <base>.
Three are independent -- compareRef is display-only metadata and HEAD's oid does
not depend on the base ref -- so they now run concurrently. The fourth was
redundant outright: the probe already runs `rev-parse --verify --quiet
<ref>^{commit}` and discarded the oid it printed, which was then re-resolved by a
second spawn. resolveWorktreeBaseCommitOid returns that oid so it can be reused;
hasWorktreeBaseCommitRef now delegates to it, leaving its other 4 callers
untouched.
3.6-3.7x on a short remote base label (192ms -> 52ms), 1.44x on an
already-qualified refs/... base, which skips the probe by design.
Reuse is keyed by ref: resolveWorktreeAddBaseRef returns at its first successful
candidate, so only that ref's oid is ever read back. Peeling is safe because only
refs/heads and refs/remotes candidates reach the probe, where ^{commit} is a
no-op.
No new git features: this removes a spawn rather than adopting an option.
Co-authored-by: Orca <help@stably.ai>
* fix(git): preserve compare semantics across providers
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>