Skip to content

[upstream #10639] fix(native-chat): resolve WSL Codex transcripts for Chat UI toggle - #91

Open
innocarpe wants to merge 341 commits into
mainfrom
fix/wsl-codex-chat-cli-switch
Open

innocarpe wants to merge 341 commits into
mainfrom
fix/wsl-codex-chat-cli-switch

Conversation

@innocarpe

Copy link
Copy Markdown
Owner

Portfolio mirror of my contribution to upstream stablyai/orca.
Exhibition only — the real review/merge target is upstream.

Upstream

Summary

Summary - Map guest Linux Codex transcript paths to host-readable \\wsl.localhost\… so Chat UI can load WSL Codex sessions. - Search WSL managed + system Codex session roots; wire transcript watch to the translated path. - Also includes false agent-title exit suppress so Cha

Note

  • Do not merge this into innocarpe/orca main until the upstream PR is merged.
  • After upstream merges: sync fork from upstream, then close this mirror PR.
  • This open PR exists so visitors see in-flight work on this fork's Pull requests tab.

AmethystLiang and others added 30 commits July 23, 2026 12:09
…on (stablyai#10145)

* fix(mobile): keep name input continuous during source picker transition

Refactor create-workspace flow to maintain input continuity: single TextInput
morphs from form slot to docked position above the keyboard while results
reflow above it. Prevents field unmount/remount and keeps user focus on the
input as it transitions. Add keyboard inset resolution utilities and fill-mode
bottom drawer support for stable frame heights during result reflowing.

* fix(mobile): keep name input continuous during source picker transition

Extract drawer navigation into a custom hook and add `interactive` prop to
BottomDrawer to pin the form sheet under the source picker. The form stays
visible and laid out but non-interactive during source selection, revealing
its original height when the picker dismisses. Fix bottom-drawer height
calculation to never exceed the space above the keyboard.

* fix(mobile): reset drawer state when create-workspace modal closes

Prevent a queued transition timer from landing after the modal closes and
leaving stale drawer/pin state for the next open.

* fix(mobile): disable source field focus during drawer transitions

Prevent the workspace-name field from reopening the source picker when
the drawer closes. The drawer's dismiss restores focus to the field,
which re-fires onFocus and reopens the drawer. Gate the field's
focusability with an interactive prop so it only accepts focus when
the form sheet is active.
…R loads (stablyai#10008)

* fix(checks): stop the transient "PR already exists" card before the PR loads

The Checks panel briefly showed the terminal "Pull request already exists"
card (Open Review only) on a cold mount, and only rendered the real PR panel
after a manual refresh.

Root cause (regression from stablyai#9428): the eligibility probe resolves
`existing_review` from cache faster than the renderable PR hydrates, and the
new exhaustive selector ranks `existing_review` as a terminal safety blocker
above every loading state. So during the fetch window the panel settled on the
terminal card instead of a self-updating loading state. The auto-fetch also
used a lazy SWR priority, so the PR landed slowly unless the user forced a
foreground refresh.

Fix:
- Selector: while a PR fetch is queued/in-flight and we have positive-but-not-
  yet-renderable review evidence (existing_review or positive_unresolved), show
  the self-updating "Checking status" state instead of the terminal card. It
  flips to the review once details land. A concurrent branch blocker still owns
  the copy.
- Fetch: when a review is known to exist but no renderable PR is cached, escalate
  the auto-fetch to a foreground `active`/80 request (matching manual refresh) so
  it resolves promptly.

Reuses existing i18n keys; adds unit coverage for both parts.

* fix(checks): bound unresolved review foreground refreshes

* fix(types): exclude absent stat overloads

* fix(checks): preserve refresh precedence and budget

* fix(checks): avoid redundant foreground PR refreshes

Fold unresolved-review promotion into the existing refresh effect so cached evidence produces one dispatch instead of an SWR plus active pair. Keep non-GitHub evidence on the budgeted path to avoid forced GitHub work for GitLab and other providers.

* fix(checks): scope foreground refreshes by provider
…lyai#9984)

* feat(diagnostics): name what grew in renderer OOM crash reports

Renderer OOMs are the dominant crash class (heap pinned at the ~3.5GB V8
ceiling in crash-channel reports) but renderer_memory breadcrumbs carry only
heap totals, so reports say "it grew" without saying what.

Add a one-shot renderer_memory_highwater breadcrumb at 60%/80% of the heap
limit carrying leak-diagnosis counts: DOM node census, terminal element
count, and per-subsystem counts from a new contributor registry. The store
registers the first contributor, reporting its 20 largest top-level
collections. Counts only, capped per contributor; zero work on the 60s
sample path while below threshold.

* fix(diagnostics): retain renderer heap profiles

* test(agent-status): remove subagent row order race

* fix(diagnostics): bound aggregate heap profile work

* fix(diagnostics): bound heap profile contributor calls

* fix(types): avoid overloaded stat return inference

* fix(diagnostics): fully bound heap profile registry

* chore(skills): refresh release snapshot manifest

* fix(diagnostics): retain browser counts in heap profiles
Improves clarity and consistency throughout mobile pairing, settings,
and permission descriptions. Makes the distinction from Tailscale
more explicit where relevant. Updates all translated locales.
* fix(mobile): show attached images in native (rich) chat

Attaching an image in the mobile native chat did nothing visible — it reused
the terminal attach flow, which pastes a bracketed host path into the hidden
terminal, so there was no composer preview and nothing in the transcript.

Give native chat the desktop model instead:
- pick + upload shows a removable thumbnail chip in the composer (no early paste)
- on submit, images ride along: Ctrl+U clear -> bracketed paste(s) -> settle ->
  text + Enter (idempotent on retry)
- the optimistic echo carries the local preview URIs and the message renderer
  draws image-ref blocks as real thumbnails when the URI is loadable, so the
  sent photo appears in the conversation immediately
- image-only echoes reconcile by ordinal against user turns after their tail
  (ignores agent replies / paginated history / the 'unknown' ack-loss path)

Terminal chat attach is unchanged (both flows consolidated behind
useMobileSessionImageAttachments). Adds unit coverage for pick+upload, the
ride-along byte order, chip render/remove, and echo reconciliation.

* test(mobile): interactive native-chat image proof (real hooks, click-driven)

Replace the hand-fed component render with an interactive harness that mounts
the real MobileNativeChatComposer/Message + useMobileNativeChatImageAttachments +
drafts under react-native-web and drives the actual flow via clicks. Only the two
OS boundaries are faked: the photo picker and the paired-host RPC socket.

Screenshots (mobile/docs/native-chat-image-attachment/) are produced by real
clicks, not props:
- attach -> real upload pipeline -> chip appears, nothing pasted yet
- send -> real ride-along emits Ctrl+U clear, bracketed image paste, text+Enter
  (shown in the live byte trace) and the sent bubble renders the photo thumbnail

* fix(mobile): scope native-chat image attachments by active tab

Images are now scoped to the tab that initiated the pick, so switching tabs
during upload cannot ride an image into another terminal. Chips stay with
their original tab, and only the active scope's images send with text.
Improved error handling with user-facing toast messages for disconnection
and send failures.

* test(mobile): add image attachment tab-scoping and error tests

Add comprehensive test coverage for tab-scoped attachment behavior,
error handling when transport fails or lease is gated, and edge cases
like attaching images during an in-flight send. Extract baseArgs and
update helpers to reduce boilerplate across test cases.

* fix(mobile): show attached images in native rich chat

Images attached in the mobile native (rich) chat now display as:
- Removable composer chips while composing
- Thumbnails in the sent user bubble after sending (desktop parity)

Implements proper image echo reconciliation by distinguishing
image-source marker turns from text echoes, so an image send isn't
cleared by an unrelated text echo. Adds scope isolation to prevent
chips and drafts from leaking between tabs, and detects tab switches
during the image-paste settle window to abort the send.

Fixes Android tap-target positioning for the image removal badge and
clears stale terminal input after failed pastes to avoid gluing
fragments onto the next message.

* rm stubs

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
…tablyai#10215)

* feat(source-control): show current branch without evicting Create PR

stablyai#9787 added the current-branch identity to the Source Control header but
did it by replacing the Create PR button's toolbar slot, so stablyai#10032 reverted
the whole thing. Create PR is the primary entry point into the
stage→commit→push→generate-PR flow, so it can't be traded away.

Restore the branch identity as its own row above the toolbar so it coexists
with the Create PR button (Option 2 layout). Detached HEAD renders in the
same identity row via DetachedHeadBadge (re-adds its tabIndex/aria-label),
replacing the separate below-toolbar badge row.

* style(source-control): match branch identity text to the 'vs main' base ref

Same font-mono / 10.5px / foreground-90 / underline treatment so the current
branch name reads visually consistent with the base ref in the context row.

* style(source-control): drop branch identity underline

Keeps the 'vs main' font/size/color match but no underline — the label isn't
clickable, so the underline read as a false affordance.

* test(source-control): harden identity-row detached + no-identity contracts

Add a stable data-testid to the identity row so the no-identity case proves no
row renders, and assert the detached badge's accessible label + focusability.
Addresses CodeRabbit review on stablyai#10215.
…resh (stablyai#10225)

Switching, adding, re-authing, or removing a Codex account awaited
refreshForCodexAccountChange before resolving the IPC call. Since the
per-account CODEX_HOME rollout (stablyai#9501) that probe runs against a cold
home (10s RPC + 15s PTY fallback, 25s WSL) and can queue behind an
in-flight global usage fetch, so the switcher sat unresponsive for tens
of seconds and a fresh login looked stuck on the loading screen.

Worse, in addAccount the awaited refresh sat inside the login cleanup
try/catch: a refresh rejection after the account was durably committed
deleted the just-created managed home, leaving a registered account
with no home ("account never connects").

Run the refresh as best-effort background work instead. Its synchronous
prefix still flips usage to "fetching" before the first await, so the
switcher updates instantly and usage fills in via the normal
rate-limit state pushes; a probe failure is logged and can never
trigger managed-home cleanup.

Fixes stablyai#10141
Co-authored-by: kazu-42 <91968271+kazu-42@users.noreply.github.com>
…eed (stablyai#9387)

* fix(status-bar): seed Resource Manager closed badge from daemon inventory

The closed chip counted tab/layout PTY wake hints (inflating terminal
count) and never fetched memory until the popover opened (showing "—").
Cache listSessions for the badge, seed memory on session ready, and drop
the wake-hint closed selector.

* fix(status-bar): update session inventory ref in an effect

CodeRabbit/React Doctor flagged mutating sessionInventoryRef during
render; keep the write in useEffect after commit.

* fix(status-bar): follow daemon session lifecycle events

* perf(status-bar): skip inventory scans for known PTYs

* perf(status-bar): bound resource inventory refreshes

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
…ablyai#10213)

* Reapply "Preserve Codex [tui] settings across managed CODEX_HOME remirrors (stablyai#9475)" (stablyai#10085)

This reverts commit 09756df.

* fix(codex): anchor settings baseline migration
…#9446)

* perf(startup): defer full worktree scan until after session restore

* fix(startup): normalize worktree recovery session keys

* fix(startup): preserve persisted workspace hydration

* fix(startup): keep SSH repos in pre-hydration worktree fetch

Round 1 review fixes for the selective pre-hydration fetch:

- The prefetch filter gated on LOCAL_EXECUTION_HOST_ID, which excluded
  SSH repos. Old full-scan code populated worktreesByRepo for disconnected
  SSH repos via the local metadata fallback (no network), so hydrateTabsSession
  / editor / browser preserved their chrome. Excluding SSH dropped that chrome
  at hydration and setHydrationSucceeded made the loss durable. Include SSH by
  excluding only runtime-owned repos (which hydrate via placeholders instead).
- collectWorktreeHydrationRepoIdsFromSession never collected activeRepoId, so a
  repo referenced only by active-repo state (no active worktree, no tabs) was
  not prefetched, breaking hydrateWorkspaceSession's main-worktree fallback.

* fix(startup): re-prune visit timestamps after deferred full worktree scan

Round 2 review fix. The selective pre-hydration fetch only populates
session-referenced repos, so the one-shot pruneLastVisitedTimestamps at
startup no longer sees non-session repos and keeps their deleted-worktree
entries (the prune defers unloaded repos to preserve SSH focus recency).
The old full pre-hydration scan had populated all local repos before the
prune. Re-run the prune after the deferred fetchAllWorktrees so stale
lastVisitedAtByWorktreeId entries are dropped once every repo is
authoritative; disconnected SSH stays non-authoritative and is kept.

* fix(startup): exclude unbounded history maps from hydration repo enumeration

Round 3 review fix. collectWorktreeHydrationRepoIdsFromSession collected
repo ids from lastVisitedAtByWorktreeId and defaultTerminalTabsAppliedByWorktreeId,
which accumulate one entry per worktree ever focused / given default tabs.
On the long-lived large profile this fix targets (stablyai#9441), those maps
reference nearly every repo, so the 'selective' pre-hydration fetch
degenerated back toward the full all-repos scan the fix removed.

Split the keyed-field list: repo enumeration now scans only chrome-bearing
fields (open tabs/editors/browser), while folder-key detection keeps
scanning all fields. The history maps hydrate unfiltered regardless, and
the deferred re-prune reaps their stale entries, so no data or recency is
lost. Also pin the round-2 re-prune ordering in the routing test.

* fix(startup): prevent competing full worktree scans

* fix(startup): keep selective hydration scans bounded

* fix(startup): ignore stale tab selection markers

* fix(startup): keep deferred worktree recovery selective

* test(windows): dismiss startup overlays before terminal setup

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
…p scan degrades (stablyai#9991)

* fix(session): preserve chrome after degraded repo scan

* fix(session): retain terminal-free degraded workspaces
…ain process (stablyai#9990)

* fix(terminal): bound cold restore replay

* fix(terminal): bound cold restore replay slices

* perf(terminal): avoid rescanning cold restore payloads
)

* fix(editor): preserve PDF zoom when file content reloads

PdfViewer rebuilt the pdf.js document on every content change and always
reset to page-width, so external PDF updates wiped the user's zoom. Keep
a fit-width vs absolute preference across reloads for the same file path.

Closes stablyai#10165

* refactor(editor): dedupe PDF zoom handlers into shared callbacks

Collapse six copies of the step-scale-and-record-preference logic (three
directions duplicated across the keyboard handler and toolbar callbacks)
into a single stepZoom callback; the keydown handler now reuses zoomIn/
zoomOut/zoomReset so keyboard and toolbar can't drift apart.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
…of a bare shell (stablyai#9454) (stablyai#10238)

* fix(agent-status): resume finished agents after macOS logout instead of a bare shell (stablyai#9454)

After the daemon-retirement fix (stablyai#9826) recovers terminals across a macOS
logout, a running Claude Code (or other resumable TUI agent) that had
finished its turn came back as a BARE SHELL — the user had to open a new
tab and `claude --resume` in every worktree to get logged back in.

Root cause: setAgentStatus writes a persisted 'live recovery' sleeping
record (the cold-restore --resume anchor) for a live resumable agent, but
drops it once the turn is 'done' — except for Pi, which was special-cased
(retainsPiRecoveryIdentity). A finished Claude/Codex turn leaves the TUI
alive and resumable at its prompt just like Pi, so an idle agent lost its
anchor; a cold restore after an abrupt app death (logout) then found
nothing to resume and dropped to a bare shell.

Fix: generalize the Pi-only recovery-identity retention to every resumable
TUI agent (isResumableTuiAgent), gated on a valid resume argv, so a finished
agent keeps its persisted providerSession anchor and the already-shipped
cold-restore --resume path brings it back logged in. Extends an existing
pattern; no new subsystem.

Reproduced + validated live in Electron via the login-session-death seam:
idle Claude -> daemon retire -> app relaunch previously bare-shelled; now
spawns 'claude --resume <session>' and restores the conversation logged in.

* test(agent-status): update sleep-hygiene assertions for retained live recovery anchor (stablyai#9454)
…names (stablyai#9989)

* feat(agent-rows): opt-in conversation-name labels for agent rows

Sidebar worktree-card and dashboard agent rows always show the last
message sent to the agent, so rows relabel on every turn and a 'continue'
prompt becomes the row's name. Add an opt-in Agents setting that labels
rows with the conversation name instead, resolved with the tab bar's
precedence: manual rename, quick-command label, OpenCode session title,
generated title (behind its existing setting), then the agent-set live
title. Live titles count only when they carry a real name - status
decoration is stripped, and pure status, identity-echo, spinner+cwd, and
placeholder titles fall back to the last-message label. Subagent child
rows keep their own descriptions.

Locale note: sync:localization-catalog also restored parity for keys
already missing on main (add-host, sleep-worktree copy).

* fix(agent-rows): read the live tab so late renames and titles surface

Row data patches live entries in place and keeps the tab snapshot from
row creation, so a rename or agent-set title landing after the row was
built never reached the conversation-name resolver. Select the current
tab from the store in the hook; retained rows without a live tab keep
the snapshot fallback.

* fix(agent-rows): reject status labels and bound lookup work

Reject native provider/status titles and Windows/UNC cwd frames so conversation-name mode falls back to the user's last message instead of relabeling rows with identity or path text. Keep default-off and subagent rows off the tab map, and share a WeakMap-backed tab index across mounted rows to avoid repeated linear scans on store writes.

* feat(agent-rows): make conversation names the default

Always prefer a usable conversation name for sidebar and dashboard agent rows, falling back to the last message when the resolver rejects a title. Remove the preference, settings UI, search copy, and catalog entries. Keep subagent labels unchanged and reject generic Terminal N placeholders so partial tab snapshots cannot hide meaningful prompts.

* chore(i18n): sync catalog after main merge

* fix(agent-rows): preserve same-tab child labels
…lyai#10242)

Portal the non-blocking reconnect banner into each pane container so WebGL
compositing cannot paint terminal text through it, raise z-index above
pane-local chrome, and use a fully opaque card background.
…awn (stablyai#10237)

* fix(claude-accounts): quote resolved claude path for Windows shell spawn

runClaudeCommand spawns the resolved claude command with shell:true on
Windows, but spawn concatenates the command into the cmd.exe line without
quoting. When the CLI resolves to a path containing spaces (e.g.
C:\Users\First Last\AppData\Roaming\npm\claude.cmd), cmd.exe splits at the
first space and account add fails with:

  'C:\Users\First' is not recognized as an internal or external command

Quote the command the same way claude-pty.ts and quoteWindowsCmdArg
already do for other Windows spawns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(claude-accounts): own Windows cmd invocation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
…nded freeze (stablyai#9447) (stablyai#9853)

* fix(app): bound the wake/quit paths implicated in the phone-session-ended freeze (stablyai#9447)

- relay-transport: waitForClose now times out (5s) so a half-open post-sleep
  socket can't wedge runtimeRpc.stop()
- will-quit: race teardown against a 20s deadline so app.quit() always runs
  (Force Quit was the only escape when any teardown member never settled)
- terminal-fit-restore: local restoreTerminalFit invoke gets the same 15s
  bound as the remote path so the held-fit modal buttons can't pin disabled

* fix(app): close wake recovery timeout gaps

* fix(relay): drop late frames after forced teardown

* fix(relay): fence detached socket callbacks

* fix(app): close timeout resource gaps

* fix(relay): detach retired mobile transports

* fix(types): exclude absent stat overloads

* fix(runtime): expire wedged terminal restore dedupe

* fix(runtime): keep restore retries on one reclaim

* chore(skills): refresh bundled skill manifests

* fix(window): fence quit acknowledgements by request

* fix(relay): bound revoked device socket cleanup
…tablyai#10240)

* fix(worktree): stop terminal removal fence error flashing on delete

Deleting a worktree kills its PTYs for the filesystem teardown, then runs
git worktree remove (~1s). During that window a doomed pane races a fresh
respawn that main correctly fences with TerminalRemovalInProgressError, but
the renderer surfaced that internal fence verbatim as a pane error banner
until the worktree unmounted.

- startFreshSpawn: skip the respawn when the pane's own worktree is being
  deleted (isDeleting) — no shell should spawn into a directory being removed
  and the pane is about to unmount.
- reportError: swallow the removal fence at the single pane-error sink so it
  never reaches the banner, covering the parent-removal-fences-child case the
  own-worktree skip cannot see.
- Share the fence messages between main (thrown) and renderer (recognized) via
  worktree-removal-fence-error.ts so the thrown text and predicate can't drift.

* test(worktree): assert onError callback captured before invoking

Optional invocation let the fence-suppression test false-pass if the
transport onError wiring broke; require the callback so a broken wire
fails loudly. Addresses CodeRabbit review on stablyai#10240.
…urvival harness (stablyai#9992)

* fix(win-e2e): survive the agent-CLI feature-wall modal in harness driving

The modal renders off an async capability check and can be up at (or appear
right after) the harness's first interaction, swallowing every click until
closed — both crash-survival attempts on stablyai#9446 timed out exactly there, while
the app itself was healthy. Add its 'Close' button to the overlay dismiss
list, dismiss overlays before ensureTerminal's first probe, and retry the
first New-workspace click through a dismissal pass instead of hanging the
full 60s.

* fix(win-e2e): scope modal dismissal and bound retries

* fix(win-e2e): keep overlay retries inside composer

* fix(win-e2e): seed blank terminal selection

* fix(win-e2e): avoid input during terminal restore

* fix(win-e2e): bound whole create path by caller timeout

createWorkspaceFromSeededRepo gave the composer wait and create-button retry
fresh fixed 15s windows, so the create path could overrun the caller's
timeoutMs. Thread one shared deadline through every step and cover a fast
first click followed by a blocked submission.
…ablyai#10253)

The show/restore repaint jiggle mutated the window frame synchronously
inside AppKit's window-state dispatch. On macOS 26 (Tahoe), windows are
scene-backed and a frame update sent re-entrantly from scene-update
handling can self-deadlock the main thread in FrontBoardServices
(kernel-confirmed spindump: performAsyncAndWait on the callout queue the
main thread was draining).

- Defer the setSize nudge to a fresh event-loop turn so it never runs
  inside the show/restore dispatch.
- On Darwin 25+ (macOS 26) skip the nudge entirely and rely on
  webContents.invalidate(); the nudge works around a pre-Tahoe
  black-surface compositor bug.
…ring startup restore (stablyai#9970)

* fix(main): log unhandled rejections durably and stop them killing the app (stablyai#9441)

Main had no unhandledRejection handler — Node's default killed the process
with a clean exit code and no macOS crash report, which is exactly the
silent startup-restore exit in stablyai#9441. Rejections now record a durable crash
breadcrumb (flushed to the trace sink) and the app stays alive; uncaught
exceptions still terminate but record the same breadcrumb first so field
exits stop being undiagnosable.

* fix(main): harden process error guard diagnostics

* fix(main): cap fatal-error records to stop a rejection storm stalling main

One broken resource can reject hundreds of concurrent restore chains, and
each durable record does a synchronous trace flush. Cap records at 20 per
60s window and carry the suppressed count into the next recorded event.

* fix(main): never rate-limit the fatal uncaught-exception breadcrumb

A rejection storm that exhausted the 60s record window would suppress the
one main_uncaught_exception record taken right before the re-throw kills
the process, recreating the undiagnosable death from issue stablyai#9441. Scope
the fixed window to main_unhandled_rejection only; the uncaught path
records at most once per install (handler removes itself, EPIPE/EIO never
record), so it cannot storm.

* fix(main): reset fatal-error window on backward clock jumps
anishekkamal and others added 30 commits July 26, 2026 02:22
…yai#10086)

* feat(editor): toggle Word Wrap from file tab actions and Alt+Z

Long single-line and structured files wrap by default and misalign.
Surface Word Wrap on the editor more-actions menu for normal file tabs
(diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can
unwrap without opening Settings.

Closes stablyai#9974

* fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z

CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving
diff panes out of sync with the markdown actions menu.

* test(editor): verify word wrap shortcut routing

Cover editor/diff setting callbacks and the cross-platform Alt+Z binding.

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected.
… / Right) and complete the desktop tab context menus (stablyai#9323)

* Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets

* Fold the per-sheet Close action into the bulk-close module (session route max-lines)

* fix(mobile): preserve pinned tabs during bulk close

---------

Co-authored-by: Tom de Bres <tomdebres@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
…yai#9773)

* feat: implement Cmd+Enter as commit shortcut in Source Control

* test: add unit tests for commit shortcut and tooltip formatting

* fix: address review feedback on modifier keys and test coverage

* test: split mac and windows/linux shortcut and keydown tests

---------

Co-authored-by: Andres Van Reepingen <andres.vanreepingen@datacamp.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
…ai#7436)

* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
… owns provider-account scope (stablyai#8188)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* Add SenseVoice speech-to-text model (Korean/Japanese support)

SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with
Korean and Japanese support. The existing local models cover only
English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is
multilingual but trades accuracy for breadth.

- Add 'senseVoice' to SpeechModelType
- Register the sherpa-onnx SenseVoice archive in the model catalog
  (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout)
- Handle the senseVoice type in the STT worker via createOfflineRecognizer
  with the senseVoice model config (auto language detection + ITN)
- Add model-catalog regression tests for the new entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(speech): add Parakeet TDT-CTC 0.6B JA to the speech model catalog

* test(speech): cover stt-worker-model-config file resolution incl. single-file models

* feat(speech): decode Parakeet TDT-CTC JA via sherpa-onnx nemoCtc offline recognizer

* fix(speech): use int8-only SenseVoice archive

* fix(speech): refresh SenseVoice catalog metadata

---------

Co-authored-by: xsacdw <xsacdw@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: LauraGPT <LauraGPT@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
* fix(terminal): stop bold flash on worktree switch

Worktree hide disposes WebGL and falls back to xterm's DOM renderer.
On reveal, resume ran after paint and flushed backlog against DOM first,
so the first frame used heavier CSS-AA glyphs before WebGL settled.

Resume in useLayoutEffect and reattach WebGL before backlog flush so the
first painted frame stays on the GPU path. No cold-park policy change.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): fit WebGL grid before backlog flush on resume

Adversarial review: resume-before-flush alone wrote TUI backlog onto the
transient DOM↔WebGL one-column-off metrics window. Order is now
resume → fitAllRevealedPanes → flush on heavy reveal and window wake.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): latch viewport intent before WebGL wake resume

Adversarial review: wake path synced intents after resume/fit, which can
re-latch a pinned viewport as followOutput. Capture before reattach and
drop the post-resume re-sync on heavy reveal (outer path already latched).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): complete visibility bookkeeping before PaneManager exists

useLayoutEffect runs before the passive lifecycle creates PaneManager, so
the mount-visible path never set hasCompletedVisibleResume. The first
intra-worktree hide then wrongly suspended WebGL. Bookkeep completion
even when managerRef is still null (extracted helper for max-lines).

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): re-sync pin geometry after resume backlog flush

Keep the pre-resume intent latch (reattach must not re-latch pins as
followOutput), then re-sync after flush with preservePinnedAtBottom so
scrollback trim updates absolute pin lines before enforce.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): drop same-tick post-flush intent re-sync

flushTerminalOutput only queues terminal.write and returns before parse, so
a same-tick re-sync read pre-parse resume/fit geometry and could overwrite
pre-resume pins. Keep pre-resume latch + enforce only.

Co-authored-by: Orca <help@stably.ai>

* fix(test): expect default worktree card properties to include cli

stablyai#10712 added 'cli' to DEFAULT_WORKTREE_CARD_PROPERTIES, but the fresh
default-profile assertion still omitted it and fails verify.

Co-authored-by: Orca <help@stably.ai>

* perf(terminal): retain Windows WebGL across worktree hides

* perf(terminal): bound retained WebGL contexts

* fix(terminal): harden retained WebGL lifecycle

* fix(terminal): preserve healthy WebGL on wake

* fix(terminal): preserve reveal recovery ordering

---------

Co-authored-by: Orca <help@stably.ai>
…tablyai#10226)

* fix(mobile): clear native-chat composer optimistically at send time

Over relay the send RPC round trip is visible and a lost ack (or a
relay/direct cutover) could strand the sent prompt in the composer
forever: the unconfirmed-send deadline dropped its tracking entry, so a
late transcript echo could never clear the draft.

Clear the draft at send time and restore it only on a definite
rejection. holdUnconfirmedSend now only manages the delivery-unconfirmed
notice; it no longer touches drafts.

* fix(mobile): isolate question answers from composer drafts
* fix(cli): bound orchestration ask timeouts

* fix(cli): harden remote timeout boundaries
…h trail (stablyai#10729)

* fix(crash-reporting): stop fit-retry bursts from erasing the pre-crash trail

Windows renderer OOM F0BKR84AHEH (0xE0000008) arrived with a 30-entry
breadcrumb ring in which two `terminal_safe_fit_retry_exhausted` bursts
consumed 26-90% of the slots. Every hidden pane is `display:none` -> 0x0 ->
unmeasurable, so one post-reload reattach wave exhausts the retry budget once
per mounted pane inside ~60ms.

The bursts were also uninterpretable: `pane.id` restarts at 1 per PaneManager
and there is one manager per tab, so 34 identical `paneId: 1` crumbs cannot
distinguish one pane looping from 34 panes firing once. Coalesce the crumb by
name and carry the live-pane census on the payload instead, so the count
survives without costing 34 ring slots.

Same treatment for WebGL diagnostics, which were worse off: context-loss and
atlas-reset crumbs only reached a DevTools-only ring (`window.n()`), so a
renderer that dies takes them with it. That bundle had three GPU-process deaths
in the 65s before the renderer OOM and zero WebGL evidence - absence of
instrumentation, not absence of the event. Mirror them into the crash report,
coalesced per kind so a routine atlas reset cannot mask a context loss.

Evidence-only: no behavior, rendering, or lifecycle path changes.

Co-authored-by: Orca <help@stably.ai>

* perf(pane-manager): count panes without materializing public views

The census runs on the crash path; getPanes() allocates a full ManagedPane
projection per pane just to read .length.

Co-authored-by: Orca <help@stably.ai>

* test(crash-reporting): pin the fit-retry burst against the 30-entry ring

Reproduces the F0BKR84AHEH ring loss directly: 10 pre-crash crumbs plus a
34-crumb per-pane burst. Uncoalesced, the burst takes all 30 slots and zero
pre-crash crumbs survive; coalesced, it takes one slot, all 10 survive, and
the pane count rides on the payload instead of on the crumb multiplicity.

Co-authored-by: Orca <help@stably.ai>

* fix(crash-reporting): name the WebGL census the same as the fit-retry census

The context-loss crumb spread getLivePaneCensus() raw, so one ring described
one measurement two ways: managers/panes here, livePanes/livePaneManagers on
the fit crumb. Spreading also meant renaming the census return keys would
silently reshape the crumb. Name the fields at the call site and pin them.

Co-authored-by: Orca <help@stably.ai>

* fix(crash-reporting): keep a hot coalesce key from being the first LRU eviction

The suppression path returned before the delete-then-set that re-anchors
recency, so a key hit continuously never moved from its original insertion
slot and became the first eviction candidate — the inverse of the LRU's
stated intent.

`renderer_error` keys carry message+stack identity, so one noisy render loop
mints unbounded distinct keys. Within a single 30s window that churn evicted
the `terminal_safe_fit_retry_exhausted` key mid-burst, un-suppressing it and
re-arming the exact ring flush the coalescing exists to prevent.

Re-anchor position only; `recordedAt` is left alone so the suppression window
still expires on schedule rather than renewing on every hit.

Found while adversarially probing the LRU claim in stablyai#10729's own description,
which asserted these keys "cannot evict live keys".

* fix(crash-reporting): report the newest census of a coalesced burst

The suppression path wrote nothing to the ring, so a coalesced burst froze
its FIRST event. Panes mount progressively, so pane 1 exhausting alone
legitimately measures livePanes: 1 -- and the 33 later crumbs, each carrying
a truer census, were dropped. A 34-pane wave was recorded as `livePanes: 1`
with no count: the exact "one pane looping" misread that coalescing by name
was introduced to prevent.

The existing burst test missed this because it fed a constant census on
every crumb, making frozen-first and newest-wins indistinguishable.

Stash the newest payload and fold it into the ring entry the key already
owns: still one slot, now reading livePanes: 34 + suppressedSinceLast: 33.
Resolution is deferred to snapshot time -- sanitizing per suppressed hit of
a 1459/min crash loop measured 2194 ns/op vs 185 ns/op deferred.

Two follow-on defects fixed alongside: an expiring key dropped its pending
payload (it loses its only handle on the ring entry), and resolving the
re-emitting key's own old slot double-counted a burst.

---------

Co-authored-by: Orca <help@stably.ai>
…stablyai#10707)

* fix(gpu-fallback): make the crash window rolling, not launch-anchored

Software-rendering fallback only ever considered GPU child crashes in the
first 30s after launch: `if (msSinceLaunch > this.windowMs) return`. Session
12e6ee64 crashed the GPU child 4 times (242s / 920s / 926s / 946s since
launch). The last three span 26.0s — inside windowMs, exactly threshold — but
every one was rejected because the burst began 920s in. The renderer died of
process OOM (0xE0000008) 39s later.

GPU work is demand-driven, so the first heavy compositing often happens
minutes into a session. What distinguishes a broken driver from normal
Chromium churn is that the crashes *cluster*, not when the cluster starts.

Keep a sorted array of recent crash times pruned to windowMs behind the
newest, and engage when the count reaches threshold.

Measured against real field telemetry (341 distinct win32 launches with >=1
GPU crash, from process_gone_suppressed breadcrumb trails): the rolling
window engages on 2/341 launches (0.59%), one of which is 12e6ee64. Max GPU
crashes in any single launch is 4. The closest non-firing sequence
([0, 29531, 55136, 74178] — consecutive gaps that each fit the window but
never put 3 inside it) is pinned as a regression test.

Also destroy the Windows tray before app.exit(0) on this path, matching the
app:relaunch IPC handler — app.exit skips before-quit, and this can now fire
deep into a session rather than only in the first 30s.

Mutation-tested: 6 mutants (launch-anchored gate, dropped pruning, dropped
monotonic clamp, cutoff </<=, threshold >=/>, dropped engaged latch), all
killed by the suite.

Co-authored-by: Orca <help@stably.ai>

* fix(gpu-fallback): ask before restarting

---------

Co-authored-by: Orca <help@stably.ai>
…i#10786)

Adds "Hide detached HEAD" alongside the existing sidebar filters, wired
through the same pipeline as Hide CLI-created: sidebar list, Cmd+J
empty-query list, workspace board, active-filter badges, Clear/Reset
Filters, and persisted UI state.

The predicate reuses getWorktreeGitIdentityDisplay so the filter targets
exactly what the card renders a Detached HEAD badge for. Requiring a real
head (not just an empty branch) keeps folder workspaces and
SSH-synthesized rows — which carry both empty — out of the filter.

Activating a hidden detached workspace clears the filter, matching the
existing reveal escape hatch for automation- and CLI-created workspaces.

Splits the filter-state describes out of visible-worktrees.test.ts into
sidebar-filter-state.test.ts to stay under the max-lines budget.

Co-authored-by: Orca <help@stably.ai>
…tablyai#10058)

* feat(daemon): add daemon_lifecycle replaced/retired telemetry event

Implements STA-2376.

Adds track('daemon_lifecycle', {transition, reason, live_session_count_bucket, version_skew?}) covering 'replaced' (unhealthy_resolver / stale_bundle / different_app_path / failed_health_check at daemon-init launcher sites) and 'retired' (died_respawn at the adapter respawn closures). Enum-only + .strict() + bucketed counts keep paths, versions, and raw counts off the wire; preserve-path transitions emit nothing. Cross-platform and SSH-safe; no-op in non-official builds.

Test plan: affected vitest (158) green; typecheck/lint clean except pre-existing unrelated failures.

* fix(daemon): prevent false lifecycle telemetry

* test(daemon): restore once-ness on respawn reason assertions

Keep STA-2376 reason checks without dropping concurrent-respawn
coalescing coverage that prevents double died_respawn telemetry.

* fix(daemon): emit replaced telemetry on runtime unhealthy_resolver respawn

CodeRabbit: adapter-driven macOS resolver replacements forked a new daemon
without a lifecycle event. Emit trackDaemonReplaced (not retired) so field
diagnosis of stablyai#7936 covers the runtime path without mislabeling it as death.

* fix(daemon): stop double-counting resolver replaces; drop redundant version_skew

Three telemetry-correctness fixes to the STA-2376 daemon_lifecycle event.

1. The runtime macOS resolver respawn double-counted. doRespawn() disconnects
   but never kills the daemon, so the ensureRunning() that follows re-enters
   createOutOfProcessLauncher, which re-detects healthy + resolver-unhealthy +
   0 sessions and emits the replace itself. The closure emitted a second one.
   It also emitted before the outcome was known, so a resolver that recovered
   mid-flight (or a session appearing) left a 'replaced' on the wire for a
   daemon the launcher went on to preserve. The launcher's emit is gated on a
   confirmed kill, so it is the correct sole emitter; this reverts the emit
   added in 1e60ca8. The reason plumbing stays -- it is what keeps a
   resolver respawn from being mislabelled died_respawn.

2. version_skew carried no information and lied to one cohort. It was present
   iff reason === 'stale_bundle' and always true, so it was a deterministic
   function of reason. isDaemonStaleForCurrentBundle also returns true when the
   pid file has appVersion: null -- a replace-once heuristic for pre-marker
   builds, where no version comparison happened at all -- so the field asserted
   skew for exactly the upgrade cohort the event exists to illuminate. Dropped
   from the schema, emitter, and call site, along with the dead branching.

3. track() is now failure-isolated in both emitters. Both call sites sit on the
   daemon launch/respawn path, where a throw costs the user every terminal.

Tests: once-ness (toHaveBeenCalledTimes) on every emit assertion -- the old
toHaveBeenCalledWith-only assertions passed under a doubled call; a regression
guard that the resolver respawn closure stays silent; and a throwing-client
test. Note the unit tests mock DaemonSpawner and never invoke the launcher, so
no test could observe the double-emit; the once-ness assertions bound each
emitter within its own seam.

Known limitation, unchanged: a wedged-but-alive daemon (stablyai#8689) can still report
died_respawn from the adapter and failed_health_check from the launcher -- the
app cannot distinguish wedged from dead at that point.

* fix(daemon): attribute the runtime resolver replace so it is not lost

Round-2 review found the previous commit over-corrected. Removing the emit
from the respawn closures was right about the premature emit but wrong about
where the event would come from instead.

doRespawn() does not kill the daemon, but it does drop its only authenticated
client, and that is enough: the last fully-authenticated disconnect sets
retirementRequested, and reevaluateIdleShutdown -> beginIdleShutdown runs with
no grace timer, unlinking the token and PID files. So by the time
ensureRunning() re-enters the launcher, the daemon is already gone --
killStaleDaemon finds no PID file, confirmedReplacement stays false, and the
gate suppresses the emit. Net effect of the previous commit: zero events for a
runtime macOS resolver replacement, the common case.

The double-emit round 1 found was real but narrow: it needs a daemon holding
non-alive sessions, which keeps host.listSessions() non-empty so isIdle() is
false and the daemon survives the disconnect to be killed by the launcher.

Fix: the adapter attributes the reason rather than emitting it, and the launch
it triggers consumes the attribution and reports it. One emit point, exactly
one event, correct reason -- whether the daemon self-retired or survived to be
killed. The attribution is one-shot so a later unrelated launch cannot inherit
it, and it is preferred over the launcher's own inference, which would
otherwise mislabel this as failed_health_check.

Also: suppress died_respawn while a manual restart is in flight. runRestartDaemon
kills the daemon while the outgoing adapter is still live and undisposed, so a
pane respawning on its synthetic exit billed a user action to the crash bucket.

Tests: a regression test that drives the closure and the launcher across the
seam the DaemonSpawner mock normally hides, with killStaleDaemon returning
false to model the self-retired daemon, plus the one-shot assertion. Verified
load-bearing by mutation (neutering the handoff fails it). Also reset
killStaleDaemonMock's implementation in beforeEach -- mockClear left a previous
test's mockResolvedValue in place, which silently disarmed the
confirmedReplacement gate for every test after it.

* fix(daemon): let a proven replacement reason outrank the attributed one

Round-3 review. The attribution was preferred unconditionally, so a launch that
independently proved a different cause reported the wrong one: resolver
unhealthy at the adapter check, daemon survives the client drop (non-alive
sessions keep isIdle() false), resolver recovers milliseconds later, and the
launcher then proves a stale bundle or a different app path and kills on that
basis -- but the event said unhealthy_resolver. A proven kill is grounded in
post-kill truth, so it now wins; the attribution covers only what the gate
cannot see, a daemon that self-retired leaving nothing to kill and no reason to
infer. Every other case is unchanged: self-retired still reports
unhealthy_resolver, and the surviving-daemon case reaches the same reason
through the launcher's own inference.

Also pins the invariant that makes the module-scoped one-shot safe -- the write
reaches the launcher with no await in between -- at both the write and the
consume, since the DaemonSpawner mock lets tests drive the two halves with an
arbitrary gap and would not catch an await being introduced.

Corrects the beforeEach comment from the previous commit: there was never a
plain mockResolvedValue on killStaleDaemonMock in this file, so it did not
silently disarm the gate for later tests. mockReset is still right -- it drops
an unconsumed *Once queue, which mockClear does not.

Tests: a guard that the launcher's proven reason wins over the attribution,
verified load-bearing by mutation (restoring the old ordering fails it).

* fix(daemon): don't let the residual health bucket absorb the resolver reason

Round-4 review caught a regression from the previous commit. Letting any
confirmed kill outrank the attribution was too broad: failed_health_check is
not an identification, it is the residual bucket that also absorbs wedges and
crashes, so preferring it discards the more specific reason the adapter already
established.

This is the likely shape of the incident, not a corner case. The dead macOS
login session that fails the resolver probe also fails the PTY spawn probe, so
checkDaemonHealth returns pty-spawn-unhealthy rather than healthy. The resolver
branch is then never evaluated, and with zero live sessions the degraded
preserve does not apply either, so the launch falls through to
failed_health_check and kills a daemon that survived the adapter's disconnect
(non-alive sessions keep it non-idle). Before this commit that reported
failed_health_check and dropped unhealthy_resolver -- burying the flagship
signal in the catch-all.

Rank by how well each reason is evidenced instead: a confirmed kill that
positively identified the daemon wins, the attribution beats the residual
bucket, and the residual bucket is still reported when there is nothing better.
Round 3's motivating case (a proven stale bundle must not be billed to the
resolver) is unaffected and still covered.

Tests: a guard for the pty-spawn-unhealthy shape, verified load-bearing by
mutation (removing the residual-bucket exclusion fails it).

* test(daemon): cover the confirmation path production actually uses

Round-5 review. No runtime defect, but the three identified reasons were only
ever proven through a mechanism the field never takes.

confirmedReplacement is `(await killStaleDaemon(...)) || cleanupResult.cleaned`,
and the two halves are disjoint in practice. unhealthy_resolver, stale_bundle,
and different_app_path all require health === 'healthy', so cleanup reaches the
daemon over RPC, shuts it down, and unlinks its pid file -- leaving nothing for
killStaleDaemon to find. Production therefore confirms exclusively via
.cleaned. Every test confirmed exclusively via killStaleDaemon, because the
net.connect stub always errors, so cleanup always returned cleaned:false.

The consequence was a silent trapdoor: simplifying the gate to
`confirmedReplacement = await killStaleDaemon(...)` stops all three identified
reasons from ever emitting in the field, and the whole suite stays green.
Confirmed by flipping the killStaleDaemon default to false -- five tests fail,
none of which are meant to be about the kill.

Adds the missing case: cleanup confirms, the kill finds nothing, and
different_app_path is still reported once. Verified load-bearing by mutation --
dropping the .cleaned half now fails this test specifically.

Scoped the new test's mocks to *Once so the identity override cannot leak into
the packaged-bundle test that follows, and hands probeSocketExists back on the
way out since beforeEach only mockClear()s it.

* test(daemon): cover the manual-restart retirement guard

Round-6 review returned land-ready with one note: removing the !restartInFlight
guard from both respawn closures left the whole suite green. That is the same
silent-trapdoor shape the previous commit closed for the .cleaned gate, on a
guard this PR introduced, so it gets the same treatment.

The guard matters because runRestartDaemon tears the daemon down while the
outgoing adapter is still live and undisposed; a pane respawning on its
synthetic exit reaches the death path for what was a user action, and would
bill a manual restart to the crash bucket.

Drives the death from inside the restart's ensureRunning so restartInFlight is
genuinely set by the code under test, rather than asserting against a flag the
test poked itself, and then repeats the respawn after the restart settles to
show the suppression is scoped rather than permanent. Verified load-bearing by
mutation: removing the guard fails this test and nothing else.

* test(daemon): close the two surviving telemetry mutations

Round-7 review returned land-ready with two test-only gaps, both found by
mutation and both the same trapdoor shape as the last two commits.

The manual-restart guard exists in two respawn closures and only the first was
covered. That is the wrong half: the restart installs its own adapter, so from
the second restart onward the copy in runRestartDaemon is the one that actually
runs in the field, and it could be deleted with the suite green. The test now
drives a second restart through the adapter the first one installed.

The privacy-invariant test only built 'replaced' payloads, so .strict() on the
'retired' member was never exercised -- someone adding a field to
trackDaemonRetired after that .strict() was dropped would have reached PostHog
with the test still passing. It now runs the leak set over both transitions,
plus a sanity assertion that each base payload is itself valid so the
rejections prove the leak and not a malformed base.

Both verified load-bearing: neutering the second guard copy, and dropping
.strict() from the retired member, each now fail exactly one test.
…A-2373) (stablyai#10065)

* fix(daemon): respawn on PTY write dropped to a dead daemon socket (STA-2373)

DaemonPtyAdapter.write() sends keystrokes via fire-and-forget client.notify().
When the daemon dies (retirement, crash, kill), the socket disconnects and the
notify is silently dropped — no rejection reaches withDaemonRetry, so the
dead-endpoint respawn never fires and the attached pane freezes. Only a
request/reply RPC (e.g. createOrAttach from opening a new terminal) detected
the death and forked a replacement.

DaemonClient.notify() now reports delivery; a dropped write to a still-active
session drives the shared respawn coalescer directly (reconnecting the
permanent client before releasing the temporary adoption lease, mirroring
withDaemonRetry's ordering), so the pane self-heals like the createOrAttach
path. Cross-platform + SSH-safe: no platform assumptions, pure adapter logic.

Complements (does not duplicate) stablyai#8426, which fixes the adjacent in-daemon bug
where a thrown node-pty write no longer marks the handle dead. That is
daemon-side; this is the app-side dropped-notify that never triggered respawn.

* fix(daemon): restore adapter state after dropped-write respawn

* fix(daemon): recover writes after endpoint respawn

* fix(terminal): remount panes after daemon death

* fix(daemon): recover sibling panes after daemon death, not just the written one

When a daemon dies, its dropped-write respawn only remounted the pane whose
write detected the dead endpoint. Sibling panes (alive at death but not typed
into) were left frozen: stale prompt pixels, silently-dropped input, no live
child, and no recovery even on later keystrokes — the exact STA-2373
frozen-typing symptom on non-triggering panes.

DaemonPtyAdapter now fans a write-unavailable signal out to every active
session when it recovers from a dead endpoint, emitted while the sessions are
still in activeSessionIds so the renderer's liveness gate still reads them
live. pty.ts forwards each to the existing pty:writeUnavailable channel, so all
panes remount + re-attach through the same path the written pane already used.

Adds a revert-sensitive regression test: with two sessions and only one
written after the daemon dies, the sibling must also be signaled to recover.

* revert(format): drop repo-wide oxfmt churn unrelated to STA-2373

A review pass ran `oxfmt --write .` across the tree, pulling seven files
with no bearing on the dead-daemon respawn fix into the PR diff. Restored
to origin/main byte-for-byte so the diff carries only the respawn change.

* fix(daemon): snapshot active sessions before the write-unavailable fan-out

A listener that kills a pane mutates activeSessionIds mid-iteration, which
can skip the very sibling the fan-out exists to reach. Matches the snapshot
fanoutSyntheticExits already takes.

* fix(daemon): re-arm dead-endpoint recovery on every daemon death

The respawn-storm latch was only released once every awaiting session
rebound. Background sessions have no mounted pane, so nothing ever calls
createOrAttach for them and they hold the awaiting set non-empty forever
— latching the fan-out off after the first death and silently making the
whole fix one-shot. Re-arm on the disconnect event instead, which fires
once per established connection, so the storm guard still holds within a
single incident.

* fix(daemon): route the write-unavailable fan-out through the pty router

Main subscribes on the routed provider, and DaemonPtyRouter is the live
localProvider whenever a legacy daemon socket exists — the common case
when an in-place update bumps PROTOCOL_VERSION with terminals running.
It forwarded write but not onWriteUnavailable, so the fan-out reached no
listener and only the written pane recovered: STA-2373 unfixed, silently.

Also stop rejecting writes on adapters that cannot respawn. Legacy
adapters have no respawn, so the remount reattaches to nothing and
rebuilds the pane empty, losing scrollback the user could still read —
worse than the pre-existing silent drop. And guard the renderer's
write-unavailable handler on ptyId like its sibling data/replay handlers,
so a transport that rebinds without detaching cannot remount a healthy
pane.

* fix(daemon): route the write-unavailable fan-out through the degraded provider

DegradedDaemonPtyProvider is the live localProvider in degraded launch
mode and main subscribes on it, but it forwarded onData/onExit/onReplay/
onBackgroundStreamEvent and not onWriteUnavailable — so the fan-out
reached no listener and siblings stayed frozen. Same defect as the router,
one provider over.

The file sat at its max-lines ceiling, so make room by reusing one
combineUnsubscribes helper across the three places that already repeated
that loop rather than bumping the limit. Forward to the daemon adapters
only: the local fallback has no dead-socket problem.

* refactor(daemon): share the listener-fanout unsubscribe combination

Adding onWriteUnavailable to both provider wrappers left each file at
exactly 300/300 lines, so the next line anyone added would have broken
max-lines with no sanctioned escape hatch. Both already repeated the same
combine-unsubscribes loop, so lift it into one module: duplication drops
and each file gets its headroom back.

* fix(test): stop the fake emitter colliding with the private adapter emitter

DaemonPtyAdapter.emitWriteUnavailable is private, so declaring a public
member of the same name on a mock intersected with DaemonPtyAdapter
collapsed the whole type to never — one collision produced 54 typecheck
errors, taking out pre-existing assertions in both files too. Rename the
fake to triggerWriteUnavailable and declare onWriteUnavailable on
ProviderMock, which IPtyProvider does not carry on this branch.

vitest does not typecheck, which is why a red build sat behind a green
suite.
Run terminal visibility transitions pre-paint only on macOS. Restore passive disposal and recreation on Windows/Linux, remove the Windows retained-context LRU machinery, and preserve the normal 128-context startup ceiling.
…ai#10776)

* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path
…lyai#10538)

When a pane is mislabeled agent:codex but still holds a Claude
transcriptPath, the Codex resume guard threw and blocked relaunch.

Only hard-fail when the path claims Codex's dated rollout layout
(sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without
requiring the file to exist. Paths that never claimed Codex provenance
return null so the pane can relaunch.

Keying on rollout shape rather than trusted-home membership matters:
returning null only declines to override CODEX_HOME, and the renderer
has already baked 'codex resume <id>' into the command. A real rollout
under an untrusted home would otherwise resume under whichever account
is selected — and once the session bridge hardlinks rollouts across
homes, codex would find that id and resume silently under the wrong
account. Trust is decided upstream by findTrustedCodexSessionResume.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>

Closes stablyai#10517
…tablyai#10467)

* fix(codex): cache weekly-only accounts when switching Codex accounts

refreshForCodexAccountChange snapshotted the outgoing account only when
this.state.codex.session was populated. Weekly-only plans report no session
window, so their snapshot was dropped and the account switcher's inline bars
rendered empty for exactly those accounts.

Accept a populated weekly window as well. stablyai#10136 made this reachable: before
duration-based classification, a weekly-only quota landed in the session slot,
so the gate happened to pass.

Claude is intentionally untouched; it has no weekly-only plan shape.

* test(rate-limits): pin that a windowless outgoing Codex account is not cached

The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher.
…ncy self-diagnostic (stablyai#10784)

* perf(terminal): coalesce per-keystroke input stamps + add typing-latency diagnostic

A user reports keystroke-echo lag on v1.4.156 that vanishes in Ghostty on the
same machine, at a scale we cannot reproduce locally (nested worktrees, ~20
agents each). Static analysis across the 155..156 renderer diff found no
perceptible regression, so this adds the instrument to measure it where it
actually happens.

Diagnostic (`window.__orcaTypingDiagnostic`, dev-console only, no shipped UI):
reports keydown->paint percentiles from real typing plus a scale census —
agent rows store-total vs mounted-DOM, store listener count, worktree nesting
depth, the settings gating suspect paths, and the focused pane's agent and
buffer mode. Nothing attaches to the keystroke path until start(), so it does
not perturb the latency it measures.

Coalescing: recordTerminalInput wrote the whole lastTerminalInputAtByPaneKey
map on every keystroke, waking every zustand subscriber. Hibernation is a >=60s
idle timeout, so the leading edge of a burst writes immediately and the rest
collapse into one trailing flush. Imperative readers merge the pending stamp,
and a late flush never revives a pane key teardown deleted.

This write path is byte-identical in v1.4.155 and v1.4.156, so the coalescing
is a general perf win, not a fix for the reported regression — the measured
saving (~0.02ms/keystroke) is well below perception.

* fix(diagnostic): count React store subscriptions in the listener census

The census wrapped `subscribe` on the bound hook after `create()` had already run.
zustand's `useStore()` reads the INNER `api.subscribe`, and `create()` copies
subscribe onto the hook as a separate property slot — so patching the hook's copy
counted only the 16 imperative `useAppStore.subscribe()` call sites and missed all
~2.2k React hook subscriptions, i.e. exactly the ones that scale with agent rows.
The metric would have read a near-constant ~16 regardless of scale, which would
have made "latency tracks listener count" read as false no matter the truth.

The inner api is only reachable as the state creator's third argument, so the
counter now installs there and lives in the store rather than the probe.

Still per-subscribe (component mount), never per-setState: zustand notifies by
iterating its listener Set directly, so this never touches the keystroke path.

Tests pin both subscribe paths; reverting to the old wiring fails 3 of the 5.
…0778)

* fix(ssh): stop remote terminals fail-opening to local PTY

Docker SSH watcher isolation failed because an unhydrated remote worktree
spawned through the local daemon with a container-only cwd. Fail closed
while the SSH owner is still loading, ignore non-PTY mux notifications
before mapping params.id, and harden the Docker SSH e2e connect helper so
the repo connectionId is present before terminal activation.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): keep host-agnostic terminals off the hydration guard

The unresolved-owner guard also caught floating and inline setup terminals,
which have no repo row by design, regressing stablyai#10151. Scope it to repo-backed
worktrees and cover the SSH hydration window with a regression test.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): treat a local-stamped worktree as a resolved host

The hydration guard keyed off "no repo row", which also withheld spawn for a
local worktree whose own hostId already proves its host. Key it off "nothing
names the host" instead, and cover the local-stamped case with a test.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): recover parked panes when their host hydrates

Withholding the spawn stopped the wrong-host PTY but left the pane inert:
nothing bumped its generation once the repo row merged, so a remote terminal
still never opened. Remount PTY-less tabs when repos:changed resolves their
owner, and drop the e2e helper workaround that was masking this.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): scope hydration remounts to panes that actually parked

Keying recovery off "tab has no PTY" also matched tabs whose shell merely
exited, remounting them on every repos:changed. Track the panes that withheld
their spawn and consume each entry once, so recovery cannot churn a live
terminal or spin on repeated refreshes.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): cover the repos:changed parked-pane remount wiring

The recovery predicate was unit-tested, but nothing proved useIpcEvents
actually calls it — the existing suite stubs repos.onChanged as a no-op.
Drive the real listener and assert a parked pane is remounted only after
its host resolves.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.