* 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.
* fix(wsl): budget the whole command line, not just the script
The argv/stdin threshold measured `script.length`, but the cap applies to the
finished command line -- which also carries `PATH=<login PATH>` and `HOME=`.
A login PATH is itself a few KB.
That produced a perverse band: with a long enough PATH, a 7,999-char hook was
placed on argv and CreateProcess refused it, while the SAME hook at 8,001 chars
flipped to stdin and ran. Size decided how a hook behaved, in the wrong
direction, and the failure looked like "your setup hook failed" with nothing
pointing at length.
Now the argv form is built, measured, and only used if the whole line fits;
otherwise the script goes to stdin as before. The count over-estimates slightly
(it charges quoting for every argument) because over-counting is the safe
direction for a cap.
The regression test uses a 7,000-char script -- deliberately under any
script-only threshold -- with a 27KB PATH, and asserts it lands on stdin. My
first attempt used 7,999 + `echo `, which is 8,004 and flipped under the old
rule too, so it passed either way and proved nothing.
Credit: Grok.
* fix(wsl): charge quoting and measure the line that is actually spawned
Two under-counts the review found in the estimator I added.
The doc comment claimed it over-counts. It did not: libuv escapes every `"`
and doubles a backslash run before a quote, so a quote-dense script costs more
than its length. And `wsl.exe` plus `-d <distro> --exec` are prepended AFTER
the measurement, so ~45 characters of the budget were never counted.
Together those put a quote-heavy ~26KB script on argv and over the real 32767
ceiling -- where the old script-only rule would have sent it to stdin and it
would have run. A narrower band than the one this PR removes, but the same
shape of bug, so worth closing before merge rather than after.
Now charges one character per `"` or backslash and measures the full spawn
line. New test: 26,000 quote characters must land on stdin; verified to fail
with the quoting charge removed.
* fix(preflight): do not count a Windows binary reached through interop as a WSL install
WSL appends the Windows PATH to the guest PATH by default, so on a distro with
no guest `claude`, `command -v claude` resolves to
`/mnt/c/Users/me/.../claude.exe`. That path is POSIX-absolute, so the existing
absolute-path check accepted it and preflight reported the agent as installed
in the distro.
That is worse than reporting it absent. Absent tells the user to install it; a
false positive launches a Windows executable inside a Linux session, where it
sees Windows paths, no guest $HOME and none of the distro's config -- and the
failure surfaces later, somewhere less obvious.
Rejects `/mnt/<drive>/` and any `.exe`, case-insensitively. A genuine guest
install is unaffected.
* fix(preflight): skip Windows mounts during the PATH walk, not after it
The review caught this and it is the more important half of the fix.
Rejecting the interop path in TypeScript happens after the guest walk has
already stopped on it: the lookup breaks at the first executable, and the
version-manager fallback dirs are APPENDED, so they sit behind the Windows
entries WSL appends. A user with claude in nvm AND on the Windows PATH
therefore went from a false positive to "not installed" -- the exact #9725
population the fallback dirs exist to serve. Worse than the bug being fixed.
The lookup now takes `skipWindowsMountDirs` and skips those PATH components
mid-walk, so the guest binary behind the shadow is still found. Matched by
mount metadata from /proc/mounts (drvfs/9p/virtiofs), not by a `/mnt` name:
the automount root is configurable, and `/mnt` is an ordinary directory on a
Linux box. That also closes the custom-root hole the reviewers found in the
name-based predicate.
The TypeScript check stays as a secondary net for a mount the guest does not
report, with a comment saying why it must never be the thing that decides.
Proven with a real /bin/sh: a Windows `claude` ahead of an nvm `claude` on
PATH now resolves to the nvm one.
Credit: review counsel, and community PR #12794 (spfcraze), which proposed
this shape first.
* fix(preflight): let the mount table be the only word on what is a Windows path
The name-based check could veto a path the walk had deliberately kept. /mnt/d
is a perfectly ordinary Linux mount, so a guest binary there was resolved
correctly by the walk and then discarded by its name -- the #9725 false
negative, reintroduced by the belt-and-braces net I added "just in case". And
if awk were missing, the name rule became the only rule, which is precisely
the failure it was supposed to backstop.
The walk skips components the guest itself reports as drvfs/9p/virtiofs. That
is authoritative. Without a mount table we now degrade to main's behaviour (the
old false positive) rather than inventing a new false negative.
Net: one predicate, three fixtures and an import deleted.
Three bugs in one screen, reported in #15256 with a diff of the user's
orca-data.json showing defaultTuiAgent going from "claude" to null.
1. The Auto pill's handler writes null, and it was rendered as the ACTIVE
choice whenever the stored agent was merely not detected right now. So the
pill that already looked selected was destructive: one click erased the
setting, and a later successful detection did not bring it back. Auto is now
active only when null is actually stored. Detection is a transient fact; the
stored value is not, and this control reports the stored value.
2. With zero agents detected there were no agent pills at all, so the stored
choice was both invisible and unrecoverable -- nothing to click to put it
back. The stored agent is now always offered, labelled as saved but not
currently detected.
3. Refresh lived inside the Installed section, which only renders when at least
one agent was found, so the only retry control vanished in exactly the state
that needs it. An empty result now renders its own Refresh.
This matters more now than when it was filed: #16028 makes WSL detection
legitimately return an empty set on machines where the only agent was a Windows
binary reached through interop, so the empty path is about to get more traffic.
Each of the three tests was verified to fail with its own fix reverted.
`safeStorage.getSelectedStorageBackend` is `@platform linux`, so it is genuinely
undefined on macOS and Windows — confirmed against the installed Electron 43,
where it reads `undefined` on darwin and `function` on Linux. The shipped code
called it behind a `process.platform === 'linux'` check, so it never threw, but
the guard was the only thing standing between that call and a startup TypeError.
The platform check now lives with the probe, alongside a typeof check and a
try/catch, and an unreadable or unknown backend reports no gap — claiming one we
cannot prove would be its own kind of lie.
The gap this closes is in the tests, not just the code: every suite here mocks
safeStorage with the method present, so the suite could stay green while the
shipped app threw. The new case deletes the member from the live mock rather than
re-mocking, because the module already holds that object and a later vi.doMock is
inert — the first version of this test passed against the unguarded code, which
is the failure mode it exists to catch. Verified against the expression currently
on main: three cases fail.
The gap warning fired every startup with no way to stop it. It usually needs a
keyring installed and unlocked to fix, so repeating it every launch is nagging
the user cannot act on and will learn to ignore.
It now reports when the answer changes: once when the gap starts being true,
again if it becomes true for a different reason, and once when it is fixed —
because silence after a "your secrets are not protected" warning would leave the
user assuming that is still the case.
State lives beside the profile data file, which is why the call moved out of the
port bootstrap: that state has nowhere to live until the profile exists. A
corrupt state file re-reports rather than trusting it, and a failed write logs
instead of failing startup, since re-reporting next launch is the safe direction.
ORCA_ALWAYS_REPORT_SECRET_PROTECTION=1 forces a re-report for support without
disturbing the stored state.