* refactor(daemon): split oversized PTY services
* revert(daemon): restore merge-base session listing and canceled-spawn behavior
Two behavior changes rode along with the file-splitting refactor:
- listLiveTerminalHostSessions dropped sessions with isTerminating, not just
dead ones, hiding sessions the merge base still advertised.
- spawnAndPublishSession called session.beginTermination() before publishing a
canceled spawn into the host map.
Both hunks are reverted to the merge base; the refactor is untouched.
* refactor Linear workspace surfaces
* refactor(linear): restore merge-base behavior in split modules
The Linear surface split smuggled in three behavior changes; revert them
so the refactor is a pure move.
- detail-state: drop 'project' from EDITED_LINEAR_ISSUE_FIELDS. List
issues never carry `project` (only getIssue maps it), so preserving it
across hydration permanently blanked the hydrated project whenever an
edit landed while linearGetIssue was in flight.
- detail-state: handleProjectChanged no longer sets hasEditedRef.
- project-selector: remove the mountedRef/requestId guards around the
global patchLinearIssue write and the success/error toasts.
- sub-issues: remove the added isComposing guard on the title Enter key.
The detail-state test asserted the smuggled project-preservation; updated
to assert hydration owns `project`.
* refactor settings maintenance modules
* revert behavior changes smuggled into settings split
- hoist isAdvancedOpen state back into RepositoryHooksSection so it survives
SearchableSetting unmount during settings search
- drop isComposing guards absent from the merge-base AgentsPane handlers
- restore merge-base JSX for the 'when one exists.' fragment (no separator)
* refactor feature wall animated visuals
* fix(feature-wall): restore merge-base render behavior in split visuals
- Hoist workbench reduced-motion state to module constants so cursorTarget
identity is stable and the cursor layout effect stops re-firing per render.
- Render one frame component and branch on the state source so toggling
reducedMotion re-renders the storyboard instead of remounting its DOM.
* fix(startup): stop the PATH seed pinning nvm to its newest install
patchPackagedProcessPath prepends the newest nvm version dir to
process.env.PATH, then hydrateShellPath probes the login shell with that
same env. nvm's startup `use` honors whatever node is already on PATH
instead of the user's `default` alias, so the probe returns a PATH pinned
to the newest install and every terminal pane inherits it.
A user whose newest nvm node is a bare install then loses every global CLI
(codex, claude, gemini, vercel...) inside Orca while they still resolve in
Ghostty/Terminal, which start from the bare GUI PATH and fall through to
`default`.
Probe with the PATH the process launched with. Windows already gets this
through WindowsShellPathOwnership.
* test(startup): pin the platform in the probe-env test
shellProbeEnv short-circuits on win32, so the POSIX-only assertion failed
for anyone running the suite on Windows. Matches the convention in
hydrate-shell-path.windows.test.ts.
Also narrows the win32 exemption comment: WindowsShellPathOwnership
snapshots its baseline after the seeds land, so it does not unwind them.
The exemption holds because Windows keys PATH as `Path` and no Windows
seed pins a node version.
* fix(startup): give win32 the same probe insulation, keyed by Path
The previous commit exempted win32 on the stated grounds that no Windows
seed pins a node version. That is wrong: getVersionManagerDirectories
calls getNvmVersionDirectories on every platform, so a Git Bash user whose
nvm uses the POSIX ~/.nvm/versions/node layout gets the newest version dir
seeded on Windows too, and the -ilc Git Bash probe inherits it.
Record the PATH key alongside the value and overwrite that entry in place,
so Windows never carries both `Path` and `PATH` — which was the only real
reason to skip win32.
* fix(startup): snapshot the launch PATH at module load, log probe failures
Three loose ends from the review, folded in rather than deferred.
The probe's clean PATH was handed over by an explicit recordLaunchPath call
from the seeding site, so the invariant lived across three files and a
refactor that moved the seed call would silently re-pin nvm. Snapshot PATH
during module init instead: that runs while the import graph is evaluated,
strictly before any statement in main's body, so it cannot observe the
seeds and there is no call ordering left to break. All seven importers are
static, so no lazy import can defeat it. A test asserts the probe ignores
later process.env mutation, and fails if the live read is reintroduced.
A failed startup probe leaves the seeded newest-nvm dir in front and said
nothing, so the population whose rc files blow the 5s budget hit the
original symptom with no diagnosable trace. Log the failureReason.
The probe is an interactive login shell, so rc files that exec into a
multiplexer or start a heavy prompt can outrun that budget with no way to
opt out. Set ORCA_SHELL_PATH_PROBE=1 so they can take a fast path.
* fix(startup): drop the other-cased PATH key from the Windows probe env
Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.
Drop any other-cased variant of the key before writing.
* fix(startup): preserve launch PATH across app restarts
* test(pty): pin the renderer-liveness guard STA-2373 relies on (STA-5373)
STA-5373 reported that #15927 dropped the `webContents.isDestroyed()` half of
the guard #10065 added for STA-2373, and that the app therefore dies when a
daemon death fans out to every pane. The guard is present on main: #15172
restored it at both senders when it split the PTY monolith, and a per-file
count across v1.4.188..HEAD shows only relocation (pty.ts:4 ->
write-input.ts:2 + bind-listeners.ts:2). The reported 4 -> 0 was scoped to
`src/main/ipc/pty.ts`, now a 39-line barrel. No production fix is needed.
What was real is that nothing pinned the guard — it survived #15927 only
because #15172 happened to re-expand it — and the shared PTY test fake made
that invisible: its `webContents` had no `isDestroyed` at all, so both guards
passed vacuously in every suite. That is also why they are written defensively
as `typeof ... === 'function' && ...`.
- Give the fake an `isDestroyed` mock, re-stubbed to `false` each beforeEach
rather than left `undefined`, so "alive" is stated rather than accidental.
- Cover both senders red/green: the daemon-death fan-out
(bind-listeners.ts:37) and the per-write reporter (write-input.ts:57).
Verified red against a window-only guard and green with the real one.
The per-write case needs a chunked write. A single-chunk write is already
fenced by `isPtyWriteEventFromMainWindow`, which rejects the event once the
WebContents is gone; only the multi-chunk path yields a macrotask mid-write,
letting the renderer die after the sender check passes. That is the sole route
to this sender with a dead WebContents, and what makes its own guard
load-bearing.
Also deletes `src/main/ipc/pty-renderer-surface.ts`: zero importers repo-wide,
and its `isRendererGone` is exactly the weakened predicate. Its comment claims
the headless path "now passes null", but register-headless-runtime.ts:26 still
fakes `{ isDestroyed: () => true }` — #15172 rolled that back too. Adopting it
would reintroduce STA-5373 for real.
Verified: 17 tests across the two pty write suites; full src/main/ipc run 3156
pass (4 pre-existing @parcel/watcher failures in filesystem-watcher-real and
worktree-base-directory-poller, unrelated and failing identically on a
pristine tree); pnpm typecheck clean; oxlint clean.
* docs(pty): correct headless-path comments the #15172 rebase left stale
#15927 made `registerPtyHandlers` accept `BrowserWindow | null` so the headless
path could pass null instead of faking a window. #15172 reverted that signature
while splitting the PTY monolith, but the comments describing it survived, so
three of them now document an API that does not exist.
- orcad-entry.ts: the module docstring claimed orcad installs its controller via
`registerPtyHandlers(null, …)`. It uses `registerHeadlessPtyRuntime`, and null
is not accepted. It also claimed desktop surfaces are "declared rather than
faked" — true except for the renderer window, which is still faked.
- register-headless-runtime.ts: record why the fake is safe rather than leaving
`isDestroyed: () => true` looking arbitrary. It is load-bearing: every
renderer-liveness guard reads it and skips, so no send is attempted.
Also gives the fake a `webContents.isDestroyed`. The real guards check both, and
a missing method reads as "alive" — the same gap this PR fixes in the test fake.
No behavior change: the window-level check already short-circuits.
Verified: 45 tests across the liveness-guard, startup-barrier, management and
kill/exit suites; pnpm typecheck clean; oxlint clean.
* test(terminal): pin park/reveal re-subscribe on a shared multiplexer
Investigating STA-5098. Parking a mirrored remote tab closes its stream
while a sibling tab keeps the multiplexer alive, so the reveal
re-subscribes on an instance that already retired a stream.
This came back green, which exonerates the multiplexer as the cause of
STA-5098 — the wedge is above it. Kept as a contract guard; the header
says explicitly that it is not coverage for that ticket.
* fix(terminal): stop stale multiplex stream handles from swallowing input
A stream handle whose record was dropped (park close, or a reconnect that
clears the stream table) kept reporting success: sendFrame gates only on
socket readiness, never on stream membership. The host drops those frames
for an unknown stream id, so a revealed cold-parked remote pane looked
connected while the PTY never saw a byte and never painted (STA-5098).
Reporting success also defeated the transport's own recovery — it re-sends
input over terminal.send when the stream refuses it, which never ran.
Guard the three public senders on stream membership, the check close() and
setOutputPaused already use, and drain input queued behind a viewport claim
on every stream install rather than only a still-pending claim.
Withdraws the parked-reveal re-subscribe test: its fake host answered
Subscribe with an immediate snapshot, so it could not fail.
* chore(terminal): tighten the stale-stream comments
* Fix deleted remote worktree reappearing due to host ID mismatch
Paired clients and servers may use different spellings for execution hosts
(e.g., client 'runtime:env-1' vs server 'local'). Resolve these spellings
before worktree.rm and worktree.forceDeleteBranch to prevent failures and
orphaned worktrees.
* Validate runtime kind before comparing environment IDs
Ensure parsed host IDs are actually runtime environments before
accessing their environmentId property. Fixes incorrect host ID
matching during worktree cleanup that caused deleted remote
worktrees to reappear.
* Use hostId directly when same-ID surviving host exists
When a surviving host has the same ID as the deleted worktree's
original host, use the hostId directly instead of qualifying it
through the runtime call host. This prevents worktrees from
reappearing due to host ID mismatch.
---------
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Verified on native Windows awin at the exact PR head with Electron CDP/Playwright: the Claude sign-in console is visible, cancellation after console launch restores Add Account state, and the login process/PID/temp cleanup completes.
* fix(sidebar): stop reporting an interrupted agent as done (STA-5357)
An interrupted turn rendered on the worktree card and the tab glyph as `done` —
visually identical to a clean completion. A cancelled or dead turn read as
finished work, and with several agents it contributed no signal at all, so a
sibling that merely finished could hide it entirely.
`interrupted` is a flag clamped onto `done` at parse time, never its own state,
so the activity summary never even received it: its Pick was
{state, workingMode}, and the `done` branch swallowed it into hasLiveDone.
Adds the flag end to end — summary, both activity hooks, the resolver, the card
glyph and the tab badge — and slots it below permission and above working, which
is where SUMMARY_STATE_ORDER already ranked it for the text summary. The card and
the expanded agent list now agree.
The tab-bar test that asserted the old behavior is flipped rather than deleted:
it explicitly pinned `done` to mirror the card, and that premise is gone.
* fix(sidebar): rank interrupted below every live state
Corrects the precedence from the previous commit. Interrupted means the user
pressed Esc or Ctrl+C — a deliberate act, so they already know. It must be
DISTINGUISHABLE from done (that is the bug) without being LOUDER than anything
live.
Moved below done in all three ladders — resolveWorktreeStatus, the tab attention
badge, and SUMMARY_STATE_ORDER, which had also ranked it above working. That
matches smart-attention, which already classes an interrupted done as 4 (idle),
below both done and working; the card, the tab, the summary text and the sort now
agree instead of two of them disagreeing.
Tests re-pinned to the corrected order, including one of my own from the previous
commit that asserted a finished sibling must not mask an interrupted pane — it
should, and now does.
* fix(status): preserve interrupted outcomes in aggregates
* wip(workspace-cleanup): PR 3 blockers-become-labels, recovered from a dead worker
Uncommitted work recovered from a worker that died with 'Agent process stop was
requested but never confirmed'. Committed as-is to preserve it; NOT verified yet.
* Fix workspace cleanup review regressions
* fix(workspace-cleanup): drop filter chips for the safety fields this PR removes
The cleanup dialog crashed on every open. My merge of #15300 brought in the
applied-filter chips, which read `safety.tiers` and `safety.selectableOnly` --
the exact fields this PR deletes. Electron QA caught it; the chip derivation runs
on open, so it threw before anything rendered.
Removed the two chip branches, their formatter entries, and the now-dead 'tier'
chip-kind label. The test that swept every chip keeps its breadth by using
`safety.dismissed`, which survives.
Worth noting what did not catch this: typecheck flagged only the test file, not
the source, because the QA agent had already patched the source locally without
committing. A merge that compiles can still remove a field a caller reads at
runtime, and only opening the dialog proved it.
* feat(agent-status): add the pane agent identity resolver
Four ladders answer "which agent is in this pane" independently — the tab icon, the
open-tab/search occupant, the sidebar title rows, and the sidebar hook-row fallback — and they
disagree. Two consult the terminal title before the launch record, so a string Orca parsed
outranks a fact Orca owns.
resolvePaneAgentIdentity is the single ranked answer. Two rules, one of which is not an ordering:
1. Evidence is ranked by how directly it observes the process; a display title is last.
2. Each observation carries the runId of the agent run it describes. Evidence from a superseded
run is INELIGIBLE, not merely outranked.
Rule 2 is the part reordering could never supply. A completed hook naming A plus a title naming
B is either a bug (hook right, title stale) or a legitimate pane reclaim (title right) —
identical signals, opposite correct answers. Run ids make them different facts: in the bug both
belong to the current run; in the reclaim the hook belongs to a previous one. That pair ships as
a test asserting the two produce opposite answers from the same evidence.
Missing run ids are treated as eligible. Absence means "this peer does not publish them", not
"this is stale", so an old host's rows are never blanked. Sibling evidence is opt-in so
pane-scoped consumers cannot inherit another pane's agent.
No consumer imports this yet; each migrates separately with its own evidence.
Verified non-vacuous: reversing the authority order fails 10 of 18 assertions and removing the
run filter fails 3.
* fix(agent-status): close three resolver contract holes found in review
**Duplicate evidence of one source resolved by array order.** `eligible.find(...)` returned the
first match, so two live hooks naming different agents were settled by input position — the exact
property this resolver exists to remove. The original order-independence test only used DISTINCT
sources, so it never exercised it. Conflicting same-class evidence now returns null with
`ambiguousAt`, and does NOT fall through to a weaker source: letting a title answer whenever two
hooks disagree is worse than saying nothing.
**A bare numeric runId collided across authority restarts.** `incarnation` is a total order only
within one `authorityId` (agent-status-observation.ts states this), and the id is regenerated per
authority instance, so a restarted host counting from its own floor would report `1` and match an
unrelated live run 1. The run key now carries its authority, and evidence from a DIFFERENT
authority is treated as incomparable — kept, like an absent key — rather than as stale.
**Title stayed reachable by consumers that authorize writes.** Ranking it last makes misuse
unlikely; `minimumSource` makes it impossible. An action consumer passes `'launch'` and weaker
evidence is dropped before ranking, so routing or delivery cannot name a target from a parsed
string even by reordering its inputs. Display surfaces omit it and are unaffected.
Also restores the generic agent-vocabulary parameter, which lives on the routing branch and was
lost when this branch was rebased.
Each fix is mutation-verified: first-match restored fails 3, ignoring authority fails 1, dropping
the floor fails 2. The authority test was itself vacuous on the first attempt — both sides used
`incarnation: 1`, so a resolver ignoring authority still passed on the numeric compare. It now uses
differing incarnations.
The remaining review finding, that `process > launch` has no freshness bound, is NOT fixed here:
it needs an observation timestamp the evidence type does not yet carry. Recorded rather than
silently dropped.
* rm unused files
* remove unused files
* Refactor PTY IPC and add host environment paths
- Split PTY handlers out of inline baseline checks
- Rename local PTY shell provider for clarity
- Pass userDataPath and resourcesPath to host environment
* Establish PTY daemon identity before first await in spawn flow
Move identity setup, session ID minting, and hidden delivery state to
the beginning of preflight, ensuring these complete synchronously
before any awaited operations. Defer async operations like folder
workspace validation; add liveness tracking for SSH provider failures.
Refactor pane spawn reservation to prevent concurrent spawns from
creating duplicate providers.
* Add incarnationId tracking throughout PTY exit lifecycle
Track PTY incarnation IDs in exit messages sent to renderer, and add cause tracking for exit events. This enables proper lifecycle state management when PTYs can be respawned or have multiple concurrent instances. Also adds deadline support to process listing operations and stop-request tracking for better shutdown observability.
* Use fake timers in SFTP namespace tests for deterministic abort handling
Tests now use `vi.useFakeTimers()` to control time during abort scenarios,
advancing timers explicitly instead of waiting on real async delays. Ensures
more reliable test execution without flakiness from timing-dependent behavior.
* Fix PTY spawn lifecycle: handle concurrent races and cleanup abandoned a
Properly release Agent Teams leader handles when spawns are abandoned or fail,
restore provisional PTY sizes on reattachment, and settle concurrent spawn races
for the same pane. Add validation guards for destroyed renderers and improve
handler re-registration to reset delivery state before bridging a new window.
* Move PTY cleanup to localized error boundaries
Restore provisional PTY size when build-options fails and guard pre-allocated handle registration. This ensures cleanup happens at the point of error, not deferred to the general catch block.
* Replace Promise.resolve() with vi.waitFor in PTY claim test
Wait explicitly for the providerSpawn call to be made using vi.waitFor()
instead of relying on event-loop yielding. This makes the test more
deterministic and reduces flakiness from timing assumptions.
* Redact PTY IDs in pending data drop diagnostics
Prevent workspace paths embedded in session IDs from leaking through
diagnostic logs by using redactPtyIdForDiagnostics.
* Mark PTY exit events as observed by provider
Exit handlers now receive `providerExitObserved: true` to
distinguish definitive provider-witnessed exits from inferred
state changes. Preserves optional exit cause when present.
* Add defensive input validation to PTY IPC handlers
Validate that IPC arguments are present and the correct type before
passing them to handler logic. Uses optional chaining and type checks
to safely handle malformed requests from the renderer process.
* Replace direct Electron imports with PTY host bindings
Abstract app, ipcMain, and powerMonitor access through getter functions
to support multiple host environments and improve testability.
* Defend against transient PTY setup failures with state cleanup
Host-env setup failures now trigger cleanup of runtime-allocated PTY state. Cached PTY geometry is preserved after transient reattach failures but cleared when the provider reports the PTY exited before the spawn reply—preventing stale geometry from corrupting future operations. Error handling now distinguishes expired SSH sessions and early-exit conditions to preserve geometry appropriately.
Agent ids persist in automations and settings, so they outlive the
build that wrote them. Direct config lookups fail with unclear
"Cannot read properties of undefined" when an id becomes unknown.
This function validates the agent and throws a clear error message
naming the unknown id.
* 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.