* perf(renderer): index project host ownership on repo catalog refresh
mergeFetchedProjectCompatibilityForHost runs synchronously inside a zustand
set() on every repo catalog refresh and was O(projects x (setups + repos)):
getProjectHostIds / getExplicitProjectHostIds rescanned all setups and all
repos once per project, and mergePreviousProjectMetadata rebuilt a full
catalog repo key-set per project.
Precompute the indexes once and hand each helper only its own project's slice.
No ownership logic is reimplemented — the same resolvers run, just on
pre-sliced inputs, memoized per project object.
200 repos 1.1 ms -> 0.2 ms (5.2x)
600 repos 11.2 ms -> 0.7 ms (15.3x)
1200 repos 43.0 ms -> 1.6 ms (26.6x)
Verified against 4,000 randomized differential cases (duplicate repo ids,
dangling sourceRepoIds, empty-repoId and orphan setups, dropped setups,
local/SSH/runtime host mixes) comparing output element provenance and order
against the previous implementation, not just deep structure. Each of the
three index helpers was negative-controlled: breaking any one of them turns
the differential red.
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): keep the repo filter array identity across no-op refetches
Three fetch sites unconditionally reallocated filterRepoIds
(`s.filterRepoIds.filter(...)`) on every repo catalog refresh, even when
nothing was pruned. Six identity-sensitive subscribers select this array,
including App.tsx at the root, so every refresh woke the whole tree.
retainValidFilterRepoIds returns the input when every id is still valid;
`.every` short-circuits so the common case allocates nothing at all. It lives
in its own module rather than ui.ts: repos.ts does not import ui.ts and this
codebase has a documented circular-slice-import hazard.
The store type widens to `readonly string[]`, which takes three more
declaration edits (visible-worktrees, add-repo-skip-finalization, the setter).
PersistedUI.filterRepoIds deliberately stays `string[]`: main owns that array,
and widening it would fail the ui-state schema-parity assertion unless the zod
schema gained `.readonly()`, which Object.freeze()s every parsed `ui.set`
payload arriving from a paired client. Copy at the App.tsx IPC boundary
instead — one allocation per debounced 150ms persist, not per render.
Co-authored-by: Orca <help@stably.ai>
* perf(renderer): reconcile projects and host setups across no-op refetches
mergeFetchedProjectCompatibilityForHost always allocates — sourceRepoIds is
rebuilt per project and fetched setups arrive freshly cloned over IPC — so
`projects` and `projectHostSetups` lost both array and element identity on
every catalog refresh. #13770's identity work covered only projectGroups and
folderWorkspaces.
Reconcile both at the merge's single return site (five callers) against
`previous`, keyed on what each merge already dedups by: project.id, and
getProjectHostSetupOwnerKey for setups. setup.id is deliberately not the setup
key — the repo-derived fallback sets id = repo.id, so one repo on two hosts
yields two setups sharing an id, and keying on it would splice the wrong
host's routing metadata into a row.
filterSetupsForPrunedRepoRows had to stop returning `[...setups]`
unconditionally: three callers feed its result in as `previous`, so the
throwaway copy defeated the reconcile even though element identity survived.
Rather than add a third parallel deep-equality helper, generalize
reconcileFetchedRepos (#13744) into reconcileCatalogRows and delete repos.ts's
isPlainCatalogObject + areCatalogEntriesEqual (#13770). The two were the same
algorithm modulo the null-prototype branch; the survivor takes the union of
both prototype guards, which is strictly more permissive and so can only
reconcile more, never mask a change. repos-catalog-merge-identity.test.ts and
repo-identity-reconcile.test.ts stay green unmodified as the proof.
Element-level reuse means a consumer mutating a Project or ProjectHostSetup in
place would corrupt the previous render's object, so RepoSlice['projects'],
['projectHostSetups'] and ProjectHostSetupProjection widen to readonly. That
is the safety mechanism, not polish — it caught two real in-place accumulators
(ai-vault-scope-paths and the runtime-host purge in worktrees.ts), which now
annotate mutable locals per the repo's readonly precedent. No casts added.
Co-authored-by: Orca <help@stably.ai>
* test(renderer): cover repo catalog refresh identity
A dedicated file — repos.test.ts is already at the max-lines ceiling and these
are a distinct concern.
The fixture is production-shaped on purpose. A scalar-only repo reconciles even
when the structural compare is broken, which is how an earlier version of this
work shipped green while being completely inert; addedAt is pinned non-zero
because project-host-setup-projection falls back to `repo.addedAt || now`, so a
zero timestamp stamps Date.now() into every projection and nothing ever
reconciles. Every mocked fetch returns a structuredClone so identity can never
match by accident.
Every identity assertion was verified red against reverted source:
- drop reconcileCatalogRows from the merge return -> no-op-refetch identity and
per-element reuse fail
- filterSetupsForPrunedRepoRows back to `[...setups]` -> no-op-refetch identity
fails
- retainValidFilterRepoIds back to `.filter(...)` -> both filter-identity cases
fail
- areValuesEqual reduced to `a === b` -> all three reconcile cases fail
- areValuesEqual forced to `true` -> all six change-propagation cases fail
- setup key switched to setup.id -> the two-hosts-one-repo-id case fails
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Why: OMP ask maps to blocked sidebar state but should not trigger
native prompt rendering. Only Pi's ask_user_question needs the
interactivePrompt payload for live card display.
The omp agent-hook path could never reach the blocked state: the
ask-question gate in normalizePiCompatibleEvent required
agentType === 'pi', isAskUserQuestionTool only matched
ask_user_question/request_user_input (omp's tool is named 'ask'),
and extractPiToolFields only derived interactivePrompt for Pi.
An omp agent parked on its ask tool therefore showed 'working'
in the sidebar with no question card.
Widen all three gates to include omp so tool_call /
tool_execution_start for 'ask' maps to blocked and carries the
pending-question envelope for the live card.
* fix(terminal): repaint a recovered remote pane from the host's retained buffer
A remote-runtime pane that loses its stream re-subscribes, but the recovery
subscribe only carries new bytes. When the host's push snapshot is empty --
an idle pane, or one whose PTY has exited and is preserved with its buffer --
the transport dropped it and nothing re-armed the restore, so the pane stayed
blank until a visibility flip issued the tagged snapshot request. That is why
switching worktrees once "fixed" it.
Emit onStreamRecovered from the recovery subscribe path only, and mark the
hidden-output restore needed so the pane pulls the buffer the host still holds.
The initial subscribe is untouched: it already carries the host snapshot, and
re-arming there would cost every pane a redundant restore request on open.
* perf(terminal): avoid duplicate recovery snapshot replay
* fix(daemon): bound retained scrollback across sessions
The daemon retains ~5000 rows of xterm grid per live session with no aggregate
bound, so retention scales with an unbounded session count. A host owning 100+
terminals held ~1 GB of grid, was killed under system memory exhaustion, and took
every session it owned with it.
Split a fixed row budget across live sessions instead: full depth until the budget
binds, then an even share down to a floor that still restores the command which
produced the visible screen. Re-applied on create and reap, so depth returns to
survivors as terminals exit.
The emulator itself stays load-bearing (on-disk history is a projection of it), so
this bounds the aggregate rather than lowering the per-session default.
* fix(daemon): preserve scrollback budget invariants
* fix(daemon): budget scrollback by retained cells
* fix(daemon): retain full scrollback for viewed sessions, trim only parked LRU overflow
Replace the even-split budget: dividing a fixed row budget across all sessions
shallowed every terminal as the count grew, so a user with 60 terminals silently
lost half their reachable scrollback — depth degradation with no signal.
Retention now trims by attention, not arithmetic. Attached sessions always keep
full depth and never consume the cap. Parked sessions keep full depth up to a
cap of 24, least-recently-viewed evicted first down to 1000 rows — enough to
reattach with the recent command context on screen. A reattached session
returns to full depth for everything it emits afterward, and a freed slot
returns the newest trimmed session to deep retention.
Worst case is bounded at cap x full depth plus trimmed remainder, the same
memory class as the old budget, without ever shallowing a terminal the user is
looking at.
* fix(daemon): release attachments on transport drop so dead clients cannot pin full depth
The attached-client exemption made an attachment that outlives its transport a
permanent full-depth pin: protocol detach was logging-only, and neither control-
nor stream-socket loss removed the Session.attachedClients entry. Enough dead
attachments would silently rebuild the unbounded retention the LRU cap prevents.
Track the attach token per session, release exactly the dropped client's
attachments in one batched retention pass, implement protocol detach for real,
and cancel an attach whose client vanished mid-flight. Move session-exit
bookkeeping to the host reap hook — it previously lived in the per-attachment
exit callback, which only fired for unattached sessions BECAUSE of the leak, so
fixing the leak made an unattached session's exit invisible to idle shutdown.
Also drop the create-time double recency increment.
Co-developed with a review pass; transport-drop release is regression-tested
against the unfixed daemon (fails without, passes with).
* refactor(daemon): move retention application into the policy module
terminal-host.ts crossed the 300-line cap; the entry-building and depth
application belong with the selection policy anyway, leaving the host with
only the recency bookkeeping it uniquely owns.
* fix(daemon): remove stray QA artifacts and restore the emulator line budget
The previous commit accidentally swept QA screenshots and a report from the
repo root into the tree (root-directory-guard failure), and headless-emulator
had crept to 301 counted lines. Screenshots live on the PR via the attachments
CDN, never in the tree. The retained-scrollback trim moves to
headless-emulator-modes with the OSC-link shift as a pure helper.
* fix(daemon): retain a flat per-session scrollback window
Replace the LRU parked-session retention with what every peer terminal ships:
a flat, small per-session window in the durable host. Sessions retain 1000
rows — the generous end of what terminal products restore on a rebuild — and
deep scrolling on an open terminal remains the renderer's live buffer.
This deletes the dynamic-retention machinery entirely: no recency tracking, no
attached-exemption, no runtime trimming, no OSC-link shift on trim. The window
is set once at session creation. Worst case at the incident's 100+ sessions is
~100k rows of grid, versus ~500k before.
The bounded env override can tune the window within [100, 5000]; anything
outside falls back to the default, since an unbounded daemon is the failure
this window exists to prevent.
The v29->v30 history-handoff fixture now pins the old-daemon depth explicitly:
it plays an old binary whose sessions retained ~5000 rows, and the new flat
window would otherwise shrink its history below the chunked-seed threshold and
silently skip the transfer path the test exists to cover.
* docs(daemon): retire retention-era wording the flat window obsoleted
* fix(browser): never resurrect cookies after a failed import clear (STA-4061)
removeAllCookiesExcept rolled a partial clear back by rebuilding each removed
cookie with cookies.set. Electron's cookies.get() omits partitionKey and
cookies.set() drops it, so a non-Google CHIPS cookie that had been removed came
back as an ordinary unpartitioned cookie — silent, restart-proof auth-state
corruption on a code path that reports failure.
The exclusion comment already stated the invariant ("Electron cannot round-trip
partition identity"); it was only applied to the Google family. Extend it to the
rollback: a failed clear now stays failed, which is retryable, instead of
reconstructing cookies whose identity cannot be reproduced.
* test(browser): require partitioned cookie removal (STA-4061)
* rm git shim
Drops the terminal git/gh PATH wrapper and its settings toggle. Renames the no-marker shell-ready launch config after what it does.
Co-authored-by: Orca <help@stably.ai>
* rm git shim: clear stale state from older installs
Deletes the orphaned wrapper dir and scrubs inherited env/PATH, so a daemon that outlives the upgrade cannot keep seeding it. Drops a now-unread spawn option.
Co-authored-by: Orca <help@stably.ai>
* rm git shim: cover the daemon and headless paths
Scrub after the PATH prepends (they re-read process.env on the sparse daemon env) and run the cleanup above the serve branch so remote hosts get it too. Retry a locked removal; match PATH case-insensitively.
Co-authored-by: Orca <help@stably.ai>
* rm git shim: keep the scrub final
Refuse to re-prepend a legacy entry during agent-teams PATH promotion, which runs after the scrub. Cover the removal guard.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* perf: keep local repo events off remote runtimes
* test(renderer): assert parked-pane remount on the runtime-active repos:changed path
The local-slice refresh 13632 introduces still has to release panes parked on
an unhydrated host; the prior test stubbed the remount but never asserted it.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(daemon): let the connect prove a retired daemon, not the token read
The daemon unlinks its token on exit but leaves its socket, and the client read
that token as the first statement of doConnect. So a retired endpoint failed at
the open, producing an errno no recovery predicate could classify — every one of
them keys on syscall 'connect' — and the pane showed raw errno text.
Read the token totally instead. A dead endpoint now fails at the connect, where
isDaemonGoneError already authorizes a respawn and isDaemonEndpointGoneError
already explains itself; a live daemon whose token was rotated or removed
rejects the empty token as 'Invalid token' rather than being declared dead by a
missing file. checkDaemonHealth has always read the token this way.
* fix(daemon): require endpoint failure for respawn
* fix(worktrees): make hidden agent worktrees recoverable from the visibility dialog
A discovered agent scratch worktree (.claude/worktrees, .gsd-workspaces)
was a one-way door: the non-Orca visibility toggle never reveals scratch
by design (#9388), the inbox never announces it, and the dialog listed
nothing — so once hidden it was unreachable from the UI while sitting on
disk. The per-path import exception has outranked the hidden rule all
along; no surface offered it.
The dialog now refetches an authoritative list on open (a stale snapshot
must not read as 'nothing hidden'), lists hidden importable worktrees,
and offers a per-row Show wired to the existing inbox import action,
which already merges the import + baseline and rolls back on a failed
refresh. When the list cannot be read the dialog says so and offers a
retry instead of claiming the repo has nothing.
No new settings, schema, or persistence: recovery rides entirely on
importedExternalWorktreePaths, which every host already stores and
validates. The repo-wide toggle is untouched and still never reveals
scratch.
Fix#10324
Co-authored-by: Gldywn <14254051+Gldywn@users.noreply.github.com>
* fix(worktrees): honest scan states and race-safe row actions in the visibility dialog
- row Show stays disabled until the open-time authoritative scan settles;
a click mid-scan could join the pre-write refetch and read success off a
list computed before the import landed, a silent no-op on slow hosts
- checking/failed indicators follow the scan state alone, so a warm older
snapshot cannot present stale rows as current with no failure indication
- ownership-neutral section copy: non-scratch rows are listed too when the
repo-wide switch is off
* fix(worktrees): close the retry race window and clear stale failure state
- Try again is locked while a row import is in flight; a retry scan
started before the import's write lands can absorb the import's own
refetch and report success off a pre-import list
- a successful row import (which requires a successful authoritative
refetch) clears an earlier failed open-time scan instead of leaving a
contradictory alert over the refreshed list
- zh: 智能体 for agent (代理 reads as network proxy); polite live region
on the checking hint
* fix(worktrees): serialize visibility dialog actions
* fix(worktrees): clarify persistent visibility policy
* fix(worktrees): clarify hidden worktree list
* Explain hidden worktree defaults
* Show agent worktrees with Always show
* fix(worktrees): bound visibility dialog state and rendering
* fix(worktrees): preserve visibility mutation fences across dismissal
* fix(worktrees): scope visibility mutations by host
---------
Co-authored-by: Gldywn <14254051+Gldywn@users.noreply.github.com>
A runtime host emits one `reposChanged` client event for project-group and
folder-workspace mutations as well as repo mutations (createProjectGroup,
moveProjectToGroup, createFolderWorkspace, ... all call notifyReposChanged),
but the client's handler only refetched that host's repo catalog, worktrees,
and lineage. Group and folder-workspace rows for a remote runtime therefore
stayed stale for the rest of the session unless an unrelated *local*
`repos:changed` happened to fire the all-host sweep.
Refetch the environment's project group and folder workspace catalogs in the
same scheduled refresh. Groups are fetched first because folder workspaces
resolve their owning group from `projectGroups`. Both fetches are host-fenced
(`claimHostCatalogFence`), so they cannot clobber local or other runtimes'
rows, and the scheduler's existing debounce/min-interval still bounds them.
CI's code-quality plugins deny a second import of a module already imported in
the same file; the pre-commit hook runs a different oxlint config and did not
see it. Both failing checks were this: `verify` is a gate that only reported
static analysis, with typecheck, tests and both package jobs already green.
* fix(terminal): never wipe a painted pane for a snapshot that carries no image
A hidden-output restore that comes back as a success frame with an empty image
was still applied. The normal-buffer replay opens with \x1b[2J\x1b[3J\x1b[H, so
it cleared screen and scrollback and painted nothing in their place -- turning a
host that had nothing to say into a blank pane.
An empty image is the absence of an answer, not proof the pane is empty, so keep
the frame the user can still see. Retry and banner behavior are unchanged: an
empty image still counts as a successful, non-bannered recovery.
* test(terminal): prove imageless restore preserves painted buffer
Reattach failure left every record untouched, including the one answer that
settles it. A shell the relay watched exit kept a live lease, so every later
reconnect fanned out an attach for it — two attempts and a ten-second deadline
each — and the set only grew, in a file written to disk.
A proven exit now retires the record. `terminated`, not `expired`: expiry is the
state the recovery grant reads, and retiring a record must not also authorise a
replacement.
Everything else is unchanged and still leaves the pane detached and recoverable,
which is the point — a not-found is also what a replaced relay answers for shells
its predecessor is still running.
Retiring a stable pane's owner authorizes a replacement that carries the pane's
agent resume payload. Until this branch, an SSH reattach failure reached that
decision as SSH_SESSION_EXPIRED, which the gone-check did not match, so it never
fired for SSH. Retiring the not-found mapping changed that: the raw
`PTY "<id>" not found` now matches, so a replaced relay answering for a shell its
predecessor is still running would retire the owner, fabricate an exit, and
respawn with the resume payload — the double resume, reintroduced by the commit
meant to prevent it.
For an SSH pane the two proving answers are the relay's own observed exit and the
expiry the reattach mints only after verifying that proof names this shell. A
bare not-found is neither, and now propagates instead: the pane keeps its owner
and surfaces as disconnected.
Local and daemon ptys are unchanged — their provider owns its ptys, so absence
really is proof. The shutdown paths that also use the gone-check are untouched:
there, "not found" is the outcome being asked for.
The covering test drove the dangerous shape directly — bare not-found, retire,
respawn with `codex resume …`. It now drives the proof, and the bare not-found
case is pinned beside it.
Agent-status events fan out multiplicatively: every event pays an
O(worktrees x tabs) pane-routing scan and its own zustand publication, and
WorktreeList's unconditional sortEpoch subscription re-renders the whole
sidebar root on each one. A 256-pane reconnect replay meant 256 full sidebar
re-renders.
Fold a burst into one status publication (plus one generated-title and one
tab-title publication - three total, not 2N) via transactAgentStatuses, and
build the pane-routing index once per batch.
Coalescing is only safe for level-triggered consumers. Two edge-triggered
ones needed work:
- useAutomationDispatchEvents diffed entry.state, so a swallowed intermediate
`done` lost the run's completion output. It now walks the newly appended
stateHistory rows and reads the completed turn's output from the entry-level
lastCompletedAssistantMessage slot (one message per pane - putting it on
every history row would retain 20 transcripts per live status and reprise
the renderer OOM in #9872).
- pty-connection's native-Windows ConPTY reset compared `state` against a
closure-local previous value, so a coalesced done -> [working, done] never
re-fired RESET_TERMINAL_CURSOR_STYLE / RESET_KITTY_KEYBOARD_PROTOCOL and
left the pane with kitty keyboard protocol armed, corrupting plain input.
It now tracks the newest completed turn's start across stateHistory, which
also keeps same-turn `done` repaints from re-resetting.
The transaction commits as a MERGE patch of the keys the fold changed, not a
REPLACE of its snapshot: a batched action reaching another slice through
get() writes straight to the real store, and a REPLACE would silently revert
it. The shadowed `set` is typed without zustand's `replace` parameter so no
call site can reintroduce semantics the merge cannot express.
Deleting the relay's pane-identity comparison disarmed the recycled-id guard for
every client that has not upgraded. The relay is shared and host-side: one person
updating a host would leave their colleagues attaching by id alone, with nothing
checking it in either direction.
It is back as a FALLBACK, used only when the caller sends no incarnation. A
client that can name the shell is still fenced on that and still attaches after
moving tabs; a client that cannot gets the older, coarser check it was already
living with rather than none at all.
The two clauses inverted when it was deleted are restored, because they send pane
identity with no incarnation — the old-client shape, which should be refused. The
moved-pane property they used to contradict is pinned separately by the clause
that sends an incarnation, and reverting the override reddens it.
AGENTS.md asks for brief non-obvious comments, one line where possible. Several
of mine ran to five and eight lines of narrative on the relay's attach path,
which is the one place a reviewer most needs to scan the branching quickly. They
now say the same thing shorter.
Both gone-paths in attach() had independently spelled out the rule that proof
must name the caller's own shell. That duplication is what let the earlier fix
close one and miss the other, so the condition is now a single predicate both
ask — a change to the rule cannot reach one path and skip the other.
The renderer kept its own copy of SSH_SESSION_EXPIRED while this branch created
a shared home for exactly that token, whose whole reason for existing is that the
two copies once disagreed about an identity mismatch and the renderer respawned a
live shell. It imports the shared one now.
Not changed, after checking: the per-generation recovery budget still does not
reset on a successful reattach. Resetting it looks obviously right and is wrong —
a flapping PTY alternates failure and success, and a covering test drives exactly
that for forty rounds. The park cooldown already bounds the harm.
The lease field was declared, preserved on load, and read on reconnect — and
never written. Both spawn writers omitted it, so every lease carried only
"pty-N", the reconnect always took the no-expectation path, and the fence added
for it could not fire. The main reconnect went on attaching by id alone, which
is the replaced-relay case the fence exists for.
Worse than inert: a successful unfenced attach durably binds the pane to
whatever answered, so the wrong identity would be recorded and carried forward.
The host attests the identity at spawn and it was already in scope one line
above both writers.
The oracle for this had to pin the WRITE. Every other clause — the type, the
loader, the reader, the reconnect forwarding — was green throughout, because
each was correct in isolation; only nothing joined them. The test that covers
the spawn now seeds a host incarnation and requires it on the persisted lease,
and removing either writer reddens it.
resolveRelayGrokHome probes a login shell and folds every spawn failure into its
fallback, so the test was asserting the runner's scheduling latency rather than the
parser. On a loaded 16-way sharded CI box the 8s timeout expires, the probe falls
back, and the first case fails with '/home/orca/.grok' instead of '/srv/grok'. The
failing job's teardown terminated orphaned `sh` and `head` processes -- the probe's
own command, still live at job end.
Stub node:child_process so the parse, the shell-mode choice, and the fallback are
deterministic. Adds the probe-failure case, which is the branch that was silently
absorbing the flake, and pins -c for sh/dash versus -lc elsewhere.
There are two client attach routes and only one was fenced. The pane-driven
restore goes through reattachSshPtySession, which was sending the shell identity;
the relay session's own reattach — the one that reconnects EVERY known pty when a
relay comes back — goes through attachForReconnect, which sent nothing.
That is the wrong one to leave open. A relay coming back is exactly when ids have
been reissued from pty-1, so the main reconnect was the likeliest place to attach
somebody else's shell, and it was attaching by id alone.
It now sends the identity the lease recorded, which is what the lease field added
earlier was for; the two halves finally meet. Pane identity is still not sent —
only the shell's own — so a pane that moved tabs is unaffected. It also declares
exit-proof support, so a proven exit can reach the path that reattaches after a
host restart.
Callers with nothing extra to say keep the older call shape, so this does not
churn every reconnect assertion in the suite over trailing undefineds.
* fix(terminal): repaint an alt frame the fit never lands for
#13014 skips a too-wide alt frame on snapshot replay and relies on a post-fit
repaint. For a pane visible but permanently below the 48x24px fit floor,
readProposedTerminalCols is forever undefined, so skipIfTargetUnknown drops the
frame — and the only compensating repaint lives in a safe-fit continuation that
is failed rather than run for such a pane. The frame is lost until the user
widens the pane.
Adds a deferred repaint gated on display:none rather than on target-unknown,
preserving #13014's no-clip guarantee for a pane that later fits narrower.
Does NOT fix artificial-opencode-terminal-load.spec.ts:761. Two open concerns:
the repaint waits out the full 40-frame safe-fit retry budget (~1.3s) against
that spec's own 2s restore budget, and the cold-park reveal path at
pty-connection.ts:8222/8289 still has no deferral net.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): restore the deferred PTY input hop
Inlining forwardPtyInput into the onData handler dropped the
deps.deferPtyInput dispatch, leaving the callback and its type declared
and supplied but never called. Link-click mouse suppression silently
stopped withholding mouse-report frames, so a click in a mouse-tracking
TUI reached the PTY and the link-open fallback never fired.
Covers both branches, which had no test at all.
* fix(terminal): retain alt frames until final fit
* fix(terminal): preserve park intent across effect replay
* test(terminal): satisfy native static analysis
* fix(terminal): consume park intent after layout replay
* fix(terminal): re-read park intent instead of caching it per pane
useTerminalParkMountIntent cached park intent in a ref for the lifetime of
the component. The ref was never load-bearing: React finishes the render
phase before any effect runs, so StrictMode's passive-effect replay already
observes the same render-phase value. What makes the reveal work is reading
at render time rather than at connect time, which a plain call already does.
The ref did go stale. The pane lifecycle effect re-runs on [tabId, cwd] for
the same component instance, so a worktree path or startupCwd change
resupplied mountFollowsTerminalPark: true long after the park reveal, since
the post-replay consume clears the per-run ptyDeps copy and not the ref.
Each spurious run re-armed the pre-paint snapshot prefetch.
Also scope the post-replay consume behind a tested one-shot helper so splits
created after the restored-layout replay keep ordinary reconnect semantics,
and correct two comments that described the removed ref.
---------
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Enforcement lived only on the host. But the host is the party whose answer is in
question, and mixed versions are the normal state — a host that applies the rule
loosely, or not at all, could hand back an exit for a shell the pane never owned
and the client would replace a process that is still running.
The incarnation travels in the proof precisely so the asking side can check it.
A proof that cannot be tied to the shell this pane asked about is not proof, and
falls through to the disconnected pane. A pane that knows no incarnation cannot
verify anything, so it does not get to act on one either.
Both halves now apply the same rule independently, which is what makes it hold
across versions rather than only when both ends agree.
The remembered-exit route was fixed to require a present, matching expectation.
The liveness probe is the other route to the same claim, and it still answered
anyone: it fires when a pty EXISTS but its pid is gone, and under a given id a
replacement relay may hold a shell that is not the caller's at all. Its death is
then no evidence about a pane whose own shell may be running orphaned under the
relay this one replaced — and the reply authorizes replacing it.
Both routes now demand the same thing. The reap still happens either way, because
a dead shell should be cleared whoever asked; only the answer depends on whose it
was, and a caller who named a different shell is told exactly that.
Found by asking whether the guard ordering was right, after the first fix closed
only the half that had been reported.
Review found three ways the new proof could be believed too easily.
A caller that names no shell was still handed a remembered exit. That is the same
double resume in a new costume: relay A is killed leaving pty-1 alive and
orphaned, relay B mints its own pty-1 and THAT one exits, and a pane carrying no
recorded identity would be told its shell is gone — then replace a process still
running. The expectation must now be present AND match. Panes with nothing to
compare get the disconnected card, which is the direction that cannot lose work.
The proof is also gated on the client declaring it understands it. What the host
answers reaches clients that predate the reply, and one of those reads an
unrecognised attach error as neither death nor recovery — a stranded pane. Older
clients keep the wording they already act on; nothing is lost, because they could
not have used the proof anyway.
And the match is anchored on the whole grammar rather than the token, with the id
and incarnation percent-encoded. A substring test would let any text that merely
quotes the token stand in for the relay's own observation.
Two callers that key on "already gone" now also accept a proven exit, so the
liveness-probe reap does not burn a retry before reaching the same conclusion.
The oracle for the first of these was itself vacuous: `toThrow` with a negated
asymmetric matcher passes whenever anything throws at all. It now reads the
thrown message, and reverting the guard reddens it.
A bare not-found became SSH_SESSION_EXPIRED, and expiry authorizes a respawn.
But "the relay I asked cannot hand that id back" proves an exit only if that
relay is the one that minted it — a replaced relay answers exactly this for
shells still running under its predecessor. So after a relay restart, live
orphaned shells were read as dead: ownership cleared, lease expired, and the
agent resumed a second time onto a transcript its first process was still
writing to.
The relay now keeps what it actually observed. On a real exit, and on the
liveness probe that finds a pid gone, it remembers {code, incarnation} in a
bounded map and answers a later attach with SSH_PTY_EXITED instead of throwing
that knowledge away. That is first-hand, same-process evidence, and it is the
only answer that now maps to expiry.
A remembered exit for a DIFFERENT incarnation is not an answer about the caller's
shell, so a recycled id cannot report a stranger's death as its own. A crash
loses the map, which correctly reads as no knowledge rather than as death.
The carrier is the message text: the relay's error transport keeps only a message
and a numeric code, so a structured payload would not survive. It is deliberately
worded to avoid "not found", which older clients map to expiry.
Cost, stated plainly: against a relay too old to remember exits, a genuinely dead
shell is now unproven, so the pane offers the disconnected card instead of
replacing itself. That is the affordance's purpose, and it is the safe direction.
* fix(terminal): require agent identity for guarded sends (STA-4028)
Quarter-circle spinner glyphs (U+25D0-U+25D3) started classifying a title
as "working" in #13925, and that status alone authorized guarded agent
sends — which auto-submit with Enter — into any pane whose TUI animates
those generic progress frames.
Keep the glyphs as an activity signal, but stop treating a title whose
only agent evidence is a quarter circle as proof an agent owns the pane:
send authorization now falls through to recognized-agent identity in the
title or a recognized foreground process. Braille-spinner behavior is
unchanged.
* fix(terminal): preserve verified managed busy identity
* fix(terminal): bind busy identity to process incarnation
* chore(test): avoid duplicate terminal gate suite
A reset relay restarts its ids at pty-1, so an id alone can name somebody else's
shell: a pane still bound to pty-1 could attach to a shell another pane was
already driving, and the two would share keystrokes and output.
The guard that used to catch this compared the paneKey and tabId frozen at spawn.
It was removed for good reason — a pane moved to another tab was refused its own
live shell, and refused as "not found", which read as death and resumed the agent
a second time. So it traded a cross-attach for a double resume.
The incarnation is the shell's OWN identity, so it discriminates a recycled id
without caring where the pane lives: both failures close at once. The relay
refuses a mismatch and is deliberately not worded "not found" — that phrasing is
what the client maps to an expired session, and expiry authorizes a respawn onto
a shell this branch just proved is alive.
Only a host-attested expectation is sent. The locally synthesized stand-in is not
stable across reconnects and would refuse a pane its own shell. An older relay
ignores the field and an older client sends none, so both stay permissive.
The tab-keyed comparator is deleted rather than left dormant, and its suite is
inverted onto the new identity: a recycled id is still refused, and a pane that
moved tabs now attaches instead of being told its shell is gone.
A lease names a shell by ptyId, and ptyId alone cannot identify one: a replaced
relay restarts its ids at pty-1, so the same id can name somebody else's shell.
This adds the missing primitive — the incarnation the HOST attested — so a later
change can tell "my shell" from "a different shell wearing its id". No behaviour
changes yet; nothing reads the field.
Only host-attested values are stored. The provider synthesizes a stand-in when a
host reports none; that stand-in is first-write-wins and is dropped when provider
state resets, so the same live shell can present a different one after a
reconnect. Recording that would later read as a different shell and strand a live
pane, so it is refused, and the synthesizer now shares the prefix constant with
the predicate that rejects it.
Leases are rebuilt field by field on load, so the normalizer had to learn the
field too — adding it to the type alone drops it on every boot, which is how a
fence ships silently permitting everything. The oracle reddens on exactly that.
* fix(browser): guide Google sign-in after cookie import
* fix(browser): show runtime name in import guidance
* fix(browser): honor host display labels in import guidance
* Extract AutomationTimeField component with improved digit input
Moves time parsing and selection logic into a reusable component
with enhanced UX: arrow key stepping, auto-commit on 2-digit entry,
and improved validation. Refactors AutomationSchedulePicker to use
the new component, reducing code duplication.
* fix(renderer): stop mutating clockRef during AutomationTimeField render
Move the prop→ref sync into useEffect so React Doctor no longer fails static analysis on ref writes during render. patchTime still updates the ref immediately for concurrent digit commits.
* fix(renderer): improve AM/PM toggle accessibility in AutomationTimeField
- Aria-label now includes current period (AM/PM) so screen readers announce which is selected
- Add aria-pressed attribute to communicate toggle button state
- Move translation keys to AutomationTimeField namespace reflecting component extraction
* fix(ci): retry Electron install on HTTP/2 stream and Fetch 503 errors
CI shards failed before tests when GitHub release downloads refused HTTP/2
streams or returned Fetch Response.status 503, which were not classified as
transient and skipped retries.
* fix(ci): give Electron package downloads more retry headroom
GitHub release CDN still returned 503 after the short 3-attempt budget under
CI fan-out; extend the default backoff so install can recover.
While the card is up the pane keeps its durable binding on purpose, so a failed
replacement can put the card back. But main resolves a stable pane's owner from
that same binding: if the shell answers again between the failed reattach and the
click, the spawn adopts it and returns its id. Nothing was replaced, and clearing
the binding then unbound a shell that is live and attached to this very pane —
leaving it running with no durable record of what it owns.
Detected by identity: an id equal to the one we were replacing means adoption,
not replacement. Suppressing adoption outright would need a new spawn option
plumbed renderer -> transport -> IPC; the user-visible oddity that remains is
getting the old shell back rather than a new one, which is benign next to
orphaning it.
hasPty is three-state and says so: null means the provider has not listed the
host yet — ignorance, not death. The retry gate tested it with `!`, which reads
null and false alike, so it ended recovery on ignorance.
That state is not exotic: a reconnect builds a fresh provider with an empty set,
which is exactly the moment rejected frames arrive. The attempt was deleted and
no retry scheduled, and because the delivery token was already retired, no later
frame could revive it — the pane stayed dark. It also short-circuited the park
cooldown, since parkedAt was never set on an entry that no longer existed.
Only an explicit false stops us now. The proven-exit clause still pins that.
Why: the resolver refactor replaced the explicit `omp` branch with an
unconditional `return resolveOmpSessionFile(...)`. That is behaviour-identical
today, but a future member of NativeChatTranscriptAgent would silently scan
OMP's session root with a foreign session id, and the tail return stays
well-typed so nothing fails the build.
Restore the explicit branch and add a `satisfies never` check so adding an
agent is a compile error at this line instead of a wrong resolve at runtime.