* test(skills): execute the plugin-cache entry limit instead of reasoning about it
The entry bound that ended a plugin-cache scan was never reached by any test.
Every existing `entry-limit` case builds a synthetic issue object at the display
layer, so both emission sites in scanKnownPluginSkillCandidates — the dirent read
loop and the declared-skill-root resolve — were verified by reading the code. The
real bound is 16,384 dirents, and the declared-root guard only fires when the count
lands in a one-entry window below it, which is why neither was ever fixtured.
Makes the entry bound injectable the way the candidate bound already was, folding
both into a `PluginSkillScanBounds` bag so a fourth positional number is not needed,
and adds:
- a case that truncates the dirent read and asserts the scan drops both real skills
and reports `entry-limit` at the root;
- a case that truncates while resolving declared roots, asserting the declared root
that exists was still walked so the guard under test is the resolve one;
- an inventorySkillFreshness case at the production 16,384 bound asserting a
truncated scan produces zero fabricated placements — the #10918 regression.
No bound value or scan behavior changes.
Refs #10918.
* docs(skills): state the declared-root guard's real window in the bounds comment
* test(skills): pin entry-limit to the scan root, not the crossing directory
Both entry-budget fixtures crossed the bound in the same directory they named,
so swapping recordIssue(rootPath) for recordIssue(directory) at either guard
passed the whole suite — the dialog would surface a nested path and no test
would say so. Cross the budget below the root instead.
* test(skills): assert the declared-root guard's threshold, not just its firing
The declared-root entry guard's ±1 boundary was left unasserted on the claim
that it is unkillable: admitting one more declared root was said to always cost
a dirent the read-loop guard then catches identically. It does not. A declared
root that does not exist reads no dirent, and when it is the last one the loop
simply ends — so the scan completes and reports nothing.
Restates both budgets against the fixture's exact entry count: one short of it,
where only the last root's resolve can cross, and exactly at it, where nothing
should be reported. `>` -> `>=` and `>` -> `> max + 1` now each fail a test.
* test(skills): assert the entry bound stops the walk, not just what it reports
Dropping `limitReached = true` at the dirent guard survived every case: the
already-read entries of the crossing directory are still descended, and the
scan reports a depth-limit for a path it never reached. A nine-level chain
whose deepest directory sits at the depth bound and crosses the budget on the
second of its two children kills that, with no symlink and no Windows skip.
* test(skills): derive the walk-stop fixture from the depth bound it depends on
The walk-stop case detects a dropped `limitReached` only because its chain is
exactly MAXIMUM_PLUGIN_SCAN_DEPTH deep, so the entries already read when the
entry bound trips are rejected on depth if the walk keeps going. That coupling
was a hardcoded 9. Raising the depth bound left the test passing while it
stopped killing the mutant it is the only cover for — verified by A/B: at
depth 12 the hardcoded fixture lets the mutant survive, the derived one does
not. Other tests in the file fail loudly on that change, which is exactly what
makes the silent one dangerous.
Exports the bound the way the file already exports its four siblings for the
same reason. No behavior change.
* Add OMP skill discovery source
* review: scope OMP discovery to the shared provider and cover orchestration
- Drop the `SkillProvider` widening: `~/.omp/agent/skills` is a provider home
like `~/.pi/agent/skills`, so it carries `agent-skills` and identifies OMP
through the source `owner`. That keeps every `Record<SkillProvider, ...>`
(including the Skills page label map) at a zero diff.
- Add the `omp-home` orchestration coverage location and map OMP to it, so the
Agent coverage panel stops reporting a real OMP install as missing.
* review: guard the Pi/OMP matcher anchors
Both roots end in agent/skills, so dropping either leading segment let one
agent's install mark the other with a green suite. Also corrects the OMP
owner assertion's rationale: no OMP native-chat profile exists, so owner
matters because a null owner leaks OMP-only skills into other pickers.
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Keep main-thread hang detection independent of the blocked Electron event loop while reducing watchdog memory from 47.1 MiB to 11.5 MiB. Preserve marker, recovery, and telemetry behavior with a bundled worker-thread entry.
Mobile Chat UI subscribes to an agent transcript by providerSession.id, so
losing that id blanks the chat: use-mobile-native-chat-session clears the
message list and then returns without subscribing when sessionId is null.
Two places dropped the id on a status ping that carried no session metadata,
both while the agent was idle at its prompt — exactly when mobile reads it:
- The renderer store refused to carry the id across `done`. A completed turn
does not end the provider session (the TUI stays alive and resumable), and
OSC 9999 repaints plus reconnect snapshot replays re-deliver a metadata-less
`done` onto an already-done row, so retention has to cover done -> done.
- The main-process OSC ingest overwrote the cached row without the id. The OSC
wire payload has no providerSession field, so an OSC observation is never
evidence the session ended. Dropping it there erased the id from persisted
rows (lost across restart) and from headless `orca serve`, which serves those
rows to mobile directly rather than through the renderer store.
Both keep the turn boundary: a new turn after `done` still starts clean, so a
reused pane cannot inherit a finished session.
Closes#10630
Revealing a live PTY from a paired phone passes the tab id baked into
its env, but the create-terminal handler only recognised ownership via
tab.ptyId and the live ptyIdsByTabId map. With the worktree closed on
the desktop no pane is mounted, so ptyIdsByTabId is empty and tab.ptyId
holds at most one leaf's PTY -- a split pane's second PTY lives only in
the persisted layout. Ownership missed, and createTab ran with an id
that already existed; it mints a fresh uuid on collision, so a second
tab appeared on the same session (#10486).
Resolve ownership through the persisted layout as well. Every binding
consulted is an exact match on the ptyId, so all of them outrank the
reveal's tab id hint: that hint is written once when the PTY spawns and
is never rewritten, so it goes stale as soon as a pane is dragged to
another tab. A mounted pane outranks a recorded one, same-tier
conflicts stay ambiguous so the mobile mount planner keeps failing
closed, and the hint is what resolves a reveal that nothing records
yet -- which is also what keeps paneKey hook attribution intact.
* feat(browser): let Shift invert link routing instead of always forcing the system browser
Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a
no-op when that is already where links go. Users who keep Link Routing off have
had no gesture to pull a single link into Orca's built-in browser.
Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes
the modifier open a link the opposite way from the setting. It ships off, so the
one-way escape hatch is unchanged for every existing user.
The title and description name the destination the modifier actually reaches and
flip with the parent setting, since "the opposite" is meaningless on its own.
- openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so
every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes
identically. forceSystemBrowser stays for callers that must bypass settings.
- The terminal hover hint names Orca when the modifier would open there, and is
re-resolved per hover so toggling applies without recreating panes.
- Link Routing's own copy drops "always uses your system browser", which the new
toggle can falsify; the nested row states the live destination instead.
* fix(browser): route the Checks panel hosted-review link through the shared modifier
The "Open on GitHub" button had its own Shift+Cmd/Ctrl escape hatch that passed
forceSystemBrowser directly, so it kept the old one-way behavior while every
other surface honored the invert setting. Route it through modifierHeld like
the terminal and markdown paths.
Also wraps the modifier row's description in translate(); the title in the same
file was localized but the description returned raw English (caught in review).
* fix(browser): make link-routing modifier copy true in every state
Review follow-ups on the Shift-inverts-routing change.
- The nested row promised "⇧⌘+click opens one in Orca" in the present tense
while its own toggle was off, so the out-of-box state described behavior the
user did not have. Phrase it as enabled-state copy, matching sibling rows.
- The parent Link Routing description gained "opens a link the other way",
which is false in the default state and contradicts the child row when
inverting is on. No fixed sentence there is true in every state, so the child
row — which knows the live destination — now owns the claim. That leaves
getBrowserLinkRoutingShortcutLabel unused, so drop it.
- The rich markdown editor still forced the system browser while the preview of
the same file honored the modifier, so one link routed two ways depending on
which view it was clicked in.
- A remote runtime pins every link to the system browser, so the hover hint
could promise Orca for a click that lands elsewhere. Gate the hint on the same
condition openHttpLink uses.
- Index both modifier titles: the search entry is built with openLinksInApp
false, so the row was unfindable by the title it renders when routing is on.
- Drop the ariaLabel that shared no words with the visible label (WCAG 2.5.3)
and add the four missing settings-search keyword keys to all five catalogs.
* test(editor): pin the rich markdown Shift+click routing hop
The editor half of the modifier fix had no coverage — reverting it to
forceSystemBrowser left the suite green while the same link routed one way in
the markdown preview and the other way in the rich editor. Also pins that a
non-local source owner survives the hop, since that is what keeps an SSH file's
links out of Orca's browser.
* test(editor): cover the Ctrl chord for rich markdown link routing
This file is the only test of handleRichMarkdownEditorClick, and it exercised
metaKey alone, so the isMac branch of modKey had no coverage off macOS. Also
stop claiming the source-owner case proves SSH links stay out of Orca — it
proves the owner survives the hop; http-link-routing.test.ts enforces the rest.
* style: trim review comments to the one-line house rule
Both explained the change adequately in two lines; the extra lines were worked
examples, not information.
* fix(browser): keep Link Routing copy unchanged until inverting is enabled
Removing the "⇧⌘-click always uses your system browser" sentence outright
reworded the row for every user on upgrade, including everyone who never turns
the opt-in on. Restore it verbatim in the default state and only hand the chord
sentence to the nested row once inverting makes "always" untrue.
* revert(editor): keep rich markdown Shift+click on the system browser
Per Brennan: the editor's Shift path hands the link to the client OS and should
not follow the invert setting — the preview opening in Orca is the intended
divergence, not a bug. Restores main's call exactly; the test now pins the
divergence so a future consistency pass cannot erase it silently.
* fix(browser): surface the inverted modifier on the hosted-review link
The hosted-review click path now passes modifierHeld, so with inverting on and
Link Routing off the chord opens in Orca — but the hint stayed gated on
openLinksInApp, hiding a live gesture. Resolve the destination instead of a
boolean. Default-off output is unchanged.
* test(browser): pin the inert modifier hint when links already open in Orca
* refactor(terminal): require the pane link hint so dropping the wiring fails to build
The optional option fell back to a duplicated copy of the legacy hint string, so deleting the hook wiring reverted the tooltip silently with every test green.
* fix(browser): trim the runtime id before hiding the hosted-review modifier hint
openHttpLink and terminalUrlOpenHintOptionsFor both trim, so a blank runtime id hid the hint while the click still reached Orca.
The mobile terminal WebView only handled touch. Wheel events fell through to
xterm, which either scrolls its own hidden viewport or — in the alternate
screen — emits cursor keys via onData, and the mobile onData bridge forwards
those to sendMobileTerminalQueryReply, which drops anything that is not a
query-reply grammar. Net effect: an external mouse or trackpad scrolls nothing
inside the terminal, and nothing reaches the PTY.
Attach a wheel handler on the terminal surface that reuses the touch path's
router: alternate-screen and mouse-aware TUIs get bounded cursor keys / wheel
reports through the existing validated terminal-input gate, and the normal
buffer gets the same coalesced scrollback scroll as a swipe.
Refs #6863, #8818
* fix(activity): bound Activity portal readiness oscillation (React #185)
React error #185 is "Maximum update depth exceeded" -- an unbounded render loop,
not setState-after-unmount as the crash reports superficially suggest. Verified in
react-dom@19.2.7's production bundle: getRootForUpdatedFiber throws
Error(formatProdErrorMessage(185)) when 50 < nestedUpdateCount. That frame is the
top of all 14 crash stacks across shipped 1.4.156 / 1.4.158 / 1.4.159.
nestedUpdateCount and rootWithNestedUpdates are module-level globals keyed on the
ROOT, not on a component. One runaway loop saturates the counter and the throw
lands on whichever fiber calls setState next, so SortableTab (terminal.workbench),
the Radix Presence/MenuPortal (page.settings) and the sidebar.worktrees button are
innocent bystanders with misleading component stacks -- four boundaries, one bug.
The driver is Activity's portal readiness pass. When 'ready' is unreachable while
'unavailable' stays reachable, updateReadiness flips loading<->unavailable from
inside useLayoutEffect -- React's sync lane, the lane that increments
nestedUpdateCount -- so it saturates and throws rather than merely being slow. A
prior investigation captured this live on Windows via CDP at 239 renders in 847ms.
Bound the oscillation: after ACTIVITY_PORTAL_READINESS_MAX_FLIPS consecutive
loading<->unavailable transitions the latch parks on 'unavailable' until a real
'ready' arrives. The latch rewrites only the hook's output, never the DOM probe,
so a terminal that churns during a slow attach and then genuinely comes up still
releases it. Because the counter is global per root, this also protects every
innocent bystander in the app -- most of the user-visible value.
Extract reconcileActivityPortalThreads and resolveActivityPortalSwap so the portal
loop is testable against the real collaborators. Note the reconciliation keeps its
worktree+tab comparison deliberately: Terminal mounts one TerminalPane per
(worktree, tab) and routes it by worktree+tab, so staging a same-tab pane would
leave the staged slot empty and its readiness stuck on 'loading' forever. Same-tab
switches swap in place via isolatedPaneKey instead.
Co-authored-by: Orca <help@stably.ai>
* fix(activity): coalesce portal readiness updates
* test(activity): tighten readiness regression coverage
---------
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): avoid flash while restoring parked terminals (#10871)"
This reverts commit 5a6a9e0b28.
Reverted for terminal rendering regressions (flashing, lost content).
Conflict resolution preserves the forwardRef signature from #10433 and
drops the parked-presentation gating #11016 fed with its effective set.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): limit pre-paint WebGL resume to macOS (#10794)" and "fix(terminal): stop switch bold flash and Windows lag (#10692)"
This reverts commits 4681edb520 and
8f5a45401f.
#10794 was itself a partial revert of #10692, so both are reverted
together: the Windows retained-WebGL LRU and the macOS pre-paint
(layout-phase) visibility transition that survived it. Terminal
visibility resume returns to passive disposal and recreation on every
platform, and the WebGL context ceiling returns to a flat 128.
Co-authored-by: Orca <help@stably.ai>
* Revert "fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)"
This reverts commit 97cb32c1cc.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): verify clipboard writes so TUI "Copied" never lies
Windows/Electron can return from clipboard.writeText without updating the
OS clipboard, so Claude Code / OpenCode OSC 52 copy and terminal selection
copy looked successful while paste stayed empty (#8977, same root as #5611).
Verify standard clipboard writes by reading back after write, surface OSC 52
host write failures with a toast, and route selection copy through a shared
helper that only clears the selection after a confirmed write.
* fix(terminal): harden clipboard write verification
* fix(terminal): contain clipboard failure notifications
* fix(terminal): isolate verified clipboard writes
* fix(terminal): address greptile clipboard verify nits
Drop the dead onWriteFailure pass-through from the coalesced OSC 52
handler so failure toasts stay owned by the microtask path. Cover
multi-line / CRLF identity in write+verify tests, and export the
verification-failed error constant for stable matching.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(worktree-palette): stop blanked display names from crashing Cmd+J
Blanking the "Display Name" field made buildWorktreeMetaUpdates emit
`displayName: undefined` as a present key. The store's `{ ...worktree,
...updates }` spread then erased the live name, so the next palette
keystroke threw "Cannot read properties of undefined (reading
'toLowerCase')" in searchWorktrees (crash a1f81ea1, build 1.4.159).
Fixed at three layers so no single guard is load-bearing:
- Producer: persist the blanking intent as '' instead of undefined, and
let WorktreeSet accept '' so remote/SSH hosts stop dropping the clear.
- Store: applyWorktreeUpdates and applyDetectedWorktreeUpdates drop
present-but-undefined keys for fields Worktree declares required.
- Readers: resolveWorktreeDisplayName/resolveWorktreeBranchLabel mirror
the main-side mergeWorktree fallback (custom -> branch -> folder) for
all four Cmd+J searches, the checks/review index, and the render site.
Co-authored-by: Orca <help@stably.ai>
* test(worktree): assert omitted display name shape
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)
SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.
- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
through both park verdicts, both selectors, watcher coverage, and the
watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).
DESIGN.md records the approved plan and the H1 magnitude non-claim.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)
Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the
slice-A switch off) had unlimited retention: the parking cap/TTL only ever
saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's
panes forever. Retention is now memory-bounded, not eligibility-bounded.
- terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min
TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over
hidden worktrees ordinary parking can never evict; reuses the hot-retain
ranking so last-active exemption, deterministic ties, and deadline-driven
rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount
would fresh-spawn and orphan the live shell).
- Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto
(darkness for uncoverable tabs is the accepted cost); buffers captured via
the sleep-flow registry before the unmount render; retention TTL added to
the recheck deadlines for budget candidates only.
- Verdict stays out of its own effect deps; policy test asserts idempotence
and time-monotone membership (flip-loop dwell regression).
- Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C)
The retention budget (slice B) must exempt worktrees holding fail-open or
foreign-worktree ptys — a remount would fresh-spawn and orphan the live
shell — which would leave that class unbounded again. Instead, past the same
45min retention TTL their hidden panes drop to the minimum scrollback tier
(measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is
gone by design, reveal restores the configured cap for future output).
- terminal-hidden-scrollback-demotion.ts: module-state verdict registry
(parked-watcher pattern) with content-equality notify damping; applied in
the existing scrollback-rows effect in use-terminal-pane-lifecycle.
- selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone.
- Retention TTL wakeups now also cover exempt worktrees so demotion fires.
- Kill switch: settings.terminalHiddenScrollbackDemotion (default on).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix)
applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling
it from applyReattachPayload (already inside the coordinator when a relay
replay exists) deadlocks on the coordinator's tail chain. The model paint now
mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate +
screen, dimension-matched, escape tail last) and arms the restored-snapshot
seq baseline so deferred/live chunks the snapshot covers dedupe instead of
double-painting. Also falls through (no early return) so reattachPayloadApplied
still latches. Adds the folder-workspace id parity unit case.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1)
Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and
reveal restores marker content at multi-viewport scrollback depth. DESIGN.md
records the as-built deltas (inline paint, force-park shape, last-active
floor) and the residuals so follow-ups aren't lost.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1)
A relay restart empties the replay buffer; the reveal previously painted
nothing even when main's headless model held the session. The reattach now
prefetches the model snapshot when no structural replay exists (SSH-shaped
ptys only) and paints it inside the coordinator; emptiness is judged on the
composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an
alt-screen snapshot with an empty screen frame still paints.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2)
Per the approved contract each slice reverts behind its own switch: slice C
now requires only the master terminalHiddenViewParking plus its own
terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for
demotion candidates even with the budget switch off. No DEFAULT_SETTINGS
entries exist for sibling flags (defaults are the '!== false' optional
pattern), so no explicit defaults are added.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3)
One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park
for its whole worktree, pinning co-located remote-runtime tabs forever. The
worktree now force-parks while exempt tabs keep their mounted panes via a
per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync,
legacy render, and the overlay cold-parking hook). Ordinary parking is
untouched — a worktree with an exempt tab still cannot ordinary-park.
Slice C now also demotes exempt tabs' panes as soon as their worktree
force-parks under the count budget (they are the only panes left mounted).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)
The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5)
Whole-worktree background mounts (browser-automation bootstrap lease, mobile
mounts, agent wakes) open a ~3s self-clearing measure window that previously
deleted hiddenSince — every remount restarted the 30s hysteresis and the
45min retention TTL, so a periodically re-mounted force-parked worktree
never re-parked. The measure window still pauses parking/eviction verdicts
(all selectors skip measuring candidates); only the clock survives, so the
prior verdict resumes as soon as the window closes. Visible and
portal-holding worktrees still reset the clock.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a)
Pad the session with ~180KB of output after the numbered markers so the
earliest marker falls outside the relay's 100KiB rolling replay buffer while
staying inside main's ~5k-row headless model; asserting marker_1 after
reveal now proves the headless-model paint rather than passing under the
relay fallback.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7)
One contract matching the code: status IMPLEMENTED around force-park (not
the unmount proposal), real kill-switch names with coupling + revert
matrices, the true retention-floor formula with measured per-pane and
demotion numbers, an explicit when-OOM-is-still-possible paragraph naming
the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock
constraint inside the slice-A section, stable-signal phrasing instead of a
capability latch, fail-open AND foreign-worktree exemption class, verified
cites, and a planned/landed/follow-up test matrix.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8)
isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty —
while the coverage veto that makes a worktree a retention candidate walks
every pane. A split tab whose second leaf held an unrestorable pty therefore
failed coverage (→ force-park target) yet looked exempt-free, so force-park
unmounted it and orphaned the live shell. The exemption now resolves panes
through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in
the union for the no-layout/no-capture case.
Also from the same review round:
- force-park's capture passes includeLocalBuffers:false like every other
shutdownBufferCaptures caller; it was serializing up to 512KB/pane of
scrollback into the store inside a fix meant to bound renderer heap.
- Terminal.tsx unmount resets the scrollback-demotion registry — module
state with no reset path, read by a pane effect that runs before the host
effect that would clear it, so a stale verdict trimmed restore replays.
- memoize watcher coverage per tab within the parking pass; the retention
candidates re-asked it for every mounted worktree, not just the parked few.
* docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2)
pendingSideEffects grew without bound under background timer throttling
(~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with
oldest-first eviction: titles drop (last-wins), a pending bell latches
onto the next survivor, agent-status payloads collapse onto the survivor
keeping the newest 16 (last-wins store state, KB-scale strings).
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)
Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)
ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config →
getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer
only) so a spec can shrink the force-park budget to 1. The Docker-gated
spec opens two remote worktrees on one relay target (second pre-seeded
remote repo), disables terminalSshViewParking to make both un-parkable,
hides both behind the local context, and proves the older one force-parks
while the last-active exemption spares the newest; re-activating the
evicted worktree restores the marker tail via relay replay.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane)
The first draft added a second remote repo mid-session, whose pane pty
spawn misroutes to the local daemon with the remote cwd (pre-existing
multi-repo issue, reproducible without any retention override — a seeded
local repo plus one remote repo shows the same misroute). The spec now
budgets across three worktrees of the ONE connected repo, created through
the product createWorktree path (an external git-worktree-add only lands
as a detected worktree needing adoption) and polled through the relay's
transient post-connect reconnect window. Verified green on the local
Docker lane in 20.8s.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): prevent remount thrashing during post-measure cool-down (
Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a
background-measure window (so TTL/ranking stay honest), but re-park waits for a
full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down,
every ~3s measure lease on a past-deadline worktree thrashes remount/reattach.
Core changes:
- Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure
cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates
until cool-down expires.
- Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts
(used by SSH reattach + daemon restore paths).
- Add SSH model snapshot timeout (750ms) with fallback to relay replay.
- Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts;
add cool-down deadline to scheduling.
- useTerminalTabColdParking: implement matching measure-clock contract with per-tab
cool-down gate to keep tab deadlines synced with worktree retention clock.
- Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted
worktrees (pane births during demotion must take the demoted tier at create).
- Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget,
terminalHiddenScrollbackDemotion.
* fix(terminal): detect Command Code completion in parked mid-turn panes
Seed the byte watcher with in-flight turn state from agent status: the
watcher is recreated per park cycle with no startup command to arm it,
and the banner scrolled away before parking. Also memoize
eviction-exempt checks and use SSH PTY ID builder in tests.
* fix(terminal): flush pending command-code settles on reveal remount
When a parked pane reveals mid-Command Code turn, the new detector
cannot re-observe the already-passed idle composer. Cancelling the settle
leaves the row stranded at 'working', so dispose now flushes the pending
settle instead.
Extract readInFlightCommandCodeTurn to shared space and seed detectors
with in-flight turns so remounts complete mid-flight commands. Also
memoize SSH model probes to prevent double timeouts on reattach.
* fix(terminal): remove scrollback demotion (C1 slice C)
The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.
* test(terminal): assert bounded probe during stalled reveal
Add assertion to verify that a stalled reveal operation makes exactly one
`getMainBufferSnapshot` call, ensuring retry logic doesn't introduce
redundant probes that would extend the timeout window before relay fallback.
* fix(terminal): implement C1 retention budget for hidden parked worktrees
Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.
* memoize eviction-exempt terminal tabs to avoid redundant store reads
Each tab's exemption check re-reads the store and walks the layout tree.
Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs
for a worktree in a single pass, then memoize the result in Terminal.tsx
and useTerminalTabColdParking. This prevents O(n) store reads when checking
exemptions across multiple tabs and ensures the set remains stable across
unrelated re-renders.
* refactor: reformat hidden-worktree retention comments
Reflow to 80-character lines and remove internal ticket references
(C1, C1 slice C).
* fix(lint): split overlay slot and eviction-exempt tabs under max-lines
Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.
* test(terminal): stabilize retention budget e2e control arm
Stage un-parkable remote pty ids only after both worktrees are hidden, and
keep re-staging during the control-arm poll so a late updateTabPtyId cannot
flip the decoy back to park-restorable and ordinary-park it before budget
engages.
* test(terminal): pin retention e2e decoy to a mounted pane snapshot
Use the active pane-identity snapshot for the decoy tab instead of all
worktree tabs, and re-assert un-parkable ids after the control-arm hold so
a deferred/empty tab id cannot fail the budget-off mounted-count check.
* fix: memoize terminal eviction exemptions on layout leaf PTYs
Splits add leaf panes to the layout store without changing the tabs
array. A memo keyed only on tabs misses this change, leaving new panes
unexempted for unmount. Include layout leaf PTYs in the exemption memo
key so it recalculates when splits occur or PTYs are re-minted.
---------
Co-authored-by: Orca <help@stably.ai>
Resolving a session's resume target scanned every worktree in every repo:
two `Object.values(worktreesByRepo).flat().find(...)` calls, which also
allocate a fresh 1124-element array each time, plus an equivalent `some()`
walk. All three run per visible session row, so a panel render repeated
them ~20 times.
`getIndexedWorktreeMap` already exists for this and is WeakMap-cached on
`worktreesByRepo`, so the index is built once per store snapshot rather
than per call. `connection-owner-resolution.ts` already resolves worktrees
this way.
~1.6ms -> ~0.002ms per render pass at 1124 worktrees. Net -4 lines.
Behavior is unchanged: the map dedupes by id, which only diverges from
`find()` when one id appears twice with different objects. Worktree ids are
`repoId::path`, so a duplicate id within a repo array — the documented race
the index was built for — refers to the same worktree.
* perf(vault): dedupe scope paths by key instead of rescanning
#11303 took the session maps off the workspace-switch path, but scope path
derivation still follows the active worktree and stayed quadratic.
addAiVaultWorkspaceScopePath deduped by re-normalizing every already
accepted path on each insert, so accepting K paths cost O(K^2)
normalize('NFC') calls — ~632k at 1124 workspaces. isAiVaultWorkspaceScopePathClaimed
separately rescanned every live worktree per prior id, and runs twice per
switch via activeWorktreePaths and scopePaths.
- carry a Set of comparison keys alongside the paths, so each insert is one
normalize plus one Set lookup
- thread that accumulator from the workspace pass into the project pass
rather than restarting deduplication against a plain array
- build one comparison-path -> worktree id map for the claim check, keeping
first-writer-wins to match the previous some() short-circuit
deriveAiVaultScopeSessionPaths on a real 1124-workspace profile:
189.7ms -> 0.8ms. Output is unchanged, including ordering: verified against
the previous implementation across 117 scenario/option combinations
covering monorepo and mixed-repo layouts, priors both claimed and
unclaimed, duplicate paths, NFD/NFC, WSL UNC, trailing and doubled
separators, relative and blank paths, and four project-key shapes.
Adds the first test file for this module: scope semantics (priors, claimed
priors, cross-repo rejection, dedupe, NFD/NFC) plus a timing guard.
Verified fail-first — the guard reports 220ms on the previous
implementation. Path length is chosen deliberately, since normalize() cost
scales with it and short synthetic paths understate the old shape.
* fix(vault): make the claim check independent of worktree ordering
Review catch on the first pass: keying claims by comparison path meant a
duplicate path had to pick one owner, and picking the active worktree
masked a real claimant later in the list. Concretely, with the active
worktree also listed at its own prior path, the prior was reported
unclaimed where the previous some() reported it claimed.
Excludes the active worktree while building the set instead of comparing
ids at read time, so any surviving entry is a claim by construction and
ordering cannot decide the result.
Adds a test over four orderings, verified fail-first against the previous
commit. Also adds a timing guard for deriveAiVaultWorkspaceScopePaths,
which the session-scope guard did not cover.
Equivalence rerun against the pre-optimization implementation: 156
scenario/option combinations, identical paths and ordering.
#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.
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.
* fix(dashboard): give the agent preview terminal a real pane's keyboard
The dashboard's preview terminal is a bare xterm, not a pane, so it never
ran `resolveTerminalShortcutAction` — its only custom key handler covered
copy/paste and IME. Ctrl+Backspace therefore fell through to xterm's default
`\x08`, which readline binds to backward-delete-char: one character instead
of a word.
Route the preview's keys through the pane's own shortcut policy, so word and
line kills, Option chords, modified Enter, and scrollback chords encode
identically. Pane-scoped verdicts (splits, search, focus) are swallowed rather
than passed to xterm, which would send e.g. Ctrl+Shift+D as a bare Ctrl+D.
The policy needs three things the preview could not see:
- kitty-protocol flags — mirrored locally from the same PTY output stream
- the PTY's execution host — bytes follow the host, not the client OS, so a
new `DashboardCard.terminalInput` profile is derived in the main renderer
(the only one holding the store) and relayed to the pop-out
- host terminal options — the ConPTY backend and the kitty withhold now apply
Also brings the emulator itself up to a pane's: Orca's Unicode 11 width shim
(replayed CJK/emoji/ZWJ laid out wrong without it), Windows Ctrl+Alt chord
repair, user font/cursor/line-height/word-separator/sensitivity settings,
ligatures, the TUI wheel multiplier, lazy Arabic shaping, clickable links, and
the IME candidate anchor — extracted from pane-lifecycle so both surfaces share
one implementation.
The in-window drawer built its snapshot from a slice subset, which would have
degraded the new profile to client-OS defaults there; it now reads the full
store non-reactively.
* fix(dashboard): enumerate pane-scoped chords instead of a default case
The switch-exhaustiveness gate rejects a `default` over the shortcut-action
union — it would let a newly added action be swallowed silently instead of
forcing a decision at the preview's boundary.
* fix(dashboard): preserve native shortcuts and PTY host routing
* chore: keep merge scope limited to dashboard
* perf(dashboard): avoid full-store copies for terminal profiles
* fix(dashboard): validate terminal input profiles at IPC boundary
* fix(dashboard): sync preview terminal refs on commit, not during render
react-compiler rejects ref writes in the render pass; every reader is an event handler or a post-await continuation, so a commit-phase sync is equivalent.
* test(dashboard): cover the three seams that relay the host-input profile
Reverting any of them left every suite green: the dialog's terminalInput prop
(the only reader of DashboardCard.terminalInput), the drawer's hand-threaded
store slices, and the pop-out's republish triggers. Each new assertion was
mutation-checked against its source line.
* fix(dashboard): follow the WSL host for a preview terminal's byte routing
The card resolver handed resolveTerminalInputHostPlatform a transport with no
getLocalSessionMetadata, so a WSL pty on a Windows client resolved to win32
while its own pane resolves linux — Shift+Enter would then encode CSI-u where
the pane sends alt-enter. Mirror the pane transport's own gate.
* fix(dashboard): republish on every slice the host-input profile resolves from
The compare set covered 4 of the ~11 slices that decide a card's execution
host, so a change to the rest (folder workspaces, project groups, the runtime
catalog, detected worktrees) never triggered a publish. On a quiet board there
is no later publish to heal from, and the pop-out — which cannot re-derive the
profile — keeps encoding bytes for the host the pty used to run on.
* fix(dashboard): sync preview terminal refs on layout, not on a passive effect
xterm's keydown is a native listener, so React never flushes a passive effect
before it. A just-relayed host profile could therefore miss the next keystroke.
* test(dashboard): pin the preview's replay-vs-live kitty scan
The existing A/B passed either way: a lone CSI > u sets the same flags through
scan and scanReplay. Redeliver the push across a snapshot and its replay so
stack semantics would leave the TUI's single pop on a stale frame.
* fix(dashboard): rebuild the drawer's snapshot on every host-input slice
The in-window drawer read ~12 host slices through getState() while subscribing
to none of them, on the premise that agent activity drives the next rebuild. A
quiet board has no such rebuild: an SSH handshake completing with every agent
idle leaves the preview terminal encoding bytes for the pre-connect host, and
agentStatusEpoch only ticks on live status changes so it never heals. Watch the
same set useDashboardPopoutBridge republishes on — each writer bails out when
nothing changed, so the added deps are far quieter than agentStatusByPaneKey,
which already rebuilds this memo on every status ping.
The existing coverage mounted a second hook, which always recomputes; the new
test re-renders the same hook after changing only sshConnectionStates.
* fix(dashboard): key the preview by the user's terminal shortcut policy
The preview passed 11 of the 12 inputs the pane's policy takes and let the
12th default to orca-first. Under terminal-first a remapped tab.close chord is
meant to yield to the shell — Ctrl+W is a word-kill there — but the preview
kept claiming it as a pane close and swallowed the bytes.
* test(dashboard): pin the host-input profile to card snapshots only
The count path main added in #11042 renders no cards, so it must not pay a
per-pty host resolution on every agent-status tick. Both assertions run against
a card that does have a live pty, so only the gate keeps the profile off.
* refactor(dashboard): extract the board's client-host read
The merge of main's label bounding pushed build-dashboard-snapshot.ts to
302 lines. The client's own platform facts are a distinct concept from the
pty host each card keys against, so they move out rather than earn a
max-lines bypass.
* Support Windows drives in the remote host filesystem picker
The remote picker was locked to the system drive on Windows hosts: the
breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were
treated as filter text, so projects could only ever be created on C:.
- Server: answer host-root browses ('/') on win32 with the mounted
drives instead of resolving to C:\.
- Client: recognize drive-anchored input (M:\, M:/, m:) as path mode,
resolve segments from the normalized drive root, and make
joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root
returns to the host root (the drive list).
Fixes#7438
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document why joinDrivePath uses a literal backslash
Review feedback suggested path.win32.join, but the renderer bundle
imports no Node builtins anywhere and runs sandboxed, so path.win32 is
not available here. The backslash targets the remote Windows host
regardless of client OS; say so at the call site.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Complete Windows drive browsing over SSH
* fix remote Windows drive browsing
* fix(ui): key remote breadcrumbs by path
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
mobile/src/terminal/terminal-webview-{tap-routing,init-surface}.test.ts
request the happy-dom vitest environment, but happy-dom was only declared
at the repo root. The mobile suite resolved it by walking up into the root
node_modules, so `cd mobile && pnpm install && pnpm test` fails with
ERR_MODULE_NOT_FOUND and loses those 12 tests unless a root install
happens to be present.
* fix(setup): correct Windows sequencing wrapper quoting
* test(setup): preserve spaced Windows batch paths
* refactor(setup): dedupe PowerShell encoder, clarify wrapCmd comment
Route the Windows setup-sequencing and Hermes startup planners through the
shared renderer-safe encodePowerShellCommand instead of two verbatim btoa
copies, and make that shared encoder renderer-safe (Buffer is unavailable in
the sandboxed renderer where both planners also run). Reword the wrapCmd
comment so it describes the current single-outer-quote behavior instead of the
old quote-doubling bug.
* test(setup): cover Windows metacharacter paths
* fix(setup): keep Windows runner paths out of cmd source
* test(setup): preserve Windows setup failures
* docs(setup): explain safe cmd path handoff
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(plugins): close trust-boundary holes in the plugin system
Move five security decisions to their chokepoints rather than leaving them
enumerated at individual call sites.
- Kill-list revocation reaches content packs: PluginContentPackRegistry now
takes an isKilled predicate and intersects it with any caller-supplied
approval, so a killed plugin's VM recipes can no longer reach
spawn(..., { shell: true }) through either reconcile() call site.
- Bound kill-list generatedAt to a 24h future skew at the parse chokepoint.
A far-future timestamp previously made every genuine later list look
"older" and disabled revocation permanently, persisted across restarts.
- Protect the whole auto.components.settings.Plugin* translation subtree
instead of an enumerated prefix list, so language packs cannot forge the
consent provenance badge or rewrite install-error security copy.
- Resolve manifest panel icons by own-key only; "constructor"/"__proto__"
previously yielded non-component prototype members that crashed the
right sidebar to its error boundary.
- Give panel liveness frames a reserved control budget so a panel that
saturates its action budget can still answer the watchdog.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): keep the kill-list future bound off the cache read path
The schema-level generatedAt bound re-judged the on-disk cache against the
device clock at every launch, so a client whose clock ran behind the last
genuine publication discarded its whole cached kill list and started with
zero revocations. Move the bound to the two fetch chokepoints instead.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU
Review follow-ups on the trust-boundary fixes:
- The reserved liveness lane had a per-window count equal to the ping
interval, so a panel's own pong-shaped traffic could spend it and drop
the next genuine reply — reintroducing the starvation the lane exists to
prevent. The lane is now size-bounded only; rate stays bounded because
every pong is also charged to the data budget.
- Only schema-valid pongs take the lane now, so near-miss pong-shaped junk
cannot drain it. readPanelPongId replaces the zod parse on this
guest-controlled path (a rejected safeParse allocates an issue list, ~90x
the accepted-path cost) and is pinned to the schema by a parity test.
- Re-read the kill list inside approveAtomically: approvedKeys is snapshotted
before an awaited verification phase, so a plugin killed during that wait
could still publish VM recipes and language packs.
- Assert the curated icon resolves to FileText; the old equality also passed
when both sides fell back to Plug.
Co-authored-by: Orca <help@stably.ai>
* fix(plugins): match zod's safe-integer bound in the pong reader
readPanelPongId used Number.isInteger, but zod's .int() rejects anything
above 2**53-1, so pingIds like 1e100 took the reserved lane the schema
would have refused. The parity test never probed that boundary.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix: stop notification loss, credentialed cache reuse, and clipboard clobber
Mobile catch-up (#8591): fetchMissed swallowed the RPC failure while
deliverLive kept advancing and persisting lastDeliveredSeq, so the next
successful catch-up asked from above the abandoned range and the desktop
cut it. Sessions are module-scope, so an unchanged epoch never resets it.
Quarantine the watermark at the last contiguously-delivered seq and hold
it there until some later catch-up actually drains — not just one retry.
A batch cut short by a teardown quarantines at the last event it settled.
Jira attachment cache: currentEpoch summed two independent counters, so a
site at siteEpoch 1 read the same value before and after a global clear.
The mid-flight guard passed and re-inserted credentialed image bytes that
"disconnect all" had just purged — resident for the process lifetime since
pruneExpired has no timer. One monotonic ticker, compared by max.
Web copy fallback: the handler registered in the capture phase, so xterm's
bubble-phase listener overwrote text/plain with the terminal selection
afterwards; served was already true, so the copy reported success. Every
Orca copy affordance over plain HTTP (Copy Pane ID, Copy Path, commit SHA,
PR URL) pasted the terminal selection. Bubble phase with
stopImmediatePropagation. Covers the secure-context retry branch too,
which shares the same helper.
* fix: roll back the persisted watermark on catch-up failure; cover stopImmediatePropagation
Adversarial review of a98d7f4d5d found two gaps.
1. The quarantine clamped only writes made AFTER the failure. getMissedSince
waits up to 30s, so a live event routinely persists a higher seq while the
request is still outstanding; that value stayed on disk, and the next launch
read it back and resumed past the abandoned range -- the original bug,
reached through a restart. quarantineCatchUpWatermark now re-persists the
clamped seq, so the stored value never outlives the gap it guards.
2. web-clipboard-copy-terminal-selection's second test registered its "late"
document handler BEFORE the fallback's, so it lost on registration order
alone and stopImmediatePropagation was never exercised -- the test passed
with that line deleted. Bubbling reaches the document before the window, so
a window-level listener is what actually requires it.
* fix(mobile): mark a notification seen only once its show lands
A pre-marked seen key made a rejected show unrecoverable: the next
catch-up re-fetched the seq and the dedup guard dropped it, and the
first later event to drain the batch lifted the quarantine past it.
Also contains the rejection so it does not escape the un-awaited
'ready'/live handlers as an unhandled rejection.
Co-authored-by: Orca <help@stably.ai>
* test(web-clipboard): pin stopImmediatePropagation with a same-target handler
Both existing cases passed with plain stopPropagation, and with the listener
back in the capture phase — neither half of the fix was actually pinned. The
window-level clobber is on a different target, so stopPropagation suppresses
it too. Registering the clobber on the document, ordered after the fallback's
own listener, is the only shape stopPropagation cannot cover.
Addresses the review comment posted after the last commit.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
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.
* fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales
7.1 ui.set rejected whole preference payloads on enum drift. The new
AssertNoMissingKeys guard is key-only, so it could not see that
LegacyWorktreeCardProperty omitted 'cli' (in DEFAULT_WORKTREE_CARD_PROPERTIES)
or that rightSidebarTab omitted 'workspaces'/'pr-checks' and every plugin tab.
UiUpdate is .strict(), so one bad value failed the entire batch and silently
dropped sidebarWidth/groupBy/sortBy/filterRepoIds riding the same debounced
write. Both enums now derive from the shared unions, AssertNoMissingValues
catches value drift by name, and UiUpdate drops an unknown value instead of
rejecting the batch around it. Unknown KEYS still reject.
7.2 The SSH shell-ready fallback moved from first-output to spawn, so a remote
shell needing >1.5s to prompt got the bracketed-paste startup command before
readline armed it, with no recovery afterward. The short deadline now applies
only once output proves the shell is talking; a silent-since-spawn shell gets a
longer budget and still delivers eventually.
7.3 The project picker armed in rank order but rendered in section order, so
with a folder group present the BOTTOM row was armed on open and Enter created
the workspace in the wrong place. Row keys now derive from the same sections
that render. The folders bucket also gains the recent-exclusion guard the
projects bucket has; that duplicate was unreachable, so this is symmetry, not a
live bug fix.
7.4 setBrowserPageZoomLevel now compares before writing, so a pane reasserting
a level the host already holds no longer emits a redundant host-wide
HostZoomMap write. The user-applied level also moved to a module-level map
keyed by page id: the guest webview outlives its React pane, so the pane-local
ref re-seeded from the shared Settings default on every remount and let a later
default retroactively hijack an already-zoomed tab. See PR notes on the part of
this finding that could not be fixed as prescribed.
7.5 A non-null sessionId short-circuited the live-work escape hatch, forcing
'loading' over hook 'working' and rendering an idle pane mid-turn: Send instead
of Stop, no typing indicator, no streaming preview. Status stays 'working'; the
empty-transcript loading SURFACE moves to selectNativeChatViewState, which keeps
7.6 #10770 merged from a base predating #8549, reverting 182-187 translated
strings per locale to English (es 182, ja/ko/zh 187) plus en.json's recipesHelp.
Restored by script, only where the English source is unchanged between the two
shas, so later legitimate edits are preserved: 0 keys added or removed, every
value sourced from 97e4776dfe, and the four other English-source changes since
7.7 Match highlighting indexed by UTF-16 code unit but rendered by code point,
so an emoji-named folder showed marks one glyph late. Cosmetic.
Co-authored-by: Orca <help@stably.ai>
* fix(ssh): keep fast startup delivery on the short fallback deadline
The 15s no-output budget added for the shell-ready fallback was applied to
every SSH launch, including 'fast' delivery. Fast delivery waits for no
marker and pastes nothing prompt-sensitive, so it gained a 10x startup
delay for nothing.
Co-authored-by: Orca <help@stably.ai>
* fix(rpc): generalize the ui.set value-parity guard to every shared key
Naming worktreeCardProperties and rightSidebarTab left the next field to drift
exactly as unguarded: dropping 'pr-status' from groupBy typechecked clean.
Check the value domain of every shared key instead, against z.input (what a
client may send) rather than z.infer (post-transform).
Also move the pure mergeNativeChatLiveSession suite beside the module it covers;
the hook's test file owns an IO harness and was at the max-lines cap.
Co-authored-by: Orca <help@stably.ai>
* fix(i18n): re-apply only the locale strings still reverted at HEAD
#10770 merged from a base predating #8549, so its stale locale copies
overwrote ~185 already-translated strings per locale back to English. A
present catalog value always beats the English translate() fallback, so
those strings render English with nothing to signal the loss.
Since that finding was written, #11205 and other upstream translation
passes independently re-covered most of ja/ko/zh. Replaying #8549's
catalogs wholesale would now overwrite that newer work, so this re-applies
a key ONLY where all of the following hold at origin/main: it was
translated at 97e4776dfe, #10770 reverted it, its English source is
unchanged since, and no upstream commit has touched it since the revert.
es 182 ja 32 ko 17 zh 55
Everything else is left to upstream. Verified zero upstream translations
reverted: every changed key still matches its #10770 value at main. Keys
upstream deleted are not resurrected, and keys whose English source was
edited since are skipped as legitimate source changes rather than reverts
(this is what keeps zh CPU on #11205's deliberate "CPU" over #8549's
"中央处理器"). Key count and order are unchanged in all five catalogs.
en.json's own recipesHelp was reverted by the same stale base and no
upstream commit has touched it since, so it is restored to match the live
source at EphemeralVmsPane.tsx:252.
* fix(rpc): keep null in the ui.set value-parity guard
NonNullable stripped null as well as undefined, so dropping .nullable()
from a `| null` field passed the guard while still rejecting the batch at
runtime -- the exact drift class the guard exists to catch. Proven: making
visibleWorkspaceHostIds non-nullable typechecked clean before, now errors
by name. Also pins the 15s silent-shell budget so it cannot silently
shrink back toward the short deadline.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(perf): correct three 07-27 perf regressions
Traversal capacity cap no longer scales with worker concurrency
(#11026). retainWorkspaceSpaceScanEntry charged a traversal-wide entry
counter, so N workers each holding a listing multiplied the live charge.
At concurrency 48 a 48x2,100 tree (100,848 entries) hit the 100,000 cap
while 100x1,500 (150,100 entries, 50% more) passed, and scanLocalWorktree
treats the capacity error as terminal, reporting an intact worktree as
"Unavailable" with sizeBytes 0. The cap is now per directory listing --
the only quantity fixed by directory shape -- restoring the invariant
docs/workspace-space-scan-resource-bounds.md already states. Aggregate
live retention stays bounded by the unchanged 64 MiB byte cap.
Note: releasing each entry's charge at dispatch (the originally suggested
fix) was measured and does not help; the peak is set at admission, before
any entry is dispatched.
Repo image icons are no longer fully base64-decoded on every snapshot
publish (#11012). sanitizeRepoIcon reached decodeBase64Prefix, which
sized its buffer to the whole payload to read a 24-byte header, running
synchronously inside ipcMain.handle at a 250 ms throttle. Validation is
now memoized on source+src in a BoundedMap. Measured for 10 icons x
256 KB: 37.34 ms -> 0.67 ms per publish.
One over-long card label no longer discards the entire snapshot (#11012).
isDashboardSnapshot was all-or-nothing and dashboard-popout returned
early with no log while replaying lastSnapshot, so `orca terminal rename
--title "<1025+ chars>"` froze the pop-out board on its last good paint
with nothing surfaced. Labels are truncated at the producer, the
validator drops only the offending card, and both the rejection and the
drop are logged. The bound now lives in the shared snapshot contract so
producer and validator cannot drift.
Co-authored-by: Orca <help@stably.ai>
* fix(perf): charge a scan listing's parent path once, not per entry
The 4.1 fix made the entry cap per-listing but left the 64 MiB byte cap
charging parentPath.length for every entry in a listing. Because a
listing's entries all share one parent-path string, that multiplied the
path by the directory's width, so the byte cap measured checkout depth
rather than live heap.
The reported symptom therefore still reproduced at the production default
limits: 48 x 2,100 @ concurrency 48 raised a capacity error once the
worktree path passed ~58 characters, while the same layout at concurrency
1 succeeded. The shipped regression test could not see this because it
passes maxRetainedBytes: Number.MAX_SAFE_INTEGER, disabling the only cap
still in play. Measured at a real 65-char worktree root, 3 of the report's
4 documented layouts still failed.
The parent path is now charged once per listing, with its first entry, so
an empty listing strands no charge. Per-entry overhead is unchanged at
512 B + name, which still dominates the estimate, so the OOM protection
the original PR added is preserved.
Adds a production-default-limits case covering the report's layouts under
a deep root, plus an assertion that a short and a deep root reach the same
verdict -- the path independence docs/workspace-space-scan-resource-bounds.md
requires and which no existing test enforced.
Co-authored-by: Orca <help@stably.ai>
* fix(perf): prove the icon cache by decode count, not wall clock
The caching test asserted a per-publish millisecond budget, which failed
on CI at 5.64 ms against a 5 ms ceiling. Any threshold flakes on a loaded
box, so count real sanitizeRepoIcon entries instead: 10 repos x 20
publishes is 200 icon checks against exactly 1 decode. Added cases pin
the cache key (payload and source both re-decode; a cached image verdict
never answers for an emoji) and that a rejection is cached too.
Also drops budget.entries, which the per-listing cap left as a
traversal-wide counter no check reads -- exactly the shape a future
guard could reintroduce the concurrency bug from.
Co-authored-by: Orca <help@stably.ai>
* fix(dashboard): bound the project filter label the whole board rides on
#11042 added snapshot-level filterOptions whose project labels are
repo.displayName -- the same unbounded source this PR already bounds for
card.repoName, but one level up where dropping a card cannot recover it.
An over-long project name would fail isDashboardFilterOptions and take
the entire snapshot with it, which is the exact frozen-board failure the
per-card drop was added to end. Workspace-status labels are already
capped at 32 by workspace-statuses.ts, so only projects needed this.
Co-authored-by: Orca <help@stably.ai>
* fix(dashboard): disambiguate the repo icon cache key
The memoization key joined `source` and `src` with a space, but the
sanitizer's base64 pattern admits whitespace inside a valid `src`. A
rejected icon can therefore split the same concatenation differently and
inherit an accepted icon's cached verdict, reaching the pop-out's
`<img src>` without ever being sanitized. Length-prefix the source.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
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).
* 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
A deadlocked main thread never crashes, so it leaves no crash report and no
artifact — incidence has been unmeasurable (n=1 confirmed, macOS 26.5.1,
FB24004458 / electron#52437). This forks a plain-Node watchdog sibling under
ELECTRON_RUN_AS_NODE that survives the deadlock, listens for a 2s heartbeat,
and after 45s of silence writes a marker to userData. The next launch consumes
it, records a durable crash breadcrumb, and emits a main_thread_hang_detected
telemetry event carrying unresponsive_ms and self_recovered.
Observes only — it never kills or relaunches the parent. A true positive
recovers nothing force-quitting wouldn't, while a false positive would SIGKILL
a live main thread mid-write. self_recovered counts exactly the stalls such a
killer would have gotten wrong, so recovery can be built on evidence if the
field numbers justify it.
macOS-only, packaged-only (ORCA_HANG_WATCHDOG_FORCE=1 to test), with sleep-gap
suppression and idempotent shutdown on will-quit.
* fix(agent-status): prevent ghost sidebar row on completed split-pane detach
Detaching a done-state split pane into its own tab migrated the agent
paneKey from oldTab:leaf to newTab:leaf. useRetainedAgentsSync only saw
the old key vanish and, finding no suppressor, resurrected it as an
unclickable duplicate sidebar row (and inflated the worktree count).
Plant a one-shot retention suppressor on the source key during
transferAgentPaneAuthority, but only when the source actually held a
live agent, so a suppressor is never leaked for a pane that had none.
Fixes#10675
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(agent-status): annotate suppressor record type and condense retention comments
Type the migrated retentionSuppressedPaneKeys as Record<string, true> so a
computed-key `true` isn't widened to boolean, which broke the web typecheck.
Also condense the retention rationale comments per review.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>