* feat(agents): distinguish Claude background monitoring
Adds an optional `workingMode: 'monitoring'` discriminator for a Claude
session whose lead turn finished but which still has background shell tasks
or session crons registered. The wire state stays `working`, so older peers
that never read the field keep rendering Working.
(cherry picked from commit d5d54b4bdd)
Rebased onto current main (554 commits of drift) by Brennan Benson;
conflicts resolved by keeping both sides where main and this branch made
independent additions to the same construct.
* fix(sidebar): keep monitoring status visible
(cherry picked from commit fd6b38654d)
* test(agents): cover Claude monitoring drain
(cherry picked from commit ce4d61ebf8)
* test(mobile): avoid unresolved renderer test type
(cherry picked from commit bbcfa35ff9)
* feat(agents): render Claude monitoring as a static turquoise dot
Replaces the yellow Radio glyph from #14205 with a static dot in a new
--agent-monitoring token (#8abeb7), defined once for light and once for
dark like --workspace-status-done, so the status keeps its identity when
the theme flips. Deliberately a fixed UI value: it never reads terminal
theme state at runtime.
Adds the turn-boundary notification pins. The monitoring predicate and
the turnCompletedAt stamp are computed from the same "lead said done but
the pane resolves to working" expression, so a rename can silently drop
the stamp and kill a completion notification that works today with
nothing else going red.
* revert(agents): restore the yellow Radio glyph for monitoring
Brennan chose #14205's original treatment over the turquoise dot, so the visual
goes back to nwparker's: lucide Radio in text-yellow-500 across the sidebar,
dashboard dot, cmd-j palette and agent-map ring.
Reverts only the visual surface. The turn-boundary notification pins stay — the
monitoring predicate and the turnCompletedAt stamp share an expression, so a
rename can silently drop the stamp and kill a completion that works today with
nothing else going red. The --agent-monitoring token is removed with its last
consumer rather than left dead in main.css.
---------
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* Restore previously-active tab when closing a browser tab
Closing a browser tab now activates the most-recently-used (MRU)
tab instead of the visual neighbor. This provides more predictable
navigation behavior when switching between tabs.
* Restore previously-active tab when closing a browser tab
When closing a browser tab, announce the MRU page selection
before guest teardown to prevent fallback to registration order.
Reorder closeBrowserTab() before destroyWorkspaceWebviews()
consistently across all close-tab handlers.
* fix(popover): wheel-scroll a popover whose scroller is nested, not the content
The workspace-cleanup Filters panel cannot be scrolled with a wheel. Only the
scrollbar drag and focus-scroll work. Two independent Electron QA passes measured
it on main: wheel events over that popover arrive defaultPrevented, panel
scrollTop 0 -> 0, while the candidate list inside the dialog subtree scrolls
normally.
Cause: the popover portals outside the Radix dialog subtree, so
react-remove-scroll's scroll-lock cancels wheel there. #14629 added a shim for
exactly this, but it scrolls `event.currentTarget` -- the PopoverContent -- and
only when that element is itself overflowing. The Filters panel is a flex column
holding a ScrollArea above a pinned footer, so PopoverContent is overflow-hidden
and the real scroller is a descendant. The shim looked at the wrong element and
returned early.
The shim now resolves the nearest scrollable element between the wheel target and
the content, inclusive of both, so it handles the nested-viewport shape as well as
the flat one. Still opt-in via `popover-scroll-content`; popovers without the
class are untouched. The cleanup Filters panel opts in.
This is the unfixed half of the original report. #14629 fixed the panel being
clipped; the wheel -- what you actually reach for -- stayed dead.
The nested test fails against the shipped shim and the other two stay green, so it
reproduces the bug without redefining existing behaviour.
* fix(popover): split the wheel-shim opt-in from the scroll-container styling
Two fixes on top of the reviewer's round.
**The reviewer's change, kept.** `resolvePopoverScroller` no longer falls back to
the content element unconditionally; it must match the overflow test like any
other candidate. The reviewer flagged that this could break the existing opted-in
popovers, so I checked: `.popover-scroll-content` sets `overflow-y: auto`
(main.css:601-607), so every one of them still resolves. The test needs an inline
style only because happy-dom does not apply the stylesheet -- noted in the test so
nobody reads it as a production concern.
**A bug of mine the review did not reach.** Opting the cleanup Filters panel in via
`popover-scroll-content` would also have applied that class's
`max-height: min(15rem, ...)`, crushing the panel's 471px flex column to 240px.
The class conflates 'style me as a scroll container' with 'run the wheel shim', and
a popover that manages its own layout needs only the second.
So the shim now accepts `popover-wheel-scroll` as a styling-free marker, and the
Filters panel uses that. `popover-scroll-content` keeps implying it, so no existing
caller changes.
* fix(workspace-cleanup): stop a sync broadcast reverting a filter you just set
`workspaceCleanupBrowse` re-hydrated on every hydration source. Two lines below
it, `activeView` is startup-guarded for the same reason and says so.
How it fired: the browse writer debounces 250ms. Any other `ui.set` inside that
window makes main broadcast to all windows (`main/ipc/ui.ts:54-61`) carrying
state that still holds the previous filters. The window hydrated that over the
edit, and the pending debounce then wrote `get().workspaceCleanupBrowse` -- by
then the reverted value -- so the revert was persisted, not just displayed.
Your own write echoing back is harmless; it is somebody else's write landing
first that does the damage. Intermittent, which is why it reads as "Orca forgot
my filter".
Guarded to `source === 'startup'`, matching activeView. The cost is that browse
state no longer syncs between windows or from mobile. That costs nothing today:
`mobile/src` has no workspace-cleanup consumer, and the popout runs a separate
store and JS context, so nothing else in the product edits browse state. Filters
become per-window, which is the call already made for activeView.
Rejected the alternative (last-write-wins via a local dirty generation): it
preserves a cross-window sync nobody uses, in exchange for more state to get
wrong on a path that just proved it can silently discard user input.
Three of the five tests fail against the unguarded source; the two that stay
green assert what must not change -- startup still restores, and dismissals
still hydrate on sync because they are main-owned.
* chore(workspace-cleanup): tighten hydration guard rationale
* feat(agent-status): add an order-independent title evidence parser
The chain this will replace is a first-match-wins scan of substring predicates, so its answer
is decided by list position rather than by how strong the evidence is. Four real recorded Grok
panes read as Codex today because Codex is checked first and their task text mentions it.
Reordering cannot fix that: whichever branch is hoisted, some other pair breaks.
collectAgentTitleEvidence collects every signal first and ranks by class afterwards:
vendor marker — a sigil or control sequence the agent itself emits; task text cannot forge it
anchored name — a name where a grammar reserves the position for identity (Orca's `- <agent>`
owner suffix, or the whole undecorated remainder)
free-text name — a name anywhere else
Anchored beats vendor marker, so `✳ agy` is Antigravity rather than Claude. Free text never
beats either, and never becomes identity on its own even as the only name present — an absent
icon is recoverable, a confidently wrong one is not. Conflicts within a class resolve to null.
No consumer imports this yet. Swapping getAgentLabel in place would move ~20 call sites at
once; each migrates behind the resolver, with its own evidence.
Measured against the live recorded-title corpus (747 distinct titles), 598 agree with the
current chain and 149 differ:
144 claude -> null spinner-only titles. Braille and quarter-circle frames are emitted by
many agents, so they prove the pane is busy and nothing about who it
is. These are answered by stronger signals once the resolver lands,
which is why the parser is not wired up on its own.
4 codex -> grok the misattributed Grok panes, fixed.
1 codex -> null a spinner-prefixed Claude pane whose task text names Codex.
Three parser defects were found and fixed by reviewing that delta rather than reasoning about
it: the OpenCode envelope was treated as a vetoable marker instead of an owning grammar, free
text was allowed to veto a vendor marker (which blinded 13 real Claude titles), and the owner
suffix matched the tail of a hyphenated worktree name (`review-14600-codex`).
* fix(agent-status): harden title evidence collection
* fix(agent-status): limit emitted display evidence
* fix(agent-status): anchor explicit title evidence
* test(agent-status): cover anchored token filtering
* fix(agent-status): complete explicit marker evidence
* fix(agent-status): recognize reserved owner ids
* fix(agent-status): reject cwd path titles
* fix(agent-status): avoid bare-name identity guesses
* fix(agent-status): require spinner for working labels
* fix(agent-status): honor opted-out synthetic profiles
* fix(agent-status): harden wrapper evidence boundaries
* fix(agent-status): clear the pane when a Claude compact finishes (STA-2915, STA-4613)
A manual /compact ends at an idle prompt without emitting Stop, so nothing in the
compact window could ever clear the pane. A worktree that entered the compact
`working` stayed `working` until the 30-minute stale sweep -- and the summarizer's
start-less SubagentStop kept republishing the row, resetting that clock each time.
The correlation added by #12332 was supposed to own this, but it could never run:
PreCompact and PostCompact were never added to CLAUDE_EVENTS, so they were never
registered with Claude. compactTrigger was always undefined, and the transition
guard, the ownership cache, the relay wire field and the ingest branch were all
unreachable. Five test files exercised the logic by injecting events past the
registration boundary, so the suite stayed green over code that could not execute.
Register PostCompact -- and deliberately NOT PreCompact. Measured on Claude Code
2.1.227, a successful manual compact emits PreCompact, a start-less SubagentStop,
SessionStart(source=compact), then PostCompact; an ABORTED compact ("Not enough
messages to compact") emits PreCompact ALONE. Mapping PreCompact to `working`
would strand the pane on every aborted compact, which is the bug being fixed, so
the abort guard is structural: Orca never subscribes to the pre-validation event.
PostCompact carries its own trigger, so no anchor is needed to tell manual from
auto and the correlation machinery is deleted rather than repaired. Manual becomes
a `done` with sessionBoundary set -- a finished compact is a session-shaped
boundary, not a completed turn, so completion notifications, unread counts and
automation-run evidence stay out of it. Auto claims nothing: it runs inside a turn
that resumes and emits its own Stop.
The source-blind early return that dropped compact events for EVERY provider
before its normalizer ran is narrowed to Claude, so it keeps failing closed on a
malformed payload without pre-empting other providers.
Ownership is kept where the deleted guard had it: a valid provider prompt id is
required, a completion clears a row but never creates one (a retired pane must not
be resurrected), and a hydrated row is matched on provider session only -- it
carries the previous session's connectionId, and older rows carry no session at
all, so a strict check would reject the restart case this fixes. A consumed
prompt id keeps relay duplicates from refreshing the row.
Mixed versions: no new wire field and no new opcode. An older relay normalizes
with its own shipped mapping and forwards the event, so ingest drops `auto`
envelopes and stamps the boundary on `manual` ones; its replay strips the trigger
entirely, so payload state stands in for it while ownership is still enforced. The
relay now caches a completion with its compact identity removed, so a client that
was offline during the compact still receives the clearing row on reconnect.
Tests go red before this change and green after: 6 of 12 in the new
registration-gated suite and 5 of 8 in the relay/ingest suite. The harness delivers
only events present in CLAUDE_EVENTS, so a fix that is never registered cannot
pass -- the failure mode that let the original correlation ship unreachable.
* test(agent-status): restate the compact reliability gate around the new invariant
The gate pinned a test file this change deletes, so the manifest check failed.
Repointing the path alone would have left the gate describing an invariant that
no longer exists: it required a manual PostCompact to match its exact PreCompact
generation, and PreCompact is no longer consumed at all.
Restate it. The invariant is now that PreCompact never moves a pane, that only a
manual PostCompact marks done and does so as a session boundary, that a
completion clears an existing row but never creates one, and that a relay
predating the contract has its automatic envelopes dropped and its trigger-
stripped replays classified by payload state under the same ownership checks.
Evidence runs are the real ones: the 105-test suite from this branch, and the
Claude Code 2.1.227 PTY capture that measured PreCompact arriving alone on an
aborted compact.
* fix(agent-status): clear the restart-stuck pane a compact was meant to clear
Review found the completion did not clear the pane STA-2915 actually reports, and
that republishing it was a strict regression.
- A manual completion now retires a subagent that exists only as a disk snapshot:
a /compact only completes at an idle prompt, so a restored child is proof of
nothing. Live evidence -- a child observed in this runtime, an unclassifiable
running background task, a registered session cron -- still holds the pane.
- A completion that cannot clear now publishes nothing instead of restating the
row, which was stripping restoredUnconfirmed off a hydrated row and restarting
the staleness clock for work the compact never observed.
- The relay defers compact ownership to the client that owns pane identity, so a
cold relay cache can no longer swallow the one event that clears a remote pane.
- claudeConsumedCompactPromptIdByPaneKey joins all three pane-scoped teardown
routes, and an auto compact no longer spends the pane's consumed-compact slot.
- The promptless completion keeps the summarized turn's label with or without a
trigger on the envelope.
Tests: the two restart cases now deliver the completion while the hydrated row is
still cached, so they exercise the restored-row branch instead of passing through
the strict one; the triggerless working replay is asserted from a FINISHED pane so
it can fail. Reverting the four source files turns 12 of 21 registration-gated and
8 of 12 relay/ingest tests red, and 18 of 18 targeted mutations are caught.
* fix(agent-hooks): preserve compact identity across relay replay
* docs(reliability): describe compact replay ownership
Add pendingCount getter to MailPointerRepointScheduler to expose the
number of handles awaiting repoint. Replace flaky vi.getTimerCount()
assertions with direct scheduler state checks.
* fix(ai-vault): replace scanner internals with actionable panel copy
Agent Session History painted the scanner's supervision errors verbatim:
"AI Vault service restart circuit is open." and "AI Vault service timed
out after 130000ms." Neither tells a user what happened or what to do.
Add a shared mapper that rewrites the supervision family into copy tied
to an action, passing anything unrecognized through so scanner-authored
messages (host name, remote path, cap) keep their own wording. It also
strips Electron's `Error invoking remote method` wrapper, which this
path never handled. Applied at both surfaces the panel paints: the local
leg's scan-issue row and the thrown-rejection banner. Humanizing in main
covers remote clients too; the raw text moves to the main log.
Also make "Refresh to try again" true. The relay path lets a forced
refresh reopen the restart circuit, but on the local path `force` stopped
at the cache layer and never reached the supervisor, so the refresh
button was inert for the full 60s fault window.
The client file sat at 299/300 lines, so extract the child-listener and
init-frame wiring into `attachAiVaultServiceChild`, next to the
ready-waiter and retirement helpers it belongs with, rather than bumping
the max-lines ceiling.
* fix(ai-vault): humanize runtime scanner errors
* fix(ai-vault): preserve runtime error metadata
* fix(ai-vault): normalize wrapped scan errors
* fix(ai-vault): preserve non-scanner relay errors
* fix(ai-vault): make forced retries cancel backoff
* Fix rebase race by fetching to private ref before rebasing
`git pull --rebase` is vulnerable to concurrent fetches modifying remote-tracking refs during execution. Fetch to a temporary private ref (refs/orca/rebase/*) first, then rebase from that stable ref to avoid the race condition.
* Fix rebase race by fetching to private ref with timeout
Concurrent fetches can interfere with remote-tracking refs between
fetch and rebase. Use a unique private ref and 60-second timeout to
isolate each rebase operation and prevent hangs on stalled remotes.
Extract gitPullRebaseFromBase to a dedicated module.
* fix rebase race by fetching to private ref with timeouts
Concurrent fetches can replace FETCH_HEAD and remote-tracking refs between
fetch and rebase, causing the rebase to fail. Fetch to a temporary private
ref instead, use --no-write-fetch-head when available (Git 2.29+), and
serialize FETCH_HEAD access for older versions. Add process termination
barriers to ensure proper cleanup and extend timeouts for SSH operations.
* Fix rebase race by fetching to both private and tracking refs
Concurrent fetches between source and rebase can replace remote-tracking refs,
causing rebases to use stale bases. Now fetch to both a private ref and the
remote-tracking ref simultaneously, ensuring the tracking ref stays current.
Also improves process termination for WSL guests with process-group tracking,
fixes process-tree termination timeouts on POSIX, and serializes FETCH_HEAD
operations for linked worktrees through their shared Git directory.
* Add WSL setsid --wait probe and barrier termination timeout
Probe for `setsid --wait` support and fall back to unwrapped execution for BusyBox compatibility. Add a deadline for process termination barriers to prevent hanging when tree termination cannot be verified. Update tests for cross-platform compatibility.
* Add wsl-process-group-termination to WSL invocation allowlist
* Serialize per-worktree git mutations to fix rebase race
Introduce operation locking for each worktree to prevent concurrent
mutations (like rebase) from interfering with each other. Ensures
rebasing a linked worktree doesn't affect the source worktree state.
Add SIGKILL fallback if process termination barriers cannot verify
tree termination.
* Serialize pull and fastForward operations per-worktree
- Extract generic git operation lock to reuse locking pattern
- Refactor existing locks to use the generic implementation
- Apply per-worktree serialization to pull and fastForward to prevent races
* Route WSL group termination through runWslProcess
ce743a4fd0 silenced the wsl-invocation boundary guard by appending
wsl-process-group-termination.ts to the allowlist. That fixture only
grows when the scanner learns to see a spawn it was blind to, and only
shrinks for a migration -- this was new code on this branch, so the
entry was the boundary regressing rather than the guard getting honest.
Migrate the kill instead. terminate() now calls runWslProcess with the
script form (`<shell> -c <script> -- <args>`), which keeps the group id
in $1, so the payload is unchanged. The script is plain POSIX, so it
must not pin shell: 'bash'; it calls only builtins and coreutils on the
default PATH and reads no login environment, so loginPath is 'none'.
wrapGuestArgs() is untouched: its argv is spliced into git/runner.ts's
own wsl.exe invocation, which is a long-standing allowlist entry.
The unit test now mocks runWslProcess and asserts the spec shape --
distro, loginPath, the group id in args -- so a regression back to a raw
spawn fails here as well as at the boundary guard.
* Assert cleanup is defined before accessing properties
* feat(workspace-cleanup): name every applied filter in the bar and make it removable
Replaces the one-time filter migration this PR used to carry, and the per-group
apply checkboxes that were planned to follow it. Both existed to answer one
question -- why is a filter I never turned on hiding my workspaces -- and neither
was the cheapest honest answer.
The bar already read "Showing 546 of 799", so the *effect* was always visible.
What was missing was the *cause*: which filter, and that it came from a previous
session. Active constraints now render as removable chips in the bar, and Clear
filters is promoted out of the popover it was buried in.
Why this replaces the migration: a blanket clear cannot tell a wheel mutation
from a deliberate choice, and the version here was worse than that -- it
neutralized all ten groups, including location.repoIds and location.pathPrefix,
which a wheel cannot set. With a chip, a stray threshold is visible on open and
one click removes it. No marker, no provenance guessing, nobody's deliberate
filters deleted.
Why this replaces the apply toggle: every group already has a neutral resting
state that does not constrain -- an empty numeric field, tri-states at 'any',
the two booleans permissive. "Not applied" and "empty" are the same state
today, so the toggle's only unique power was parking a value you are not using.
That is a modest convenience against ten checkboxes, two-way drafts, a
persistence model that could not use an 'enabled' flag without an older host
dropping it, and a hydration guard.
Chips are per-field, not per-group: "Activity" tells a reader nothing, while
"Idle 20d+" names the thing hiding their workspaces.
Zero handling matches the matchers: a 0 minimum is inert and shows no chip, a
0 maximum hides every measured non-empty workspace and does.
* fix(workspace-cleanup): address the second review on the filter chips
Four findings from the re-review of 3b88645aa1:
- **Same-tick writes could restore a cleared chip.** `replaceFilters` read the
store but `patchFilters` still rebuilt from the render snapshot, so a chip
clear plus a facet patch in one tick left `idleMinDays` at 20. Every writer
now derives from current store state. Tests cover both call orders, and
reverting the reader reproduces the reported failure.
- **Chip labels went stale across a language change.** They were memoized on
`filters` alone, so unchanged filters reused the previous language's strings.
Derivation is constant-size (one pass over the filter fields, not per row), so
it just runs each render.
- **The new catalog entries were English-only.** ko and zh now carry all 22 chip
strings. The verifier stayed green because missing target-locale entries are
allowed and fall back to English -- which is exactly the trap this series has
now hit twice.
- **Remove targets were 16x16.** They use the shared button primitive at the
canonical `icon-xs` size.
Also trimmed the defect-history comments to the repo's concise style.
* fix(workspace-cleanup): unify chip clears on the merged updater form
#15298 landed the functional-update `patchFilters`, so `replaceFilters` uses the
same idiom rather than reading the store directly. Same guarantee, one pattern.
* Track container-only tokens and tab focus for cmd+j ranking
Previously ranked by whether any container-only matches existed (boolean);
now counts tokens matching only containers for finer-grained ranking. Tab
focus recency is now tracked explicitly so recent refocuses rank above
stale worktree activity. Preserves worktree grouping by input order while
applying focused-group MRU within each block.
* fix(cmd-j): preserve duplicate recent tab occurrences
* fix(cmd-j): preserve host scope during worktree purge
* fix(cmd-j): scope repo purge for exact-id host twins
* fix(cmd-j): scope ssh visit recency to local to survive restarts
Boot hydration loads only local + runtime:* partitions, so routing
ssh-qualified recency to ssh partitions strands it across restarts.
- Keep ssh-qualified visit timestamps in local partition
- Route runtime-qualified keys to their partition
- Remove groupId from recent tab occurrence base (unstable on regroup)
- Collapse bare and host-qualified timestamps, preserving max
- Simplify repo pruning host-match logic
- Add robustness: optional chaining, helper function
* Scope focused tab recency by worktree to fix Cmd+J ranking
Tab ids can be duplicated across worktrees; scoping recency keys to per-worktree prevents one worktree's MRU position from overwriting another's in Cmd+J. Scope worktree order blocks to (hostId, worktreeId) to keep same-id worktrees on different hosts separate.
Also fix recency preservation during partial identity migrations and prune orphaned host keys on removal.
* fix(agent-status): retire panes whose agent process is gone (STA-4612)
Agent status can hold `working` on a pane where no work is outstanding, and
nothing closes the gap. A pane's Claude state is a join of a lead turn and three
latches — the subagent roster, the background-task gate and the session-cron gate
— and each is set by a hook and cleared only by another hook. Claude Code emits
no terminating hook on `/exit`, `/clear`, Ctrl+C, crash, SIGKILL or terminal
close, so every one of those latches is a claim with no owner and no expiry. The
join is also materialised at ingest time and persisted, so a stale `working`
survives restart and blocks hibernation, which requires `done`.
Registering `SessionEnd` is not the fix: it covers roughly a third of exit paths
(measured on 2.1.231/2.1.233; upstream anthropics/claude-code#17885 and #6428 are
both closed as not planned). Nor is a TTL — `AGENT_STATUS_STALE_AFTER_MS` only
decays the sidebar dot at read time while the stored row stays non-terminal.
So the backstop is built from evidence Orca already owns.
A session id that changes means the conversation was replaced. On the first hook
of the new session — whatever that hook is — the previous session's own claims
are void: its session crons and its one-shot subagents. Deliberately not voided:
the background-task gate (a background shell is an OS process that survives
`/clear`, and the previous inventory is positive evidence it was running), and
`confirmedTeammate` rows (persistent in-process teammates a lead swap cannot
end). The lead record is left to the incoming event's own fold.
A certified process exit retires the pane. Orca already does this on every
attributable PTY exit — `clearProviderPtyState` resolves the pane key and calls
`clearPaneState` — but that resolution depends on the spawn-time `ptyPaneKey`
mapping, which a restored or reattached PTY may never rebuild. Those panes keep
their row and latches for good. `onPtyExit` knows the keys teardown could not
resolve, so it reconciles them from its own records. The certificate is
`exitCode >= 0 || hostExitConfirmed || providerExitObserved`: a synthetic `-1`
from a failed stop is not a death (the PTY can have survived it), while a real
exit can also report `-1`, so neither the code nor the SSH surface predicate is
sufficient alone. `providerExitObserved` is additive and separate from
`hostExitConfirmed`, which also drives the liveness verdict and the SSH surface
decision.
A confirmed shell foreground is the `/exit` case: the agent died, the shell
lived. That already dropped the row, but through `agentStatus:drop`, which by its
own contract preserves a live pane's caches — so every latch survived and the
next event resolved the pane back to `working`. It now routes through the
reconciler instead, gated on a per-pane accepted-status generation rather than
row identity: the confirming process read can take seconds, and `updatedAt`
cannot order two writes inside one millisecond (the store deliberately admits
equal timestamps).
Cold start generalises the same way. The startup sweep required a restored
subagent roster, so a stranded lead row, background-task gate or cron gate — the
shapes with no child event left to reap them — were never candidates.
Hibernation needs no change: with the above, those rows become genuinely `done`
and the lockout resolves through the front door. A `restoredUnconfirmed` bypass
in the planner would let it reclaim the heap of an agent that may be working.
Not included: folding `background_tasks` from a child-attributed `SubagentStop`.
Writing its test surfaced #11838's deliberate assertion that child inventories
are not authoritative for lead-owned background work, and the listener says the
same — "background_tasks is trusted only where unambiguous". An empty list on a
`SubagentStop` does not prove the lead's shell ended, so the fold would have
cleared a gate on evidence that establishes nothing.
STA-4119's live-side question — whether a genuinely live background shell should
hold the lead row after the lead turn ends — is untouched. This change extends
gate-clearing to zero new triggers.
* fix(agent-status): make the confirmed-shell reconcile survive its own drop
The /exit leg never fired. `settleDeferredCommandFinishedStatusDrop` runs the
paired drop before the reconcile, and `dropAgentStatus` cleared the per-pane
accepted-status counter the reconcile's guard then read — so the guard compared
a live anchor against a zeroed counter and skipped itself on every pane that had
a status row, which is every pane worth reconciling. The existing test passed
only because it used a pane with no row, where the drop early-returns and both
sides read 0.
Stop keying the guard on a counter a sibling teardown path can reset: the
ordinal is now stamped on the row itself, derived from the row it replaces, so
there is no side table to clear and a batched burst lands the same ordinals as
the equivalent sequential writes. A removed row means "nothing reported", which
is exactly what the paired drop leaves behind.
Also:
- Keep the `providerSessionOnly` resume identity that the paired dismissal mints
when the shell outlived the agent; a certified PTY exit still takes it, since
there is no pane left to resume into.
- De-vacuum two guard tests. The confirmed-teammate pin never anchored a session
owner, so the void it claimed to survive never ran; the unavailable-inspection
pin asserted before the confirm ladder settled. Both now fail when their guard
is removed.
- Derive `hasLiveClaimsForPaneKey` from a predicate that lives beside
`clearPaneCacheState`, so a new latch cannot be added to the teardown and
silently missed by the claim check.
- Drop the unreachable compact-`trigger` clauses; SessionStart is the whole guard.
- Cover the connectionId arm of the exit certificate, where a provider-observed
death and a preserved SSH surface are deliberately independent.
* fix(agent-status): keep agent-status-types under its line cap
main already sits exactly at the 300-line max-lines cap for this file, so the single
`acceptedStatusSeq` field this branch adds pushed it to 301 once main's observation
facet merged in.
Declared the field as a mixin beside the observation facet instead. Both are per-write
facets mixed into `AgentStatusEntry` rather than fields a reporter supplies, so they
belong together — and the capped file loses a line rather than gaining one, since it
already imports from that module. No lint suppression.
* fix(agent-status): collapse the entry facets into one intersection
The previous attempt still tripped max-lines: two mixins on one intersection wrap
across two lines under oxfmt, so removing the field line bought nothing.
Expose a single AgentStatusRowFacets that already includes the observation facet, so
the entry intersects one short name on one line. The payload keeps intersecting the
observation facet alone — it must not carry the renderer-local ordinal.
Verified by formatting first and then linting, which is the order that catches this.
* fix(agent-status): retire resume authority with dead panes
`cleanupLegacySystemManagedHooks` reads `~/.codex/hooks.json` and, when it finds
no hooks, removes Orca's managed trust entries from the system config.toml and
deletes that home's grant-ledger record.
Since the STA-4823 read classification landed, `readHooksJsonWithRaw` reports a
genuine absence as `{ raw: null, config: {} }` and a failed read as
`{ raw: null, config: null }`. Both still fell into the same branch, so a read
that merely failed discarded hook approvals the user had already given — and the
ledger record that would have let a later pass notice.
Only the definitive-absence answer may reach the removal now.
Found while reviewing #15417 and not covered by it: that PR fixed the classifier
and this is a consumer of it that still collapsed the two answers.
The sibling `assertHooksJsonGeneration` guard in codex-real-home-hook-install.ts
has the same `existsSync ? read : null` shape and is deliberately NOT changed
here. Measured, a permission denial leaves existsSync true and throws from the
read, so that path already fails closed; a guard there could not be made to fail
in a test and would be unprovable code.
`shouldReadWorkspaceCleanupGitEvidence` refused to read git whenever `blockers`
included 'pinned' -- in a clause `forceGitCheck` could not override. So the
confirm-time forced read, which exists precisely to decide whether a removal
needs force, never ran for pinned rows.
This is reachable today, not only after the planned verdict removal. 'pinned' is
not a queue blocker (queue blockers are main-worktree, folder-repo, dismissed),
so a pinned idle workspace is hand-selectable from the row checkbox right now.
Its git evidence stays `clean: null, checkedAt: null`, and
`shouldForceWorkspaceCleanupRemoval` returns true whenever git is unknown -- so
it force-deletes with no evidence ever obtained.
Moved 'pinned' into the cost-skip clause so `forceGitCheck` overrides it:
broad scans still skip it, targeted preflights do not. main-worktree and
folder-repo stay unconditional because both are refused before removal, so
reading git for them is pure cost.
Broad-scan performance is unchanged, and there is now a test pinning that: it
passes before and after. The preflight test fails against the unfixed source.
* Add keyboard shortcut for workspace deletion
Default Mod+Shift+Backspace (⌘⇧⌫ on Mac) lets users delete the hovered
worktree or folder workspace immediately. The shortcut targets the
sidebar hover state rather than requiring focus, and avoids terminal
pane D-based split shortcuts on all platforms.
Co-authored-by: Brennan Benson <brennankbenson@gmail.com>
* Omit delete shortcut from disabled Delete Worktree for primary checkout
- Remove shortcut badge from the disabled "Delete Worktree" action when it cannot be executed
- Only show shortcut in multi-context delete actions where the command is available
- Extract host identity parsing into reusable helper function to prevent inline string manipulation
- Fix folder workspace deletion to use correct host-qualified identity comparison
* Document host extraction safety for destructive worktree ops
Unqualified identities must stay undefined rather than defaulting to
'local'. Destructive operations depend on correct host identification.
Added tests and JSDoc to clarify this safety-critical behavior.
* fix test
---------
Co-authored-by: Brennan Benson <brennankbenson@gmail.com>
* test(e2e): gate the tab-bar agent launcher on Windows shells and WSL
The `+` menu agent launcher had no golden coverage in the Windows lane, so a
Windows-only break anywhere in its chain (detection row, startup-plan build,
tab create, PTY spawn, startup-command injection) could ship unnoticed.
Adds a golden spec that launches a stub agent from the menu and asserts the
agent's own banner reached the pane — a tab that spawned a bare shell instead
is indistinguishable at the store/tab layer. Runs two agents everywhere, and
on Windows also PowerShell, cmd, Git Bash and a WSL project runtime.
* test(e2e): track WSL stub agent staging state for precise cleanup
Refactor `stageWslGoldenStubAgent` to track which artifacts it creates
during setup, then only remove those artifacts during cleanup. This
prevents the test from destructively removing pre-existing symlinks or
state from previous runs, improving test isolation and idempotency.
* test(e2e): track WSL stub agent staging state for precise cleanup
- Back up and restore pre-existing stub agents to avoid destroying them
- Simplify verbose test comments to match project style guidelines
* test(e2e): serialize WSL stub agent setup with distributed lock
- Add mkdir-based lock to prevent concurrent staging invocations
- Reclaim stale locks after 10 minutes to recover from crashes
- Track lock ownership in stage state for safe cleanup
* test(e2e): track WSL stub agent staging state for precise cleanup
Track which stubs this test helper stages by writing a marker file, then
only remove stubs during stale-lock recovery if we created them. Prevents
cleanup from removing stubs left by other processes.
* test(agent-status): characterize title-derived agent identity before the resolver change
getAgentLabel is an ordered first-match-wins scan of substring predicates over a display
title, so chain position rather than evidence strength decides identity. Pin the current
answers — including the wrong ones — so the resolver change lands as a reviewable diff of
assertions instead of silent behavior drift.
Eight of the nineteen assertions record defects. Five are minimized from real recorded pane
titles: four Grok panes that read as Codex and one that reads as Gemini CLI, in every case
because a foreign agent name in free-form task text is checked before the `- <agent>` owner
suffix that actually names the pane. The suite also pins the pairwise property behind them —
both orderings of a name pair resolve to the same agent, which is the tell that the title
carries no signal distinguishing them.
Also pinned as correct so the resolver does not regress them: hyphenated worktree names
(`review-14600-codex`) stay unclassified, and a Claude glyph still wins over foreign task text.
Verified non-vacuous: applying PR #15535's narrowing to isGeminiTerminalTitle flips exactly
four assertions, one of them a real corpus title, and the suite is green again on revert.
No production code changes.
* test(agent-status): re-pin the four assertions #15535 changed
#15535 landed the Antigravity narrowing, so four characterized answers moved. Re-pinned
against the new main rather than deleted, and the two that are now correct say why they are
correct — a targeted exception cleared the path, not a structural fix.
Added the general form as a new defect case: the same Grok pane without the word
"Antigravity" in its task text still reads as Gemini CLI, because only that one pair has an
exception. That is the case the resolver has to answer without a per-competitor clause.
* test(agent-status): clarify characterization precedence
A profile carried `activity.idleMinDays = 20` that the user never set, hiding 253
of 799 workspaces on open. Chromium mutates a *focused* number input on every wheel
tick, and before #14629 the facet panel could not scroll, so the natural response --
cursor into the panel, spin the wheel -- walked the threshold up and persisted it.
Two fixes:
- `FacetNumberField` renders `type="text" inputMode="numeric"`. A wheel cannot
mutate a text input, and the parser already takes strings. `preventDefault` on a
focused number input would also work, but it blocks the wheel's default action --
which includes scrolling the nearest scrollable ancestor -- and would re-break the
panel scrolling #14629 just fixed, in exactly the reported gesture. All five
numeric facets share this one field.
- `patchFilters` closed over the render's `browse` snapshot, so two patches in one
tick dropped one. It now writes through a functional update against the latest
store state. `toggleSortField` and `clearFilters` had the same defect.
Both tests were confirmed to fail against the unfixed source before being kept.
* fix(workspace-cleanup): stop pre-selecting workspaces for deletion
'Ready' is Orca's verdict about the user's own work. The dialog already
refuses to display the tier as a workspace fact, then pre-checked rows based
on it anyway — acting on the verdict more loudly than showing it would. Open
with an empty selection and let the user decide.
Removing auto-select unmasks two selection defects it was hiding, both fixed
here because they become reachable the moment select-all is the primary path:
- The header checkbox compared a canQueue-scoped selected count against a
canSelect-scoped selectable count, so hand-picking review-tier rows flipped
it to fully-checked and the next click cleared the entire selection.
- A filter change silently dropped selected rows; auto-select used to refill
them instantly, so the loss was invisible. It is now reported.
Also drops the scan toast's 'N cleanup suggestions' clause, which published
the same verdict outside the dialog, and labels select-all with the count it
actually takes (the deletable subset, not every matched row).
* chore(i18n): sync the catalog for the cleanup selection strings
* fix(workspace-cleanup): preserve explicit selections
* fix(workspace-cleanup): name the local context instead of totalling it
The delete confirmation showed 'Context: 2', a sum of five unrelated things
(terminal tabs, clean editor tabs, browser tabs, diff notes, finished agents)
rendered as plain text with no icon or tooltip. A reader cannot tell what the
number counts, which is the one thing that screen exists to tell them.
Reuse the breakdown the expanded row already renders ('Terminal tabs: 1,
Browser tabs: 1') so the confirmation names what deleting would discard. No new
strings: the per-kind labels already exist and were already translated.
* fix(workspace-cleanup): keep context labels legible
Document that Git worktree removal may also delete the checked-out local branch, while clarifying that --force does not force branch deletion and that Orca retains branches whose changes cannot be proven merged.
* reland(opencode): session continuity without the command-finished deferral (STA-4557)
Relands #14866 (reverted in #14943) minus its `orca-runtime.ts` change, which
is what caused the revert.
## Why the original runtime change was wrong
`retirePtyAgentLaunchAuthorityAfterCommandFinished` deferred launch-authority
retirement behind an async foreground read, on the premise that OpenCode emits
`command-finished` while still in the foreground. Raw PTY capture disproves it:
OpenCode emits no OSC 133 of its own, and Orca's shell wrappers emit exactly one
`133;D` per pane — at OpenCode's exit — under both zsh and bash. The event being
deferred past only ever fires at exit, which is exactly when authority should be
retired. Both call sites stay on the synchronous `retirePtyAgentLaunchAuthority`.
## Why the deferral was unsafe
`confirmPtyAgentExit` uses the same async-foreground pattern four lines away, but
its early return means "don't record an exit" — conservative. The deferral copied
that shape into a site where the early return means "don't revoke a secret". Same
code, inverted consequence: every guard failed open, so a stale or racing read
silently kept a finished session's authority alive, and the pane's persisted
`launchTokenHash` was never scrubbed — so it rehydrated as `restored` authority
after an app restart.
## Why the deferral's guards could not have worked
`ORCA_AGENT_LAUNCH_TOKEN` lives in the PTY environment, so every process started
in that shell inherits it — both sessions in a reused pane post the same token. A
pane-lifetime bearer secret cannot be a session identity baseline, by
construction, and `incarnationId` tracks the PTY, not the agent. The only field
that separates sessions is the provider `sessionID`.
## What lands
- Status/session-boundary work from #14866: opencode emits `SessionStart` for
root sessions (mimo-code does not), launch-token fencing, and `SessionStart`
as an opencode turn boundary.
- The two `server.ts` fixes from #14941: re-fence a still-authorized pane on a
tokened `SessionStart`, and restore mimo-code's explicit-prompt restart
boundary (mimo emits no `SessionStart`, so opencode-only stranded its panes).
#14941's re-poll hunk is dropped along with the code it patched.
- Five regression tests in `opencode-finished-session-authority.test.ts`. They
pass here and all five go red if the deferral is re-added.
* chore: drop incidental reformatting of files unrelated to this PR
Antigravity's models are named "Gemini <n.n> <Name>" — the real `agy models`
output is already parsed in commit-message-agent-spec.test.ts — so an agy pane's
own title carries a whole `gemini` token. getAgentLabel checks Gemini CLI before
Antigravity, first match wins, so the model name won and the pane read as Gemini
CLI. Measured on '⠋ agy · Gemini 3.7 Flash · high': geminiGlyphs false,
geminiToken true, agyToken true, label 'Gemini CLI'. Even
'Antigravity · Gemini 3.7 Flash' resolved to Gemini CLI.
This surfaced as the tab bar and the sidebar disagreeing about the same pane,
because the two reach different copies of the chain and apply different
precedence to its result.
Defer only the token path: if a title carries an agy/antigravity token, the
bare-`gemini` branch declines. The four Gemini OSC glyphs stay decisive, and agy
emits none of them. Same shape as the existing isPiAgentTitle veto directly
above, which exists because substring matching made paths like 'gemini-project'
masquerade as Gemini CLI.
Narrowing the token rather than reordering the chain, deliberately: a real
recorded pane title from local terminal history is
'STA-4011 Linux Antigravity Commit Messages - grok' — a Grok pane whose task
text contains the token Antigravity. It resolves correctly only because grok is
checked before antigravity, so hoisting the Antigravity branch would break it.
That title ships as a regression case.
Both copies of the chain are fixed; the sidebar reaches one and the tab the
other, so fixing one alone would only move the disagreement.
* refactor: split pty-connection.ts under 400 lines
* rm design doc
* refactor(pty-connection): extract reattach payload handlers as factories
- Replace bindApplyReattachPayload with createReattachPayloadHandlers factory that returns handlers instead of mutating session directly, enabling better composability and testing
- Extract waitForUserInitiatedSshConnect as standalone function for reuse across deferred session attach flows
- Create ReattachPayloadSession type to document and isolate required session capabilities
- Add test coverage for overlapping reattach payload attempts
- Clean up comments to remove redundant prefixes (session.pane → pane, session.transport → transport)
* fix(pty-connection): correct sequencing and state bugs in spawn and reat
- Fix terminal tail slice to take prefix instead of suffix, preserving escape
sequence markers needed by next scan
- Clear pending pane serializer when direct SSH retry PTY is unclaimed
- Initialize interrupt status baseline to undefined so first input advances
sequence counter
- Bump reattach generation only after confirming current attempt owns the stream,
preventing superseded results from canceling in-flight prepaint
* fix(pty-connection): correct sequencing and state bugs in spawn and reat
- Fix terminal tail slice to take prefix instead of suffix, preserving escape
sequence markers needed by next scan
- Clear pending pane serializer when direct SSH retry PTY is unclaimed
- Initialize interrupt status baseline to undefined so first input advances
sequence counter
- Bump reattach generation only after confirming current attempt owns the stream,
preventing superseded results from canceling in-flight prepaint
* fix(test): increase poll iterations to prevent Node 26 test leakage
Increase event loop turns from 40 to 200 in the timer settlement loop.
Node 26's libuv poll phase can briefly starve when concurrent workers
transform tests, causing cleanup to leak into the next test. The higher
iteration count ensures async operations complete before returning.
* fix(foreground-output-budgets): use >= for budget window boundary check
At the exact window boundary, the budget should roll over. Change the
comparison from > to >= so the window resets when now equals
windowStart + FOREGROUND_BUDGET_WINDOW_MS, not just after. Add tests
to verify budget rejection and rollover behavior.
* refactor(pty-connection): add status observations and routing improvemen
- Track agent status observations with origin and transition metadata
- Separate interactive redraw input timing from general terminal input
- Restore pane authority on bind and reattach
- Refine routing trust and confirmation state handling
- Invoke queued startup callbacks when PTY is bound
- Resolve Windows shell overrides with user settings
* refactor: extract resolveLaunchAgentCandidate helper
Consolidate duplicated launch-agent resolution logic into a shared helper to prevent future divergence between paneExpectsLaunchAgent and resolveExpectedLaunchTuiAgent.
* refactor(pty-connection): use model snapshot for direct SSH reconnects
Direct SSH reconnects now restore from the full SSH model snapshot (complete scrollback) when dimensions are compatible, instead of the bounded relay tail. Falls back gracefully when incompatible or alternate-screen was exited.
* refactor(pty): retry unverifiable SSH reattaches via preserved bindings
Preserve deferred SSH session IDs longer when they serve as the only retry binding,
allowing the system to attempt recovery through direct SSH retries or PTY remounts
when reattach fails in an unverifiable way. Simplify reconnect model restoration
by removing the conditional model snapshot probe and using relay replay directly.
* test: poll terminal readiness in expectSingleOwningPty
Retry the terminal list assertion with polling to account for timing
delays in PTY state reporting from the runtime.
* fix(codex): stop rebuilding shared Codex state from a read that failed (STA-4823)
Six shared files were rebuilt, erased or reported healthy after a read that had
only failed. Batch A of the STA-4606 split: every one of these is reachable and
testable on the host lane, so none of them wait on the WSL work.
- `config-toml-trust.ts` upsertHookTrustEntries: `existsSync` reported a locked
config.toml as absent, so the base content became '' and the upsert rewrote
the file from the trust entries alone — a trust-only stub, with the user's
model, provider, MCP servers, approvals and comments gone. It refuses now;
every hook-service caller already turns that into "trust entries could not be
written. Run /hooks in Codex to approve."
- `codex-trust-grant-ledger.ts`: an unreadable ledger degraded to empty and the
next write persisted a file holding only the home being written, dropping
every other home's grants. The write paths refuse; the read path still
degrades, and a corrupt ledger is still rebuilt.
- `codex-pane-account-registry.ts`: an unreadable registry erased every pane's
attribution AND cached that erasure, so it survived the file recovering. The
failure is no longer cached, and both write sites refuse rather than persist a
registry derived from an empty stand-in.
- `hooks-json-read.ts`: the read arm already separated "no hooks" from "could
not read", but the `existsSync` arm in front of it returned a valid empty
config for a file that could not be opened. One read now classifies both.
- `config-settings-baseline.ts`: absent, unparseable and unreadable all collapsed
into `null`, so the snapshot rebuilt a baseline it could not read — recording
an in-Codex edit as Orca's own write, after which promotion skips it forever.
- `config-sync-stall.ts`: an unreadable runtime config read as absent and the
status reported `synced` while the mirror was refusing. It reports
`managed-home-unavailable`, the existing reason for exactly this, rather than
borrowing a source-side one and blaming the wrong path.
Absent and malformed still rebuild throughout — resetting corrupt state is the
intent, and conflating it with unreadable would wedge a user on a broken file.
* fix(codex): close shared state read-denial gaps
* fix(codex): recover oversized settings baselines
* fix(codex): name the stalled managed config
* fix(codex): preserve hooks after failed source reads
* test(codex): correct what the denyExistence rig actually models
MEASURED on both platforms: a file-permission denial leaves existsSync TRUE
and fails only the content read — chmod 000 gives EACCES on macOS, icacls
/deny (R) gives EPERM errno -4048 on Windows, with stat/lstat succeeding in
both. The rig's docblock claimed this mode modelled that denial. It does not.
What it models is the UNC / \\wsl$ transport, where an unreachable distro
reports errno UNKNOWN at every level and existsSync folds it to false.
The distinction decides what the D29 guard is worth: under a permission denial
the pre-fix code already failed safe, because existsSync was true so it took
the read branch and threw. Only a transport that lies about existence reaches
the rebuild-from-empty path. No behaviour change; the comment was wrong, not
the code.
* test(codex): exercise live baseline read denial
* fix(codex): retry pane attribution writes
* fix(codex): retry reconciliation registry writes
* fix(codex): report unreadable sync baselines
* fix(agent-hooks): revive a retired pane on each provider's own new-turn event
The un-retirement gate matched two raw event-name literals, UserPromptSubmit and
SessionStart. Only 5 of 18 hook sources name their turn boundary that way, so for
the rest a reused pane stayed rowless forever: the user starts a new turn and no
row ever appears in the sidebar or dashboard.
Measured, not estimated — 10 providers fail the new test on main: gemini
(BeforeAgent), antigravity (PreInvocation), amp (agent.start), cursor
(beforeSubmitPrompt), pi/omp/prime-agent (before_agent_start), grok
(user_prompt_submit), copilot (sessionStart, seen raw), hermes (pre_llm_call).
The gate was also wrong in the other direction: opencode has no turn boundary at
all, yet a literal SessionStart revived its pane. Both directions are covered.
The correct per-provider classifier, isNewTurnEvent, was already imported into
this file and already used 160 lines below — #14706 added that call specifically
so consumers would stop re-deriving boundaries from literals, and left this one.
Keeps the literal check when the remote envelope omits source: that field is
optional, an older relay does not send it, and requiring it would have left every
source-less remote pane retired forever.
* fix(agent-hooks): tell an absent source apart from an unrecognized one
Review of the first commit found that `isAgentHookSource(envelope.source) ?
envelope.source : undefined` collapses two different wire conditions into one:
an older relay that omits `source`, and a NEWER host relaying a provider this
build does not know. They need opposite answers.
Case two is the normal upgrade order — hosts and clients update independently —
and its boundary event will not be UserPromptSubmit/SessionStart, because 13 of
the 18 providers we already ship are named something else. So the legacy-literal
fallback stranded that provider's panes permanently: the exact defect this branch
fixes, silently reintroduced for traffic our own wire doctrine calls normal.
Pass the raw wire value so the gate can distinguish them, and fail OPEN on an
unrecognized provider. The costs are asymmetric: a stranded pane is invisible,
permanent, and has no user-facing recovery, while a spurious revive produces a
row that decays after AGENT_STATUS_STALE_AFTER_MS.
Also from review:
- Cover the source-less branches, which had no coverage at all — including a test
pinning what the legacy shim CANNOT do, so nobody later widens the literal list
to "fix" it.
- Use the source as agentType rather than always 'claude'; 15 of 16 rows were
describing a state that cannot occur.
- Reword the opencode comment: no plugin in that family emits these literals, so
this closes a hole rather than removing behavior.
Checked and not changed: the local HTTP path 404s an unresolvable source, so the
fallback is unreachable there and masks nothing.
* test(agent-hooks): stop pinning opencode's fence behavior on a synthetic event
The negative case asserted that an opencode pane stays retired after a literal
SessionStart. That rests on a false premise: origin/main's opencode plugin never
emits SessionStart at all — zero occurrences in opencode/hook-service.ts, and the
shared family source is what mimo-code uses too. So the assertion pinned an event
no plugin sends, and it would have become actively wrong the moment a pending
change gives opencode a real SessionStart, silently re-breaking the rowless
reused-pane case that change exists to fix.
Assert against mimo-code and command-code instead. Both genuinely have no
boundary event in any planned state, so the case tests what it claims to.
* fix(agent-hooks): reject malformed relay sources at retired fence
Only a non-empty unknown string can identify a future provider. Keep null, blank, numeric, and object source values behind the retired-pane fence instead of treating malformed wire data as a new turn.
* fix(status-bar): remove pet menu reserved space
* test(status-bar): add pet segment layout validation tests
- Unit test guards against pr-[6.5rem] padding reintroduction
- E2E test measures trailing overhang instead of total width delta
for more accurate layout validation
- Extract enableExperimentalPet helper for test clarity
---------
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
* fix(runtime): classify tui-idle from the visible screen only
The adopted-PTY tui-idle probe added in #15569 read the provider snapshot as
`scrollbackAnsi + data`, and the Codex readiness classifier matches the startup
banner. For a daemon-hosted adopted worker — where the retained tail stays empty
forever — every wait re-probed and could resolve `satisfied: true` off banner
history while Codex was actively working, turning a loud timeout into a silent
false ready.
- probe now requests and parses the visible grid, never scrollback
- retirement of a timed-out provider acquisition is checked before the
re-acquire branch, so a wider row request can no longer resurrect a hung
provider
- probe builds its result before clearing the poll interval, so a stale handle
cannot leave the waiter with neither poll nor probe
Fixture follow-ups from the same review:
- resume legs pin the captured `launchConfig.agentCommand` to the fake instead
of bare `codex`, which resolved the machine's real Codex off PATH
- the command override is quoted for the Windows shell the runtime will actually
use, and specs pin that shell alongside the override
- fake agents acknowledge a bare submit after a short grace, so an unbracketed
delivery path fails with a diagnosable ACK instead of a suite timeout
Refs STA-4907, STA-4885
* test: assert tui-idle probes serialize visible grid only
- Verify idle timeout probes exclude scrollback from serialization
- Add test case for Git Bash shell path quoting with apostrophes
- Simplify verbose test helper comments
* test: improve fake agent paste protocol validation
Refactor paste end detection to properly track both begin and end markers,
validate bracketed paste protocol (RFC 2544) through chronological event
sequencing, and emit correct error messages for protocol violations. This
ensures reliable detection of when pastes complete even when delivered
across multiple chunks, and correctly distinguishes between bracketed and
unbracketed paste modes.
* fix(runtime): reject provider snapshots when live output advances
Provider snapshots become stale when live output is received after the
snapshot is requested. Reject snapshots where the current output sequence
exceeds the snapshot sequence, preventing callers from consuming outdated
terminal state. Add tests verifying stale frame rejection.
* perf(preflight): read the WSL mount table once per shell, not once per CLI
The prelude is embedded in the lookup script, and the caller wraps that in
`for cmd in <every agent>`, so the unconditional assignment forked awk once per
probed CLI -- 36 of them inside the distro against a 10s detection budget. The
comment claimed it was read once outside the loop; it was not.
`${x+set}` rather than `[ -n ... ]`: a host with no Windows mounts yields the
empty string, which must still count as read.
Pinned by counting real awk forks through /bin/sh with a stub on PATH, because
nothing covered this expression at all -- a wrong-field mutation shipped green.
Verified to bind: the unconditional form counts 4 for 4 commands.
* fix(wsl): make launch resolve the same binary detection reported
Agent detection skips Windows mounts during the PATH walk; the Codex WSL
command builder and the WSL branch of isCommandOnPath did not. So Orca could
report the guest codex as installed and then launch the Windows one sitting
ahead of it on PATH, or disagree with itself between preflight and detection
about the same distro.
Both now pass the same option.
Verified on a real Windows host against a real WSL2 distro, with a Windows
binary planted ahead of a guest one on PATH:
plain `command -v orcaprobe` -> /mnt/c/Users/neil/orca-agree/orcaprobe
this lookup -> /home/neil/.orca-agree/bin/orcaprobe
That host reports /mnt/c as 9p, which the mount expression matches, so the
fstype list is confirmed against hardware rather than fixtures.
* test(preflight): prove the memoised mount list applies past the first command
Counting awk forks with a stub that reports no mounts cannot see what the
hoist trades correctness for. A mutant that empties `_orca_win_mounts` inside
the walk keeps the fork count at 1 and keeps every existing test green, while
every agent after the first stops skipping /mnt.
This runs two commands behind a stubbed Windows mount and asserts both resolve
to the guest binary. Verified against that exact mutant.
Credit: review counsel.