* 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.
* 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
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.
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.
* 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>
* 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
* 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.
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.
Why: WSL filesystem access can stall when a distro hangs, and stalled
operations must degrade gracefully rather than misreport as "not found".
Distinguish gate refusals (timeout, capacity, unavailable) from actual
missing files so callers can retry or fall through to alternatives. Fail
fast when stuck I/O holds a permit to prevent caller pileup. Route-level
sequencing and waiter deadlines keep one stalled distro from blocking
others.
Splits run-idle-cpu-benchmark.mjs into a scale fixture, an in-page timing
probe, and process sampling, and records a measured origin/main baseline so
the agent-status batching slice has an auditable before.
The agent-status write workload is not included: it needs setAgentStatuses,
so it lands with the store slice.
* fix(agents): detect a live OpenCode pane from its native OC | session title
OpenCode publishes `OC | <session>` as its OSC title, which carries no
agent-name token. detectAgentStatusFromTitle gates status on a whole-token
name match, so it returned null and every status consumer read a live
OpenCode pane as a plain shell: no "Send notes to" entry, no title-derived
sidebar row, and a title that the runtime's agent-presence check scored as
neutral. Identity already resolved (getAgentLabel returns OpenCode); only
activity was missing.
Treat the native marker as a live idle agent, placed after the spinner and
glyph checks so the decorated frames pinned by #8940 keep their status, and
accept it in the send-readiness gate the way Claude's U+2733 prefix is
accepted -- only a running OpenCode TUI ever publishes it.
* fix(agents): require spaced `OC | ` marker for native OpenCode detection
Unspaced pipes like `OC|Build` match other tools and would mistakenly
route non-OpenCode panes as send targets. Enforce literal ` | ` as
OpenCode emits it. Also clarify that only spinner decorations carry
working status, not keywords in the session summary.
* fix(agents): require OpenCode foreground process to validate native mark
OpenCode's native `OC | ` title marker now requires an active OpenCode process
to authorize agent sends, preventing false detection when the marker is left on
shell prompts. Extends wrapper prefix matching (ssh, tmux, etc.) and spinner
glyph support. Adds permission-prompt blocking signals for guarded writes.
promptCacheTtlMs was a third store subscription for a field the card
already reads via foundation.settings; derive it instead.
Does NOT bundle the 26 foundation selectors as originally planned.
Measured on this store with React (150 cards, 400 unrelated writes,
26 keys): 26 separate useAppStore hooks 100.7ms; one useShallow bundle
402.5ms (4.0x worse); one bundle with a non-allocating keyed compare
141.5ms (1.4x worse). zustand v5 shallow() rebuilds two Maps from
Object.entries per compare, and a bundle must still build the whole
projection every notification, so collapsing cheap selectors only
trades away useSyncExternalStore dispatches that are cheaper than the
object it allocates. Listener count would have improved 26 -> 1 while
the hot path got slower.
docs/design/agent-status-over-ssh.md was cited from ~10 source files but
does not exist in the repo. Replace each pointer with the invariant the
code actually relies on so the knowledge survives without the doc.
Renderer-side citations (useIpcEvents.ts, agent-status-types.ts) are left
for the concurrent batching change that owns those files.
Co-authored-by: Orca <help@stably.ai>