Terminals froze app-wide several times daily, needing a manual pkill. libuv
unlinks the pathname a server bound to when it closes, with no ownership check,
so a departing daemon deleted whichever socket then sat at the canonical path —
including a live replacement's. The replacement kept hosting PTYs no client
could reach.
#12709 fixed that mechanism; this replaces the shape around it. Two invariants:
only a daemon publishing itself onto the canonical endpoint may mutate that
entry, and only by replacing one it has itself just proven dead; and no actor
removes a name it did not create.
Publish binds a private name, takes the canonical one with an exclusive link,
and on EEXIST proves the incumbent dead by connecting before replacing it in a
single rename. Only 'connected' means occupied and only refused/missing prove
death — a timeout proves nothing and declines. Deletes the claim sweeper, the
reclaim tail of killStaleDaemon, and three unfenced unlinkSync(socketPath)
calls in the launcher.
Measured: rename exposed no gap across 6,525 darwin / 8,004 linux probes of a
live handover, where unlink-then-link gapped on 200 of 200.
Verified on all three platforms: full suite on macOS and Linux, and daemon
restart e2e on a real windows-2022 host. Contract in src/main/daemon/AGENTS.md.
Collapses a 13-PR stack (#12289 -> ... -> #12553, plus #12560) onto `main`,
which had moved 253 commits ahead of the stack's base. The stack could not land
in order: its base was CONFLICTING/DIRTY and every PR above it was blocked.
21 conflicts. Seven were modify/delete — `main` editing files this work removes
— and each was checked for a fix worth carrying rather than resolved by reflex:
- #12462 (ConPTY Ctrl+Enter) is a real fix; `main`'s version now stands here,
and it is FULLER than the port made on the branch, which had to omit
`hasCtrlEnterCsiUAuthority` because that helper did not exist there
- #12442 (candidate-anchor layout perf) is moot: the anchor is removed entirely
- #12357, #12763 target machinery this work replaces
`main`'s deleted `onKeyUp` also held non-IME behaviour — the `optionKeyLocation`
reset — which this branch preserves via `installTerminalNativeInputListeners`.
Four stale fixtures, all this work's tests meeting changes `main` made while the
stack waited: the chat message shape moved from `text` to `blocks`, a button was
renamed to "Submit", `createBrowserSessionProfile` gained a third parameter, and
`updateWorktreeMeta` began returning a result the dialog reads `.ok` from.
Two files exceeded `max-lines` after the merge. Suppressions are forbidden here,
so both were reduced by extraction: the ConPTY Ctrl+Enter cases move to their own
spec, and the dialog's Enter handling moves to `worktree-meta-enter-handlers.ts`
— the two fields need different IME tiers, which is a second responsibility.
Full suite: 4538 files / 48145 tests pass. Four files fail and none is caused by
this merge — all are untouched by this branch, two pass in isolation
(parallelism), one is a known local-environment failure, and one is the
cross-version harness needing a checkout it cannot fetch here.
* Persist the Linear issue list view and per-workspace filters
Layout, grouping, ordering, columns, and attribute filters survive a restart.
Facet ids are workspace-scoped, so filters are kept per Linear workspace and the
active filter is *derived* from the selected workspace rather than reset by an
effect on switch — no ordering race can apply workspace A's facets to B, and an
unresolved or cross-workspace selection reads as unfiltered without erasing
anything.
A single shared catalog backs the renderer state, `TaskResumeState`, and the
strict `ui.set` schema, so a new view option cannot leave paired web/mobile/relay
clients rejecting the whole payload. Persisted values are normalized as untrusted
input: a corrupt preference or a single bad workspace entry is dropped without
taking the rest of the resume state with it.
Deriving the filter also removed the guard that used to make three neighbouring
behaviours safe, so they are re-scoped here:
- The primary-team facet reset now fires only on an in-workspace team change.
A workspace switch also changes the primary team, and clearing there wiped the
filter that had just been restored for the workspace being switched *to*.
- The list-read force check no longer fires on the session's first read, so a
restored filter serves warm cache instead of forcing a network round trip
behind a blocking spinner on every cold start.
- The filter dropdown derives "no single workspace" from `workspaceId` alone.
With an unresolved workspace it previously rendered the statically populated
priority section, whose clicks now have nowhere to be stored.
* Harden Linear view persistence against the failures review surfaced
Five issues, each found by a reviewer and reproduced before fixing:
- The filter dropdown's prune effect only ran when the user opened the popover,
because the filter was always empty at startup. Restoration makes it run on
mount, where `availableTeams` may still be the issue-scraped fallback rather
than the real fetch. Metadata complete for a *partial* team set passes every
R12 guard, so it pruned facets belonging to teams it simply hadn't seen — and
the write persisted, deleting them permanently. Gated on `teamsSettled`.
- `canonicalize` dedupes but enforces none of the transport bounds; only the
throwing parser does. So `serialize` could emit a 101-label filter that the
strict `ui.set` schema rejects, which drops the WHOLE taskResumeState — github,
jira and linear query included — on every subsequent write, since the renderer
resends the merged object each time. Added `boundLinearIssueAttributeFilter`
and a round-trip test built from serializer output rather than a literal, which
is the only kind that can catch renderer/schema drift.
- `linearIssueView` now carries `.catch(undefined)`: value tolerance stops at the
top level, so any future instance of the above is a cosmetic reset of the view
instead of silent loss of every other resume field.
- A workspace switch forced an uncached list read in both directions. The switch
is a later observation, so the null-baseline fix didn't cover it; the cache is
already workspace-keyed, making the force pure cost.
- Recency for the 20-workspace cap came from object key order, which is wrong
twice: re-filtering an existing workspace left it at the head (first evicted,
though just used), and an array-index-like key enumerates first regardless of
insertion, so a write could evict the very entry it added. Recency is now an
explicit ordered key list.
Also adds the nested parity assertion — the top-level one compares only
TaskResumeState's own keys, so a field added to LinearIssueViewResumeState stayed
invisible to it, which is exactly what `.strict()` rejects.
The wiring test was blind: deleting the hydration guard outright left all four
assertions green. The gate is now `shouldPersistLinearIssueView`, unit-tested
directly, and the file is renamed to the repo's `*-boundary.test.ts` convention
with an assertion that fails on that mutation.
* Log discarded Linear views and fix empty-filter serialization
- Schema now logs when linearIssueView is discarded, making validation failures visible
- Fixed serialization: filters that become empty after bounding are now omitted
- Added AssertNoExtraKeys type check for bidirectional schema/type parity
- Refactored view option catalogs to use canonical constants, preventing UI/schema drift
* Remove workspace persistence limits and LRU eviction
Stop capping persisted Linear workspace filters at 20 and evicting
least-recently-used workspaces. Simplify persistence to store all
workspace filters, gate persistence only on resume state application,
and remove tests that pinned implementation details. Users can now
persist filters for all their workspaces without arbitrary limits.
* add test for linear persistence
* Improve Linear filter test clarity and fix e2e overlay dismissal for CI
- Convert parameterized filter-pruning test to sequential assertions
- Fix dismissOverlayChrome to toggle overlay triggers instead of
force-clicking inert page elements in headless CI
* Prevent TaskPage from stealing Escape from Radix menus
- Add check to detect open Radix dropdown menus and popovers; return
early from Escape handler to respect their capture-phase ownership
- Update overlay dismissal in e2e tests to use keyboard.press('Escape'),
now that TaskPage no longer interferes
* The capture-phase Escape guard in TaskPage bailed out for open dropdown menus and popovers, but an open Radix Select matches none of those selectors: the shared SelectContent wrapper (src/renderer/src/components/ui/select.tsx:60) renders data-slot="select-content" and Radix gives its content role="listbox", not role="menu". So with a select open, the window-level capture handler ran first, called preventDefault() and closeTaskPage() — closing the whole task page instead of just the select. Added [data-slot="select-content"] to the guard, as suggested. I did not add [role="listbox"]; the reviewer explicitly notes it's too broad, and the data-slot selector covers every select rendered through the shared wrapper.
---------
Co-authored-by: m4air <m4air@MacBook-Air.localdomain>
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
* Extract GitHub task search commit debouncing into a hook
Move debounce logic from TaskPage into useGitHubTaskSearchCommit to
prevent excessive GitHub API calls on every keystroke. Uses a 750ms
idle window before committing search values. Add tests for the new hook.
* Keep task rows visible while typing search query
Removed premature row-hiding logic from the search input handler that was
triggering before debounced queries fire. The handler now only updates the
input state; debouncing and query timing are handled by a dedicated hook.
Added e2e test verifying search idles before fetching and Enter doesn't
double-fetch.
* Test that GitHub task search commits cancel on disable and unmount
Verify the useGitHubTaskSearchCommit hook properly cleans up pending
commits when disabled or when the component unmounts. This prevents
unnecessary GitHub API calls during normal user interaction.
Also fix e2e test instrumentation to find the active repo through the
worktree relationship rather than assuming the first repo with a path.
Fractional display scaling was #12164's last unexplored branch, and the reason
is worth recording: earlier attempts were BLOCKED, correctly, because they
proposed mutating the Windows display scale on a remote physical machine with no
console recovery. `--force-device-scale-factor` reaches the same renderer state
per process, so nothing outside the Electron instance changes and there is
nothing to restore.
The hypothesis was specific: `프프로로젝젝트트` is what a half-pixel cell boundary
could produce on a 2-column glyph, and nothing else in the suite varies dpr.
Measured at 1.25 and 1.5, both under WebGL: ink extents 25/21/16 with identical
ink groups, matching the scale-1 run. No doubling.
The arm self-certifies before asserting — if the flag does not take, the test
fails rather than silently measuring at dpr 1. That matters here: the sibling
spec's WebGL arm went two days reporting a missing GPU as a Korean rendering
defect precisely because a silent fallback looked like a result.
* feat(ai-vault): validate session-delete targets for single-file providers
Add the pure judgement layer for deleting an Agent Session History entry.
`validateAiVaultSessionDeleteTarget` decides whether a session may be removed:
the agent must be one of the nine providers where a single file is the whole
session (gemini, copilot, cursor, hermes, devin, openclaw, droid, pi, omp),
the host must be local, and the renderer-supplied path must resolve inside
that agent's own session roots and match its discovery predicate.
To keep the delete roots from drifting from the scanner's own roots, the
WSL-expansion helper moves to session-scanner-root-dirs.ts and the OpenClaw
root derivation + session predicate become shared helpers that
discoverOpenClawFiles itself consumes.
The result is path-only and never touches the filesystem; a returned
`allowed: true` still requires an lstat/realpath re-check in the executor
(S-2) before removal, documented as a caller contract on the result type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* feat(ai-vault): move a validated session transcript to the trash
Add the filesystem executor behind session deletion. It calls the S-1 path
validator, then performs the fs-side guards that validator documented it
could not: lstat().isFile() rejects a directory or symlink, and realpath is
re-fed through the validator so a regular file reached through a symlinked
parent that escapes the agent's roots is rejected too. Only then is the file
moved to the OS trash via shell.trashItem, with ENOENT treated as success so
a delete racing an external removal stays idempotent.
WSL UNC paths (no Recycle Bin) are delegated to tryDeleteWslUncPath before the
Windows-local fs guards, mirroring fs:deletePath. Any non-ENOENT error is
returned as a failure result rather than thrown, since IPC payloads are
untyped at runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* feat(ai-vault): delete-session IPC handler, preload bridge, cache invalidation
Wire the S-2 delete executor to an IPC endpoint and expose it on the preload
bridge. The renderer calls aiVault:deleteSession with { agent, filePath,
executionHostId }; the handler fetches WSL homes, delegates to the executor
(which re-validates and trashes), and on a real delete invalidates the caches
that could otherwise keep serving the deleted session.
Cache invalidation is generation-guarded: a scan already in flight when the
delete lands carries an older generation and must not write its pre-delete
result back into the cache. Without this, an in-flight scan resolving just
after the delete would resurrect the deleted session for the 15s TTL — and
force-refreshing the panel only masks it for the desktop, not for the paired
mobile client or runtime RPC that share the same cache module. Both the shared
local-scope cache and the desktop multi-host cache carry the guard, with
regression tests for the in-flight race.
The delete result type moves to shared/ai-vault-types.ts so the renderer can
import the same contract the executor returns. To keep ai-vault.ts within the
max-lines budget after adding the delete wiring, two cohesive pieces are
extracted to their own files: the delete orchestration (ai-vault-delete.ts)
and listAiVaultSubagentSessions (ai-vault-subagent-list.ts). The latter is the
only handler with no dependency on this module's private cache state, so it is
the one piece that moves verbatim without threading state through a seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* feat(ai-vault): renderer judgement for whether Delete is offered
Add the renderer counterpart to the main-side delete validator: given a
session, decide whether the row menu shows Delete enabled, or disabled with a
reason a tooltip can render. It reuses the shared deletable-agent set and
unsupported-reason map so the two sides can never disagree about which agents
are deletable, and reuses the existing local-host / synthetic-path renderer
helpers.
This is intentionally not a security boundary — it validates neither the path
root nor the file predicate. Those are the main process's untrusted-input
defense; the renderer only picks the affordance, and the main side re-checks
on delete regardless.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* docs(ai-vault): correct deletability parity claim; test multi-reason agent
The renderer deletability check runs host -> synthetic -> agent, while the
main validator runs agent -> host -> synthetic. The two layers agree only on
deletable-or-not (renderer-false is a subset of main-false), not on the reason
code a doubly-failing session carries. Document that explicitly instead of
implying the orders match, and add the antigravity case (two reason codes) so
the agentReasonCodes array shape is actually exercised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* feat(ai-vault): add Delete to the session row menu with a confirmation dialog
Wire the delete affordance into AI Vault. Both the dropdown and the context
menu gain a destructive Delete item; a session that can't be completely deleted
(remote host, synthetic OpenCode-SQLite path, or a directory/registry-backed
agent) shows the item disabled with a reason surfaced both as a tooltip and as
an aria-label so keyboard and screen-reader users learn why. Confirming opens a
dialog that names the session and states it will no longer be resumable from
the provider's own CLI, then calls the delete IPC and force-refreshes the list
for immediate feedback (the main side has already invalidated its caches).
The confirmation copy says the session "will be deleted" rather than "moved to
the trash": on Windows a WSL session is deleted with rm inside the distro (no
Recycle Bin), so promising recoverability would be a lie on that platform.
Deletability is computed once per row and shared by both menus so they can
never disagree. New pure logic — the reason-to-tooltip mapping (including the
multi-reason join) and the delete action hook's deleted/rejected/failed
branches — is covered by unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLggjSAjDnaWi3Y8U622i
* fix(ai-vault): state that Delete is unavailable without naming the cause
The disabled Delete item explained a provider's storage layout to the user
("Claude sessions can't be deleted here: stores sessions as a folder, not a
single file"). That is Orca's problem, not the reader's — the tooltip now says
which sessions are affected and stops there. The non-local-host string stays as
it was: it states scope, not a cause, and tells the user what would work.
The reason-code plumbing existed only to compose that tooltip, so
AI_VAULT_UNSUPPORTED_DELETE_REASONS, AiVaultUnsupportedDeleteReasonCode, and the
renderer result's agentReasonCodes field go with it. Why each agent is excluded
moves into the comment above AI_VAULT_DELETABLE_AGENTS, where a reader looking
up the deletable set will find it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
* feat(ai-vault): delete claude, rovo, and grok sessions by their directory
These three were excluded only because the delete unit was one file. Their
sessions are directories — claude keeps Task subagent transcripts in a sibling
`<uuid>/subagents/`, rovo and grok keep everything under `<sessionId>/` — and
nothing in them is shared with another session, so a directory-aware delete is
still a complete delete. Supported goes from 9 agents to 12; the four that
remain (antigravity, kimi, codex, opencode) are blocked by a registry or a
SQLite row, which no delete unit fixes.
Validation now returns an ordered removal plan instead of a single path. Each
removal carries the kind it must be on disk and the roots its realpath must
stay inside, so the executor's guard is the same shape for a file and for a
directory. Companions come first and the transcript last: the transcript is
what puts the row on screen, so a part-way failure leaves the row to retry
from rather than dropping it and stranding the rest on disk.
Claude's `session-env/<uuid>/` goes with the transcript — it holds that
session's generated shell exports and nothing else. Its sibling
`file-history/<uuid>/` deliberately does not: it is the rewind buffer holding
earlier versions of the user's own files, and retiring a session is no reason
to take away the only copy that can restore them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
* fix(ai-vault): remove a claude session's own directory, not just its subagents
Deleting a claude session trashed `<uuid>/subagents/` and left `<uuid>/` behind
as an empty directory — one per deleted session, accumulating under every
project. The directory is named after the transcript, so it belongs to that
session as a whole; take it rather than the one subdirectory inside it. Still
derived from the scanner's own subagents path, so the two cannot drift.
Reaching the parent means a degenerate stem now matters: `..jsonl` passes the
extension check and its stem is `.`, which would resolve the session directory
to the project directory holding every session. Reject it instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
* fix(ai-vault): keep a session row collapsed when a menu action is chosen
Radix portals the row's dropdown and context menus out of its DOM, but React
still bubbles their clicks back through the component tree, so every menu
selection also hit the row's own click handler and expanded it. The trigger
button already stopped propagation, which is why opening the menu looked fine
and only choosing an item misbehaved.
It shows worst on Delete: the row expands behind the confirm dialog, so
cancelling leaves the list rearranged under a dialog the user just backed out
of. Toggle details only for clicks that land in the row's own subtree — that
covers the context menu and any future portalled surface, not just this one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGuzChimmQ1dYX2raecrH7
* fix(ai-vault): harden the delete-confirmation flow against IPC rejection and mid-delete dismissal
Two robustness gaps flagged in review:
- handleConfirmDelete only branched on result.outcome. The main handler
resolves with a 'failed'/'rejected' outcome rather than throwing, but the
IPC invoke itself can still reject on a transport/serialization error, and
the caller fires it with `void`. That reject would surface as an unhandled
rejection with no toast. Catch it and show the same generic failure toast.
- handleDialogOpenChange cleared sessionPendingDelete on every open=false.
The Cancel button is disabled mid-delete, but Radix still fires its
Escape/outside-click/X close, which could dismiss an in-flight delete out
from under itself. Ignore close requests while deletingSession is true.
Both covered by regression tests (verified failing without the fix).
* fix(ai-vault): route WSL UNC directory removals through the WSL rm branch
Directory-shaped deletes (claude's subagents/session-env dirs, rovo/grok's
session dir) gated the WSL branch on kind === 'file', so on Windows a session
under a WSL distro home fell through to shell.trashItem — which can't trash a
WSL-volume item (no Recycle Bin) and throws, or worse is silently stranded when
the 9P filesystem's unreliable lstat false-reports ENOENT and the executor
treats that as success. Single-file deletes predate the directory kinds, so the
file-only gate was correct until directory removals were added.
tryDeleteWslUncPath already supports recursive removal; pass recursive for
directory removals so they take the same WSL rm path as files instead of
shell.trashItem. Covered by two regression tests (file: non-recursive,
directory: recursive), verified failing without the fix.
Also drops the internal ledger-ID references (D-*, S-*) from comments in these
two files; they pointed at a private design doc a reader can't see.
* docs(ai-vault): drop internal design-ledger IDs from shipped comments
Comments across the session-delete feature cited decision/slice IDs (D-1..D-7,
S-1..S-5) from a private design document. Those references are meaningless to
anyone reading the code without that doc, so remove the IDs while keeping the
reasoning each comment carried. No behavior change.
* test(ai-vault): e2e-cover the real on-disk session delete
The unit tests mock lstat/realpath/trashItem, so nothing proved the whole IPC
path actually removes files. This spec seeds sessions into the E2E harness's
isolated HOME and deletes them through window.api.aiVault.deleteSession:
- a single-file session (gemini): the transcript is gone from disk and drops
out of the list.
- a directory-shaped session (claude): the transcript, the <uuid>/ session
directory (subagents included, no empty shell left), and the session-env
companion are all gone, while the file-history rewind buffer is preserved.
Verified failing when the executor's removal is stubbed out. Runs on Linux CI.
* fix(ai-vault): address review findings on the session-delete flow
Three points raised in review:
- Disable Delete for a still-running session. resolveAiVaultSessionDeletability
now gates on liveState (working/blocked/waiting) last — an otherwise-deletable
session that is mid-run shows "wait for it to finish" instead of an enabled
Delete, so trashing a live agent's transcript can't drop writes it is still
appending. Unsupported/remote sessions keep their permanent reason.
- Realpath the roots, not just the target, in the executor's escape check. The
roots were only resolve()'d (text), so a session under a symlinked root
(~/.claude -> /Volumes/…) was falsely rejected; realpath each root (falling
back to its text form when it can't be resolved) before the membership check.
- Invalidate the parse cache with the raw filePath, not resolve(filePath). The
cache is keyed by the exact path the scanner discovered, so resolve() could
normalise it away from the stored key and miss. Drops the now-unused import.
Also moves AiVaultDeleteSessionArgs/Result out of ai-vault-types.ts (which the
upstream merge pushed over the max-lines limit) into the ai-vault-session-deletion
domain module they belong to, and updates importers.
Regression tests added for the live gate, the symlinked-root accept, and the
reason string; verified failing without each fix.
* fix(ai-vault): type the deleteSession preload bridge as its real result
The bridge declared Promise<unknown> while AiVaultApi.deleteSession promises
AiVaultDeleteSessionResult, so the preload object leaned on the api-types
declaration to stay honest instead of being checked against it.
Co-authored-by: Orca <help@stably.ai>
* refactor(ai-vault): tighten the session-delete code to house style
Comments across the delete flow explained HOW alongside WHY and ran to a dozen
lines; they now carry only the non-obvious reasoning. The excluded-agent
rationale, the caller contract on the validator, and the file-history carve-out
are kept — those are knowledge, not narration.
Also removes three duplications the feature introduced:
- AiVaultSessionDeleteExecutionResult was an alias for AiVaultDeleteSessionResult
whose comment pointed at a module the type no longer lives in.
- The synthetic-path predicate existed twice under near-identical names; the
renderer now re-exports the shared one it already had a sibling import of.
- The delete-failure toast was written out verbatim in both the rejected and
the thrown branch.
Co-authored-by: Orca <help@stably.ai>
* refactor(ai-vault): use a design-system dialog width and a stable row selector
The confirm dialog pinned an arbitrary sm:max-w-[440px]; every other dialog in
the right sidebar uses a scale token, and md (448px) covers the role.
The row-expand test selected the row by [draggable="true"], which stopped
naming the row when draggable moved to the title element upstream. It still
passed by bubbling, so the comment was the only thing wrong — now it selects
the title deliberately and says why the query is first-match (Radix's asChild
trigger repeats the subtree, so screen.get* sees duplicates).
Also types the e2e delete helper as AiVaultDeleteSessionResult instead of a
hand-written { outcome: string }, now that the preload bridge returns it.
Co-authored-by: Orca <help@stably.ai>
* refactor(ai-vault): consolidate agent sources and use system dialog
Discovery and deletion now share the same agent source definitions, eliminating the risk of them drifting apart. A single `AI_VAULT_AGENT_SOURCES` table declares each agent's root directories, file extensions, and acceptance predicates. Replaced the custom delete confirmation dialog with the system dialog, simplifying the delete action hook and removing boilerplate state management.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): defer metric option writes to unmeasurable panes
Writing fontSize/fontFamily/fontWeight/lineHeight makes xterm re-measure
cell size against the pane's current box. A hidden or mid-layout pane can
measure a wrong-but-nonzero size, which latches (hasValidSize) and mis-keys
the shared WebGL glyph atlas until a manual resize — the stuck variant of
the P0 bold/blurry-font reports.
Metric writes now land only on measurable panes; otherwise the latest
values park per-pane and flush on the next safe fit or reveal (with a refit
on the light tab-resume path, which otherwise skips fitting). Measurability
helpers move to pane-fit-measurability.ts to stay under the pane-fit.ts
line cap.
* fix(terminal): key metric deferral by terminal, not pane view
getPanes() returns a fresh toPublicPane() wrapper per call, so a
WeakMap keyed on ManagedPane never matched across call sites: deferred
metric options were dropped, not deferred. Key on pane.terminal, which
is carried by reference and dies with the pane.
Also from review:
- flushDeferredPaneMetricOptionsIfMeasurable checks the pending WeakMap
before the measurability probe, so the common no-deferral case costs
zero forced style/layout on every reveal.
- applyTerminalAppearance skips the apply (and the probe) when all five
values are already live and nothing is parked; any settings write
re-runs the pass over every mounted pane, and arming a no-op deferral
would trigger a refit on the next reveal.
- fitRevealedPane flushes first: its pixel/grid checks can both no-op
and return without fitting, stranding parked options.
- Font zoom folds its direct fontSize write into any pending deferral so
the flush inside safeFit cannot clobber the user's zoom.
Corrects comments that asserted a cell-size re-measure mechanism xterm
does not have: CharSizeService measures via OffscreenCanvas TextMetrics,
independent of the pane box, and only fontSize/fontFamily re-measure.
Test fixtures now allocate a fresh pane view per getPanes() call, which
is what production does and what hid the keying bug.
* fix(terminal): re-check the fit floor after a metric flush
performSafeFit evaluated the min cols/rows gate with the pre-flush cell
size, then flushed and fit unconditionally. A large font jump on a
narrow pane passes the gate at the old size and lands under it at the
new one, so fit() pinned the PTY to the tiny grid the floor exists to
reject. Re-check after a flush that actually landed.
The parked values still apply, so the pane is never stuck on stale
metrics; only the fit is skipped.
* fix(terminal): route a reveal metric flush through the stable fit
fitRevealedPane's new flush branch called safeFit directly, which is
exactly what the function's contract forbids on reveal: resumeRendering
has just re-attached WebGL, whose cell metrics transiently differ from
the DOM renderer's, so a raw fit can propose a one-column-off grid and
reflow — and xterm's wrap/unwrap is not a perfect inverse, leaving a
diff-painting inline TUI corrupted.
A landed flush leaves pixels unchanged with a diverged grid, the same
shape as a snapshot resize, so it takes the same steady-grid repair.
A real resize still fits synchronously, after the flush.
Reachable via window wake, which calls fitAllRevealedPanes with no
pre-flush loop.
* fix(terminal): gate metric writes on the pixel box, not the fit floor
canApplyPaneMetricOptions reused canMeasurePaneForFit, whose >=8 cols /
>=4 rows floor exists to stop a fit pinning the PTY to a sliver. But the
divider clamp is 50px, which clears the 48px pixel floor and proposes
~5 cols — so a pane dragged to the clamp deferred every font change and
never flushed: it never hides, and its box never changes, so no reveal
and no ResizeObserver entry ever arrives. It rendered a stale font until
widened, where pre-PR the write was unconditional.
Gate metric writes on display plus the pixel box only. Hidden panes and
the transient worktree-switch overlay are near-zero, so they still
defer — the deferral's purpose is unchanged. The cols/rows floor stays
on the fit, including the post-flush re-check in performSafeFit.
Apply and flush share the same predicate, so no "applies but never
flushes" state can open up.
* fix(terminal): flush heavy reveal metrics after WebGL resume
`check-changed-code-quality` reports unused eslint-disable directives as errors,
and these five sat above diagnostic `console.log` calls in IME test and spec
files where `no-console` is not enabled — so each suppressed nothing.
This is the second of the two static-analysis steps. `c51c6b5837e` fixed
"Enforce focused code-quality plugins" (duplicate test titles); this fixes
"Enforce changed-code quality". Both had been red on this branch since
2026-08-06, and I mistook the first for the whole job.
The diagnostic logs themselves are kept — they are what a failing IME arm prints
for a reader to inspect.
The #12164 probe runs two arms, webgl and dom, and closes by asserting the
active renderer is the requested one. That assertion is right for the dom arm —
it is what proves the pane actually left WebGL, without which the arm is
meaningless — but headless CI has no GPU, xterm falls back to DOM silently, and
the webgl arm then fails.
The failure reads as a Korean rendering defect and is not one, so the webgl arm
now skips with the active renderer named. The dom arm keeps the assertion
unchanged.
This is the third of three checks that have been red on this branch since
2026-08-06. `static analysis` and `verify` were fixed in c51c6b5837e; the CI log
shows this job as 1 failed / 1 passed, the pass being the dom arm.
Wake idle Run coordinators with durable orchestration mail pointers while keeping message payloads in the store until check consumes them. Preserve waiter, Cursor, restart, real Codex title, and PTY replacement behavior.\n\nPart of #12953.
TISCreateInputSourceList can return several entries for one input source
id. A third-party IME publishes a non-selectable parent alongside the
selectable mode, and taking sources.first can return the parent — after
which TISSelectInputSource fails with paramErr (-50) while the caller
reports success from the enable step.
Found with Qingg (com.aodaren.inputmethod.Qingg), which exposes exactly
that pair under one id. Its mode id equals the bundle id, so filtering by
name would not have helped; selectability is the discriminator.
Now filters on kTISPropertyInputSourceIsSelectCapable and falls back to
the old behaviour when nothing advertises it, so single-entry sources are
unaffected. Also enables every entry for the id rather than only the one
being selected: selecting a mode whose parent is still disabled fails the
same way.
Compile-checked, and selecting com.apple.keylayout.ABC still exits 0.
Unrelated to the enable path: on macOS 26.5.2 third-party IMEs are gated
behind a consent sheet in System Settings. TISEnableInputSource returns
noErr immediately regardless, and the enable only lands if that sheet is
answered while the requesting process is still alive.
* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated kitty pane
Ctrl+Enter emitted \x1b[13;5u unconditionally, so a pane that never
negotiated the kitty keyboard protocol (local Windows ConPTY, plain
shell) printed the escape verbatim into the prompt. Mirror the
Shift+Enter guard and fall back to the legacy CR every emulator sends
for this chord. Keeps the intercept, so IME commit ordering and the
single-send dedupe still apply.
Fixes#12329
Co-authored-by: Orca <help@stably.ai>
* test(e2e): negotiate kitty via PTY output in the Ctrl+Enter spec
The Ctrl+Enter gate reads the PTY-output kitty tracker, which
enableKittyKeyboardReporting never feeds (it writes straight into
xterm's parser), so the spec pressed the chord on a pane the policy
still saw as un-negotiated and got the CR fallback. Negotiate from the
application side like the neighbouring Shift+Enter spec, and reset the
flags afterwards for the serial suite.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): preserve trusted Ctrl+Enter routing
* fix(terminal): scope IME redispatch ownership
* fix(terminal): reject conflicting Ctrl+Enter evidence
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
#11504 reports macOS inserting ". " after a Hangul Space commit. This
sweeps six arms across both states of NSAutomaticPeriodSubstitutionEnabled,
reading the preference live per run rather than asserting a literal.
Two results worth having on record.
The reporter's stated trigger did not reproduce. Their words are "There is
no second press at all. One space is enough", but korean-single-space emits
zero insertText with the preference on or off. So does word-space-word-space.
Their timing does reproduce, with different content. korean-double-space and
korean-longer-word-double-space emit a delayed insertText at +122.5-122.7ms
after compositionend — squarely the reported +149ms — but the payload is a
space, never ". ". Consistent with the double-space rule seeing two slots
under ABC and only one under Korean, where the IME commit consumes the first.
The substitution itself is real and preference-bound: latin-double-space
gives "ab . " with the preference on and "ab " with it off, on one build
with the preference as the sole variable, reproduced across two runs.
That also refutes a claim in PR #11506, which states the substitution "is
enforced outside the renderer and never reproduces in dev builds, so changes
here must be verified against a packaged app". It reproduced in the dev build
twice and did not reproduce on the signed packaged app. That claim should not
be used as a verification gate.
Gated @headful behind ORCA_E2E_NATIVE_MACOS_PERIOD, same shape as the Korean
preedit spec, so it does not run in ordinary CI. Evidence is onData and DOM
only — the PTY-child reader aborted and no packaged-app arm was stable.
That commit said the landed form had never run and carried the probe's
values as literals. It has now run on real hardware: 1 passed, 9.1s, rc=0,
with the capture and log sealed under a verified hash manifest.
The teeth check was also run rather than reasoned about, and it changes
which assertion matters. Forcing the active overlay to max-width:0 with
overflow:hidden — invisible on screen — leaves the active class, the
textContent, display:block AND checkVisibility() all passing. Only
during.rect.width fails. checkVisibility() is not sufficient against this
defect; the bounding rect is the single load-bearing assertion, which the
docblock already said and this run confirms.
An earlier teeth attempt injected the CSS mid-run and tripped the
hasActiveClass poll instead, failing at the wrong assertion. It is
inconclusive and excluded from the seal rather than counted.
#11914 reports the composing text invisible until Space. Its c3 was recorded
as unobtainable, and the reason on file was wrong: the boundary IS
assertable, but not in happy-dom, which reports display:block in BOTH the
active and inactive states and zeros for every rect. A test there passes
with the defect present.
Captured on real hardware instead: hidden and 0x0 before, .active with
display:block, a 15.84x16 rect and checkVisibility() true while composing
그, hidden again after. 39 DOM events, 2 composition starts, onData
["한","그","\r"].
Two mechanism findings are carried in the setup because both are invisible
in the result and fatal if removed. The input source must be selected AFTER
the app takes focus — focusing resets it to ABC. And the IME must be warmed
until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m)
with no composition at all, which is indistinguishable from an IME that is
not installed. Two runs were voided on exactly that signature before the
warm-up was found.
The has229 and compositionStarts assertions exist to make such a run fail
loudly rather than pass as a clean negative.
Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The
final spec form has not itself been executed — the machine became
unavailable — so it carries the probe's measured values as literals rather
than a run of its own.
Five tracked macOS IME specs ran `swift .tmp/select-input-source.swift`, a
file that is gitignored and existed only on one machine. Anyone else
checking out the repo — or the same machine after .tmp is cleaned — could
not run them, and they are the capture drivers for the macOS rows that are
blocked waiting for exactly those runs.
Moves it to tests/e2e/ beside its callers. The chord spec now resolves it
from __dirname rather than reaching two levels up into .tmp.
Two defects in the echo latency probe.
It hooked onWriteParsed and onRender but never onData, so it measured
key->parse->render echo rather than the composer-vs-onData delta the latency rows
need. Adds a third hook feeding its own sample set.
And `event.key.length !== 1` silently dropped IME keystrokes: Pinyin and Cangjie
keydowns arrive as key:'Process' (length 7). Replayed over the captured corpus,
the old filter accepted 580 of 4137 Chinese IME keydowns -- it was discarding 80%
of them. The new filter matches the shape the owner itself branches on.
Attribution charges each onData to the latest keydown rather than a FIFO head,
because composing jamo emit no onData at all and a queue would credit a whole
composition to its first keystroke. The consumer now asserts sample count before
any percentile, so a zero-sample run cannot render as a flawless distribution.
Co-authored-by: Orca <help@stably.ai>
* fix: show full paths in quick open results
* refactor: use native file path tooltips
* fix: position file path tooltips
* refactor: share the cursor path tooltip with quick open
Co-authored-by: Orca <help@stably.ai>
* fix: let path tooltips run wider before wrapping
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
The byte expectations were transcribed from korean-ime-terminal-shift-enter-commit
:364/:383, which assert against onData -- a renderer boundary where the terminator
is CR. This spec reads the PTY child, where the tty has already converted CR to LF.
Names both forms per row rather than swapping the constant, so the conversion reads
as evidence that the capture reached past the renderer, as #11936 and #11951 record.
Ctrl+Enter's CSI-u sequence is unaffected and is identical at both boundaries.
Co-authored-by: Orca <help@stably.ai>
The existing korean-ime-terminal-shift-enter-commit spec synthesizes composition
over CDP: Input.imeSetComposition sets the preedit directly and Input.insertText
performs the commit. Asserting the IME produced events you injected yourself is
circular, so that spec cannot certify real-IME behaviour.
This arm selects 2-Set Korean via TIS, reads it back live, and injects through
System Events key codes, so the OS owns the preedit, the commit instant, and
isComposing. PTY byte expectations are preserved verbatim.
Covers 2 of the original 4 cases by design. The other two are the Windows/Linux
redispatch-before-keyup ordering, which macOS cannot produce and which cannot be
selected -- the OS decides it. Reintroducing synthesis to "restore coverage"
would reintroduce the circularity.
Co-authored-by: Orca <help@stably.ai>
Adds #11951's recorded Cangjie cancel shape to the existing cancellation suite,
which covered Pinyin and Sogou but not Cangjie. One keystroke then Backspace
arriving as deleteContentBackward with data: null, so the stale preedit is the
only thing a fallback could replay.
Verified against the historical pre-6cd944c62b3 bundle: the positive fails with
['尸'] where [] is expected, while the ordinary negative stays green.
Also fixes the Vietnamese spec, which asserted a TIS-space input-source ID
against getKeyboardInputSourceId(). Those two Orca APIs report the same source
in different namespaces — TIS nests it under VietnameseIM, the app API does not.
The resolver stays as an installation precondition; the assertion matches the
leaf.
Co-authored-by: Orca <help@stably.ai>
Four instruments recorded silence as success, so a void run scored as a clean
one:
- readTerminalImeBoundaryTrace returned an empty trace when the probe never
installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
silently downgrading a PTY assertion
Input sources now resolve by enumeration and name the near-matches on failure.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): clear stranded link hover tooltip
* fix(terminal): declare the tooltip reserve var where it resolves
--orca-terminal-link-tooltip-height was declared on .pane-manager-root, a
class no live element carries, so both .xterm-container height calc()s were
invalid at computed-value time and collapsed to height:auto — the element
FitAddon measures, making rows a fixed point.
Also isolate _clearCurrentLink() so a throwing provider leave() cannot skip
the cache invalidation, and bound the e2e gap assertion on both sides.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Mouse events posted with CGEventPostToPid reach the target app with no
window association, so AppKit never routes the press to a view: hover
states fire but the control is never activated, and the mouseUp is
dropped outright when posted back-to-back. Post click events to the HID
event tap instead (as keyboard synthesis already does), pace them, and
stamp mouseEventClickState so multi-clicks register.
Synthetic clicks now also report verification unverified/synthetic_input
from the helper itself, matching the other synthetic actions.
Stacked on the #12793 revert. Widens the passive-identity set from
{inactive} back to {active, done, inactive}, so the PR/check glyph
returns to the left status lane for workspaces that are actively being
worked, not just idle ones.
Tradeoff, deliberate: #12658 was not purely a regression. It also fixed
#8813, where an active workspace with branch identity and no PR showed
the grey branch glyph instead of the emerald Active dot. This revert
reintroduces that, and removes its e2e guard.
The left lane holds one glyph, so activity, branch identity, and review
status cannot all be shown. This picks review status.
`orca serve` publishes a ready graph under HEADLESS_RUNTIME_WINDOW_ID with no
BrowserWindow behind it. `shouldCreateInBackground` only degraded when the
create was renderer-backed, so any focus-requested create fell through to
getAuthoritativeWindow() and threw "No renderer window available" — leaving
`terminal create --focus` with no workaround on a remote server (#10333).
With a worktree selector and no renderer window, a background spawn is the only
usable path, so collapse the renderer-backed window check into a plain
"no window" check. That is the existing rendererBacked clause plus exactly the
missing focus case, and it drops the confusing `rendererWindow === null`
indirection (rendererWindow is already gated on rendererBacked).
Focus is not lost by the degrade: the spawned pane is still published to the
session-tab model and revealed with `activate: true`, which is how a paired
client learns about it. Mirrors the in-tree precedent in
runCreateMobileSessionTerminal.
Headed hosts are unaffected — the clause only fires when no window exists.
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
* Reorder source control to show staged changes first by default
Stages are closest to the commit action and most relevant to the
commit workflow. Merges untracked files into Changes visually while
preserving their Git area. Removes the untracked-first preset and
includes migration logic for existing user settings.
* Drop source control group order user preference
Remove the sourceControlGroupOrder setting and related UI, migrations, and persistence logic. The source control view now always displays sections in the order: staged changes, unstaged changes, untracked files.
* Reorder source control to show changes before staged
Aligns with the edit-stage-commit workflow by showing unstaged
changes (active edits) before staged changes (queued for commit).
- Replace 'Send answer' with 'Submit' for clarity and consistency
- Update all locale translations (en, es, ja, ko, zh)
- Remove fixed button width and add whitespace-nowrap for flexible sizing
- Update component and test references
PR 9501 shipped real-home routing for the host system default, and the
env override that could turn it back off was never a shipped control. The
managed-account half of the shared runtime mirror has been unreachable
since: every host account routes to its own self-contained CODEX_HOME
before that code runs.
Delete the flag module and its env plumbing plus the managed branch of
syncForCurrentSelection and the six helpers only it called. The three
lanes that still use the shared mirror -- Windows, a custom CODEX_HOME,
and a hook-lane gate that reports unusable -- are untouched, as are every
legacy migration and the WSL read-back helpers.
With a desktop client paired to a remote Orca runtime, terminal panes could report connected, writable, and `terminal.send` returning accepted — yet keystrokes never reached the agent. No error, no banner, no recovery; input silently vanished.
The ticket was really two bugs. The attach half was already fixed by #12589 (subscriber-driven daemon attach), confirmed by reproducing against current main. This fixes the remaining half: a write the host refuses had no way to tell anyone.
A capability-negotiated `WriteUnavailable` opcode carries that refusal back to the client, where it feeds the pane's pre-existing recovery hook. Capability gating matters because decoders reject unknown opcodes on desktop — and, worse, silently drop them on mobile — so the signal is negotiated in the subscribe handshake. Verified per direction: an old host strips the unknown Subscribe key, an old client omits it so the host never emits, and capability cannot be inherited across resubscribe.
Independent review then found the signal was being delivered and discarded: recovery demanded an authoritative liveness answer, and `pty:hasPty` had no `remote:` guard, so a paired pane's id fell through to the LOCAL provider, which returned false, and recovery bailed before remounting. Every test stopped at the transport boundary, so all of them passed while the pane stayed just as stuck. `pty:kill` already had exactly that guard.
The fix makes main answer LESS rather than claim more: `pty:hasPty` now returns unknown for a `remote:` id instead of a fabricated false, because main cannot speak for another host's PTY. The remount is then authorized by positive evidence — the process that owns the PTY stating it refused this specific write over a live negotiated connection — not by inference from silence. Local and app-SSH ids keep the probe, where a false genuinely means the shell died. Nothing is destroyed on this path; the remount rebuilds the renderer over the session it already had.
An end-to-end test now carries a rejected write from the host through to an actual remount, which no prior test did. A surviving mutant was also killed: the legacy-binary capability gate could previously be deleted with nothing turning red.
The reliability gate stays experimental — live paired journeys and mixed installed-release evidence remain uncollected. Fixes STA-2830.
Mixed versions are the normal state of the remote-server feature: users update clients and servers independently. Until now nothing tested that. Every cross-version claim was made by code reading plus unit tests with hand-written old/new shapes — enough to catch design problems, not enough to catch a real skew regression.
This runs the REAL protocol implementations from two builds against each other in one process: the actual host methods and RPC dispatcher on one side, the actual renderer multiplexer on the other, with a transport that reproduces the production asymmetry — each side decodes with its OWN codec and drops frames whose opcode it does not know. A frame survives only if the RECEIVING build understands it, which is what makes this level sufficient without launching two apps. The old side is a genuine checkout extracted from the release tag; the extracted client was confirmed to lack a symbol that exists only on main.
Journey: subscribe, first snapshot, input reaching the process, live output, hide/reveal snapshot, transport drop, resubscribe, input landing again — across old->new, new->old, and a current/current control. Every step ends on an observed-state barrier; no sleeps. The oracle asserts the recorded step list, the exact 16-frame named sequence, negotiated capabilities, the exact input the host wrote to the PTY, rendered content, and zero decoder-rejected frames. A host method the stub lacks is recorded by name and asserted empty, so a harness gap cannot masquerade as a wire break.
Detection is proven per violation shape, and it attributes each to the correct side: an unnegotiated opcode goes red only where a decoder would reject it, a removed published field goes red only where an old client consumes it, and a legal additive field stays green in all three pairings so the harness will not cry wolf on safe changes.
It also documents the three compatibility rules in docs/reference/remote-wire-compatibility.md, linked from AGENTS.md, since they previously existed only as folklore — notably that "decoders reject unknown opcodes" is true for the desktop decoder but NOT for mobile, which silently drops them.
Deliberately scoped: terminal stream only. The session-tab sync channel is not covered, nor agent-session publications, file/Git RPCs, mobile E2EE framing, or the relay transport. Two version points, so a regression introduced and reverted between them is invisible.
CI selection was verified rather than assumed — `vitest list` confirms 0 matches under the shard's exclude and 4 under the dedicated job — because a lane silently running zero tests is precisely how a host-side defect escaped CI earlier in this series. Closes STA-3469.
* Add host and project filtering to the worktree jump palette
Filter the search results by execution host (local, SSH, runtime) and project/repo, with a drill-down options menu, chips for active selections, and overflow hints for large result sets. Filters reset on open to prevent silently hiding results, and stale selections auto-prune. Caps rendered rows per section to prevent DOM bloat from single-character queries. Host badges appear when filtering to clarify which rows survived the cut.
* Add E2E test for worktree jump-palette host filtering
Tests filter interaction via keyboard, filter/project intersection, empty state when filters exclude all results, and ephemeral filter reset on modal close.
* Fix worktree jump-palette filter persistence and search UX
- Persist filter state when filter model changes to prevent dropped IDs from silently re-activating
- Fix result count to distinguish between query matches (all items) vs. empty list (capped sections)
- Improve filter field options: use state for scroller to handle unmount/remount, clamp highlight index to valid range, only reset on query/field change, not re-ranks
- Context-aware space key: allow toggle in listbox only, not in search input (preserves for typing)
- Replace generic "Clear field" translation with field-specific strings to preserve capitalization in non-English languages
* fix(cmd-j): reset filter highlight without prop-change effect
Derive the active option index from field/query identity instead of
resetting it in a useEffect so React Doctor and first-paint stay correct.
* fix static analysis issue
* fix(e2e): rename project entity in jump-palette filter seed
Filter options use project.displayName when a Project exists, so only
renaming the repo left the local option labeled with the path basename.
Reconnect could never recover a terminal pane whose host-side process was gone (host restarted, or the workspace was never opened there): recovery only polled the tab inventory, which can never create the surface it is waiting for, so Reconnect spun for ~60s and gave up permanently.
Verified with a deterministic reproduction: on main the recovery path issues 51 inventory polls and zero activations across both an automatic online trigger and a manual Reconnect click; with this change the pane re-materializes, rebinds and accepts input.
Review found and fixed three further defects beyond the original change:
- an activation answered with a stale ready handle left the loop polling forever instead of re-activating;
- a non-missing activation failure (e.g. an older host without the method) never fell back to inventory;
- host-side, activating a parked surface permanently deleted the host tab, because an already-absent persisted binding was read as a competing owner *after* the destructive retirement had already run.
Independent review confirmed by mutation testing that every production change is covered by a test that fails when it is reverted, that only an authoritative inventory can retire a pane, that the loop is bounded under every failure mode, and that the unknown-liveness guard (proven death required before retirement) is intact.
Fixes STA-3002.
* test(diff): repro for STA-3420 combined-diff invalidation freeze
Co-authored-by: Orca <help@stably.ai>
* Fix diff-view freeze when large diff invalidated by rebase writes
Staged-diff sections now reload in-place on external file changes instead of remounting every visible Monaco editor and bumping the virtualizer generation, which wedged the renderer during rebase bursts.
* test(diff): calibrate STA-3420 burst assertions against an idle baseline
The burst window's peak lag is dominated by a one-off stall from opening 8x15k-line
Monaco editors, which reproduces identically with invalidation disabled. Measure an
equal-length idle window first and assert p95, sample coverage, and lag relative to
that floor. Adds unit coverage for isUnchangedDiffSectionReload.
Co-authored-by: Orca <help@stably.ai>
* fix(diff): keep renderedIndicesRef pure during render
React Doctor blocks ref mutation during render; sync the on-screen
section set in a layout effect instead so static analysis can pass.
* Fix unchanged diff-section reload detection for truncated diffs
When a diff exceeds render limits, content is pruned to '' for memory.
The old check compared content equality, so limited reloads always
appeared changed, triggering unnecessary revalidation that froze the UI.
Compare render-limit metadata instead — it's the sole change signal
and full description of what the fallback banner displays.
Also calibrate STA-3420 e2e assertions relative to idle baseline for
machine independence instead of absolute thresholds.
* fix(diff): defer invalidation reloads for in-flight stale-token loads
When a diff section is invalidated while a large-diff load is in-flight:
- Don't delete the in-flight load from loadingIndicesRef, since a newer load may own it
- Bump the reload token but defer the reload if there's still an in-flight load
- Let the in-flight load settle first, then reschedule the reload at settle-time
- Prevents the freeze by avoiding race conditions that leave sections stuck loading
This fixes STA-3420 where rebase-driven invalidations could hang the diff view.
* test(diff): relax STA-3420 burst assertions to inclusive comparisons
Switch from strict inequality checks (toBeLessThan, toBeGreaterThan) to
inclusive variants (toBeLessThanOrEqual, toBeGreaterThanOrEqual) to allow
measurements landing exactly on the threshold boundaries.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(status-bar): invalidate the CLI session count on kill and restart
`pty:management:killOne` / `killAll` / `restart` tear sessions down via `adapter.shutdown()` and broadcast nothing — unlike `pty:kill`, which ends in `sendPtyExitToRenderer`. The status-bar count is an event-sourced cache, so killing sessions from Manage Sessions or "Kill all terminals" left the `>_ N` chip frozen until the popover was opened, which itself triggers a refresh.
> [!NOTE]
> The dual-source split described in the issue text was already fixed by merged #9387. This closes a *different* remaining invalidation gap that produces the same reported symptom.
Broadcast the teardown so the chip updates without needing the popover opened.
Fixes#8372
Co-authored-by: Orca <help@stably.ai>
* test(e2e): add recordable proof for status-bar-cli-session-count
Fails on origin/main, passes on this branch.
Test: drops after Manage Sessions kills a foreign daemon session, popover never opened
Co-authored-by: Orca <help@stably.ai>
* fix(status-bar): avoid duplicate inventory refresh after kill all
---------
Co-authored-by: Orca <help@stably.ai>
* fix(setup): stop caching an unreadable orca.yaml as "no setup script"
`checkRepoHooks` returned `{hasHooks:false, hooks:null, mayNeedUpdate:false}` with no `status` field when the SSH filesystem provider was unavailable, and inside a blanket catch for any read error. The renderer only bails on `status === 'error'`, so that status-less false negative was cached as an authoritative "no setup script" and the prompt stayed on screen.
Mirror the `hooks:check` IPC twin exactly: `status:'error'` for a missing provider, ENOENT-aware in the catch, `status:'ok'` on the folder-repo, binary, SSH-success and local branches.
Fixes#8752
Co-authored-by: Orca <help@stably.ai>
* test(e2e): add recordable proof for setup-script-prompt-false-negative
Fails on origin/main, passes on this branch.
Test: recovers from an unreadable orca.yaml instead of pinning the failed verdict
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): bail out of identity-equal terminal layout and cache-timer writes
setCacheTimerStartedAt and setTabLayout spread a fresh object and returned it
unconditionally, so every redundant call published a new AppState and ran every
zustand subscriber's selector across all mounted panes. Both have a real
redundant cadence: parked-terminal-byte-watcher writes a null cache timer on
each agent working/exit/stale-title transition, and TerminalPane re-persists an
identical layout on pane-title churn.
Extract the existing terminalLayoutEqual comparator out of web-session-tabs-sync
into a shared module and use it to gate the layout write, and dedupe the
remote-runtime layout IPC against the last snapshot pushed per tab.
* fix(renderer): retry failed remote pane layout pushes
* test(renderer): cover stale remote layout failures
* test(e2e): cover remote pane layout retry