* 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 #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 1e60ca87a4. 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 (#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.
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>
* 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>
* 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 #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>
* 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(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
#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>
* 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>
* 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>
* 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>
* 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>
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.
* 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#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>
* fix(gitlab): stop refresh button overlapping dialog close X
The GitLab item dialog's SheetContent renders its own close (X) at
absolute right-4, but the header refresh button sat at the header's
px-5 right padding and overlapped it. Reserve pr-10 on the header so
the refresh button clears the close X, and lift it -mt-1.5 so its icon
aligns with the close X on the same line.
* fix(gitlab): integrate sheet controls into header
---------
Co-authored-by: viniciussilva <vinicius.silva@plus10.de>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(history): quarantine unreadable recovery files
* fix(history): preserve mixed recovery generations
* fix(history): reanchor reconciled live sessions
* fix(history): serialize final checkpoint queue
* fix(history): drain sleep shutdowns before disconnect
* fix(history): restore legacy wide sessions
* fix(history): preserve malformed mixed logs
* fix(history): preserve malformed log tails
* refactor tests to reduce file size
* refactor(history-recovery): extract freeze helper and improve test robus
- Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites
- Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes)
- Replace fixed sleep with deterministic wait for queued exclusive checkpoints
- Distinguish ENOENT (missing) from corrupt in history metadata reads
- Add ceiling-dimension restore test and torn-tail exclusion assertion
- Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures
- Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws
* test(history-recovery): consolidate checkpoint assertions
Wait for both the checkpoint call and set clear atomically to avoid
a timing race where the spy fires before the set is cleared.
* fix(persistence): fsync state writes so a rename is actually durable
`Store` wrote `orca-data.json` to a temp file and renamed it. rename() is
atomic for readers but says nothing about durability: without an fsync the
directory entry can reach disk before the data does. After power loss or a
hard crash the file can come back holding the previous state or, worse,
zero bytes — and `JSON.parse('')` throws, so an empty file takes the
full corrupt-file path rather than degrading.
This is the same empty-file symptom as #1158 from a different cause. That
issue fixed a logic path that persisted empty state and added the .bak ring
as a safety net; the ring also catches this, which is why it went unnoticed.
Recovery costs up to an hour of tabs/layouts/session state (backups are
throttled to >=1h spacing), and a user in their first hour has no backup
slot yet, so they land on defaults indistinguishable from a fresh install.
Both write paths now fsync the temp file *before* the rename, then fsync
the containing directory. Directory fsync is best-effort by design: Windows
cannot open a directory for fsync and some filesystems reject it, so it is
swallowed. The file fsync is the load-bearing part and works everywhere.
Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce.
The async path does not block the main thread.
The syscall-order test mocks `node:fs` and counts fsync targets at the
module boundary, asserting ['file', 'directory'] — proving the ordering
rather than inferring it from reading the implementation, since a fsync
after the rename would still pass every content assertion.
* test(persistence): make the syscall proof platform-aware and actually prove the order
Two problems, both found from CodeRabbit's Windows observation.
The assertion hardcoded ['file', 'directory']. Directory fsync is
deliberately best-effort — Windows cannot open a directory for fsync and
some filesystems reject it — so on Windows the helper swallows the failure,
only the file fsync is observed, and the test fails. The expectation now
probes the real platform instead of assuming, keeping the guarantee tight
where directory fsync works rather than dropping it everywhere.
Worse, the test did not prove what its name claimed. Moving the fsync to
*after* the rename still passes: the file is fsynced either way, and only
fsyncs were recorded, so the correct and broken orders produced an identical
log. Mutation-testing the "before rename" claim is what surfaced this — the
mutation passed.
The rename is now recorded in the same sequence, since it is the boundary
the ordering is defined against. Re-running the same mutation fails, so the
ordering claim is now backed by the test rather than asserted in a comment.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(github): resolve owner/repo through SSH Host aliases (#10284)
Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.
* fix(github): harden SSH alias resolution
* fix(github): align PR source and review head origin
* fix(github): pin number-based work item open to the repo source preference
Open-by-number and details still ran the upstream-first multi-candidate PR
probe, so a fork and its upstream sharing a PR number opened different PRs
than the list and start-point paths did once #10677 pinned those to origin.
Thread repo.issueSourcePreference through dispatchWorkItem, getWorkItemDetails,
getRepoWorkItem, and getRepoWorkItemDetails. getWorkItemByOwnerRepo is left
alone: explicit owner/repo already pins identity. auto/upstream/undefined keep
the multi-candidate probe.
Co-authored-by: Orca <help@stably.ai>
* test(github): enforce origin preference in review head origin resolution
The explicit origin preference must short-circuit before any identity probe, so no remote queries should occur. Add validation to reject unexpected remotes and tighten the test assertion to verify no remote get-url calls happen at all.
* fix(github): enforce origin preference in issue open-by-number lookup
listWorkItems and getWorkItem must share preference so origin/upstream
toggles cannot disagree. Explicit origin preference now fail-closes when
origin identity is unresolved (no bare-lookup fallback), matching the
PR candidate resolution rule.
---------
Co-authored-by: Orca <help@stably.ai>
* fix(windows): fail closed on unknown PTY identity
* Gate Windows tree-kill on PTY identity verification
Only taskkill when the identity probe returns `own`; skip tree-kill for
`unknown`/`foreign`/`absent` to avoid terminating unrelated processes.
Clarifies the fail-closed behavior across agent and plain shell teardown
paths, matching the POSIX descendant-snapshot discipline.
Local and remote repos that resolve to the same git identity collapse into a
single Project, but badgeColor is per-host repo metadata. The host-refresh merge
spread the fetched project over the previous one, so whichever host's rows landed
last repainted the color — a remote Orca server sharing a project name would
overwrite the color chosen locally.
The sidebar reads the per-host repo row and stayed correct, while the
create-worktree composer reads the merged project and went grey, which is how the
mismatch surfaced.
Anchor the merged color to the local host's repo row when the project has one, so
the result no longer depends on host fetch order. Remote-only projects keep their
own host color.
* test(e2e): make Codex typing-latency harness measure real echo latency
The local Codex typing-latency spec produced meaningless numbers. Four
defects, all fixed here:
1. False-positive readiness. `/Ask Codex|OpenAI/i` matched "OpenAI's
command-line coding agent" on the *sign-in* screen, so the test went
"ready" against a login prompt and measured typing into a non-composer.
Now gated on the composer status bar (`/Context \d+% used/i`), which
only the live composer draws. Banner text is unusable: the serialized
buffer interleaves ANSI escapes through those glyphs.
2. Missing auth. The E2E profile runs an isolated HOME with a managed
CODEX_HOME that has no auth.json, guaranteeing the sign-in screen. The
launch now pins the real ~/.codex, and skips with a clear message when
auth.json is absent instead of silently measuring a login screen.
3. Measurement overhead swamped the signal. Per-key latency was measured
by polling getTerminalContent() every 5ms, so each sample was real echo
latency + full buffer serialize + CDP round-trip + poll granularity.
Measurement now happens entirely in-renderer: an in-page hook stamps
performance.now() on keydown (window capture phase, before xterm
forwards to the PTY) and again in xterm's onWriteParsed once the glyph
is in the viewport, with onRender giving a separate time-to-paint.
Samples are drained in one page.evaluate after typing ends — zero CDP
round-trips inside the measured window.
4. Thresholds were meaningless (median<150ms / worst<500ms). Replaced with
p50<35 / p95<60 / max<120, based on 10 local runs.
Also: 60 keystrokes instead of 24 with the first 10 discarded as warmup,
p50/p95/max instead of a lone median, lowercase-only input so the slash
and file-mention popups can't perturb later keys, an assertion that no
keystroke went unechoed, and a terminal dump on readiness failure.
Measured (10 local runs, headless, real Codex 0.145.0):
echo (key->parse) p50 21.6-22.6ms, p95 23.2-41.5ms, max 23.4-58.7ms
paint (key->render) p50 25.5-32.9ms, p95 34.3-49.7ms
A plain-shell control on the same probe reads p50 2.0ms / p95 3.0ms,
confirming the ~22ms is Codex composer redraw cost rather than a harness
floor — the old harness reported ~29-30ms for everything.
Co-authored-by: Orca <help@stably.ai>
* test(e2e): widen Codex latency tail budgets and assert terminal focus
Follow-up calibration over ~20 local runs: the per-key distribution is
unimodal at p50 21.3-22.7ms with rare isolated spikes to ~90-125ms that
are not a steady-state shift. Tail budgets move to p95<80 / max<150 so
only a sustained regression fails; p50<35 still gates the steady state.
Also assert the xterm helper textarea actually took focus. One run typed
all 60 keys with only 5 parse events because focus was lost, which
previously surfaced as an opaque sample-count mismatch.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default
Zellij and other multiplexers copy via OSC 52. Empty Pc is a valid XTerm
default for clipboard, but we rejected it, and the feature defaulted off so
copy silently failed inside Zellij. Accept empty Pc as clipboard, default the
setting on (query still blocked; size capped), and surface Zellij in settings.
Closes#10567
* fix(review): make the OSC 52 default actually reach existing installs
Review fixes for #10588:
- Persistence: profiles saved under the old off default persisted `false`,
which is indistinguishable from a real opt-out, so the default flip never
reached #10567's reporter. Added the repo's one-shot stamp
(terminalAllowOsc52ClipboardDefaultedOnForAllUsers) so unmigrated profiles
flip once and a later opt-out sticks.
- Replay: reattach/cold-restore re-writes recorded PTY bytes through the same
parser, so a stale `\e]52;c;...` silently clobbered the clipboard on every
restart. Gated behind isPaneReplaying via a new resolveOsc52ClipboardGate.
- Blocked toast latches once per renderer session and could be burned by a
pre-hydration read; it now fires only for a real opt-out.
- An empty Pd decoded to '' and, with the gate default-on, silently blanked
the clipboard. Now rejected as invalid.
- Localization: en.json is bundled and the catalog beats the code fallback,
so all three copy changes were inert. Resynced across five locales.
- Corrected the empty-Pc rationale: tmux (not Zellij) emits `\e]52;;<b64>`.
* test(terminal): cover the OSC 52 gate wiring and settings copy
Extracts createOsc52OscHandler so the replay/hydration gate wiring is
covered, not just the pure gate — dropping the isReplaying getter now
fails a test instead of passing silently.
Adds catalog assertions for the two OSC 52 settings strings. Only the
toast key was pinned, so the same inert-copy regression (code fallback
edited, bundled en.json not) could still ship for the settings pane.
* docs(settings): note that the OSC 52 default only covers new profiles
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): migrate the web settings store to the OSC 52 default-on flip
The default-on flip only reached the Electron store. The web/remote client
keeps its own settings in localStorage, so a profile that persisted the old
`false` there stayed opted out — the same bug the Electron migration fixed,
in the second store.
Extract the migration into shared/osc52-clipboard-settings.ts and call it
from both stores. Also coalesce OSC 52 writes onto a microtask so a hostile
chunk of ~15-byte sequences cannot fan out into a million clipboard writes,
and latch the blocked-write toast after it renders rather than before.
* feat(terminal): tell users when the OSC 52 flip overrides their opt-out
The default-on migration cannot distinguish a deliberate opt-out from a
profile that simply never touched the setting — both persisted `false` under
the old default. Flipping everyone is the only way to fix#10567 for existing
installs, but doing it silently reverses a security choice the user made.
Arm a one-shot notice at load when the migration overrides a persisted
`false`, on both settings stores, and show it once the renderer hydrates.
Profiles that never opted out are never notified.
* fix(terminal): clear the OSC 52 notice after it renders, not before
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the web OSC 52 notice armed against an unmigrated host
The host store always projects osc52ClipboardDefaultOnNoticePending, so the
plain spread in the web client's runtime UI merge overwrote an arm raised by
its own localStorage settings migration — flipping the opt-out in silence.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): stop the OSC 52 notice overclaiming, and cover it
Round-3 review fixes:
- Rename the arming predicate to osc52ClipboardDefaultOnOverridesPersistedOff.
Both stores rewrite the whole settings object on every save, so every profile
saved under the old off default holds `false` — the deliberate-opt-out cohort
is not distinguishable on disk. Name, docs and test names now say so.
- Read settings before the UI snapshot in readLocalWebUIState: getStoredSettings()
arms the notice, so reading first snapshotted a pre-arm state that callers wrote
back, erasing an arm the stamp can never raise again.
- Give the notice toast a stable id; StrictMode re-runs the effect against the
same closure, so the early return cannot catch the second pass.
- Restore guardParserHandler parity in the coalescer microtask.
- Drop the unverified Zellij claim justifying all-selections routing; that routing
predates this branch and PRIMARY routing stays an open question.
- Cover the notice hook (order, single-fire, deep-link), the armed flag reaching
disk and surviving a clear, and pin the notice catalog to its code fallbacks.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin OSC 52 setting discovery by product name
The migration notice says to turn it off in Terminal settings, so searching
Zellij/Grok/tmux has to find it. Also note why the OSC 52 write-back clauses
stay despite an unrelated always-true clause in the same condition.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): consume the OSC 52 notice on close, and cover the guards it relies on
The notice was cleared the moment the toast was enqueued, so a quit inside its
15s window spent the profile's only warning on a launch where nothing was ever
seen — and the settings stamp means it can never re-arm. Clear on
onAutoClose/onDismiss instead, plus explicitly in the action handler, because
sonner's action path deletes the toast without firing onDismiss.
Also closes three coverage gaps a review found:
- ui.set must accept osc52ClipboardDefaultOnNoticePending. The update schema is
strict, so dropping the key rejects the whole call rather than stripping it,
and the renderer only logs that failure — every paired client would re-toast
forever with nothing red.
- the coalescer's try/catch and .catch had no test; the rejection case needs a
plain function because vi.fn tracks settled results and hides the leak.
- pin that every selection kind (including bare `p`) lands in the system
clipboard, so routing PRIMARY separately later is a deliberate break.
Co-authored-by: Orca <help@stably.ai>
* test(web): pin that ui.get arms the OSC 52 notice when it runs the migration
readLocalWebUIState reads settings before the UI blob so the migration's arm is
in place before the snapshot every caller writes back. Seeding localStorage
after install is what makes ui.get the first settings read, and therefore what
makes swapping those two lines fail.
Co-authored-by: Orca <help@stably.ai>
* test(store): cover the OSC 52 notice clear and its hydration
The clear sets local state before persisting so a rejected ui.set cannot leave
the toast re-firing for the rest of the session; losing the persist only re-arms
the notice next launch.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): state the real residual risk of default-on OSC 52
Three comment corrections from review:
- the safety note claimed exfil was the risk; queries are blocked, so it isn't.
The actual accepted risk is execute-on-paste: decoded text goes to the
clipboard verbatim, newlines included. Filtering here would break multi-line
TUI copies, which is the feature; bracketed paste is where that is handled,
and kitty/Ghostty take the same posture.
- the coalescer bounds a flood per parse yield, not overall.
- the replay gate reads at parse time while queued live bytes are drained
before the guard engages, so a copy racing a reattach is dropped silently.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): close the four OSC 52 gaps a full revert walked through
Mutation testing found four assertions that stayed green against the very
change they were written to pin.
The notice suite passed 8/9 against a complete revert to clear-at-enqueue:
`calls[0][1][callback]?.()` is a silent no-op when the option is absent, and
the call count was already satisfied by the enqueue-clear, so nothing
separated "cleared by this callback" from "cleared earlier". Assert the
option exists and the notice is unspent before invoking it.
The stable toast id was deletable with all 9 green despite the adjacent
comment calling it load-bearing for StrictMode. Pin it.
The blocked toast's latch-after-throw fix was unproven: both orderings pass
when `toast.info` succeeds. Only a throwing first call tells them apart.
Deleting the hook call in App.tsx silenced the desktop notice with every
suite green. Pin it alongside the static Toaster import, since sonner drops
a toast enqueued before any Toaster subscribes and never replays it.
Also retone the coalescer-latch comment, which claimed the reset ordering
was load-bearing on its own; the try/catch reaches the same end, so the
test binds the pair.
All four verified green->red by mutation, then restored.
* test(terminal): cover the OSC 52 notice and its guards
Add tests pinning the static Toaster mount required to prevent notice dropout (#10567), the stable toast ID deduping StrictMode double-invokes, that the notice stays unspent on toast throws, and that flush-latch guards prevent silent consumption across error boundaries.
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
* feat(source-control-ai): add {linkedIssue} recipe variable for commit and PR prompts
Custom commit-message and pull-request recipes can now reference the GitHub
issue linked to the workspace, so a template like "Fixes #{linkedIssue}" lands
the closing trailer without the user retyping the number.
- register `linkedIssue` on the commitMessage and pullRequest actions only,
with the VARIABLE_INFO entry the chip hover card requires
- substitute unconditionally via `formatLinkedIssueTemplateValue` (empty string
when nothing resolves) so the token never survives into a prompt; enrich the
draft context conditionally via `withLinkedIssueDraftContext` so unlinked
workspaces keep their existing context shape
- attach at the 7 call boundaries (runtime commit x2, runtime PR shared, IPC
commit x2, IPC PR x2); the pure git gather stays pure
- validate the renderer-supplied worktreeId against the request path and repoId
before any meta read, comparing SSH paths as raw strings so a Windows host
cannot rewrite a remote POSIX path
- built-in prompts are unchanged; no GitLab dual-read and no default trailer
* fix(source-control-ai): resolve {linkedIssue} adversarial review findings
Addresses 13 of the 14 findings from the {linkedIssue} code review
(6 minor, 8 nit, 0 critical, 0 major); Issue 5 (GitLab provider naming)
is deferred to design Open Question 3 as product expansion.
Behavior:
- Dialog previews the workspace's real linked issue instead of the
synthetic 123, in both the chip hover card and the plan preview, so an
unlinked workspace previews the `Fixes #` it will actually generate.
Settings dry-runs stay fully synthetic.
- Reject non-positive, fractional and unsafe-integer issue numbers at the
IPC resolver via a shared isLinkedIssueNumber predicate, so corrupt meta
never reaches a draft context (previously -7 rendered `Fixes #-7` and
1e21 rendered `Fixes #1e+21`).
- Fail closed on an empty-string repoId instead of skipping the cross-check.
Structure:
- Split the variable registry into source-control-ai-action-variables.ts
and re-export it, restoring max-lines headroom with no consumer churn
and no lint disable.
- Constrain withLinkedIssueDraftContext to contexts declaring linkedIssue.
- Move the misplaced shared imports into their import group.
Docs and tests:
- Document that the IPC id/path validator guards relay/CLI/future callers,
not the renderer (whose path is id-derived), and rename the three tests
that read as proof of a protection that cannot fire.
- Add PR-side coverage that was missing: three git:generatePullRequestFields
handler tests, a built-in PR prompt no-leak guard, and the runtime PR
unlinked case.
- Replace the coincidental '42' assertion with a fixture-unique sentinel.
- Type the runtime worktree fixture with satisfies, which surfaced and
fixed pre-existing drift in its git sub-object.
- Add an e2e case covering the preload -> main -> meta -> template chain.
Co-authored-by: Orca <help@stably.ai>
* fix(source-control-ai): resolve {linkedIssue} adversarial re-review findings
Addresses all 8 findings from the {linkedIssue} code re-review
(2 minor, 6 nit, 0 critical, 0 major); none deferred.
Behavior:
- Revert the variableOverrides parameter on planSourceControlTextGeneration.
Its result is a Save/Generate gate, not a preview, and the recipe it
validates is saved repo- or globally scoped -- so rendering it against the
active workspace disabled both buttons with "Command input is empty." for a
{linkedIssue}-only template on any unlinked workspace, blocking a global
settings write. Validation is synthetic again; chip previews are unchanged.
- Make the chip hover card additive instead of either/or. A supplied preview
now appends a "This workspace" sample below the description and Example
rather than replacing them, so the GitLab-empty and dangling `Fixes #`
warning survives on the two dialogs where recipes are actually authored.
basePrompt keeps its preview-only shape, where the preview is the content.
Structure:
- Drop the registry re-export from source-control-ai-actions.ts and move the
last two consumers onto source-control-ai-action-variables, so one import
path per symbol keeps a grep of the registry's consumers complete.
- Split the registry/helper suites into source-control-ai-action-variables.test.ts
so each test file mirrors its module.
Tests:
- Cover the Save/Generate gate at the canRunGeneration level for a bare
{linkedIssue} recipe on linked and unlinked workspaces, with a negative
control proving the buttons can still be disabled.
- Cover the chip hover card directly; the dialog tests mock it away.
- Guard the PR mismatched-id test with toHaveLength(1) so it cannot pass
vacuously on an unrelated early return.
- Add an unlinked-workspace e2e case (saw-issue:empty), which is what
distinguishes a real resolver from one that always returns a number.
Spec now runs green: 3 passed.
- Rename the dialog test that claimed a synthetic-fallback assertion it did
not make, and route its renders through one shared helper.
Docs are worktree-local (.gitignore:84 ignores docs/**): the design doc's
plan-preview and chip-surface claims, the manual QA rows, and both reviews'
statements about pre-existing PR-handler tests are corrected there.
* fix(source-control-ai): make the {linkedIssue} e2e guard and dialog test falsifiable
The e2e unlinked case extracted the echoed issue with `ORCA_E2E_ISSUE=(\d*)`,
which matches zero digits in front of an unexpanded `{linkedIssue}` and reported
it as `empty` — so the case that exists to catch a literal token surviving into
a prompt passed on exactly that regression. Capture the whole line instead: a
literal now arrives as `saw-issue:{linkedIssue}` and fails, verified by dropping
the substitution key for unlinked contexts and watching the case go red.
Also drop the inert `not.toContain('Command input is empty.')` assertion — that
copy is click-driven `generationError` state and this suite renders statically,
so it could never fail; the claim it reached for is carried by the plan test.
Rename two plan tests off the "plan preview" framing the design now rejects.
Local review artifacts (design doc, implementation notes, final review) were
swept to match the tree in the same pass; they are gitignored here.
* Resolve {linkedIssue} from live metadata, not cache
Resolved worktrees are cached for a second, causing commit and PR
generation to use stale linked-issue state. Hosts now implement
getWorktreeLinkedIssue to provide fresh issue metadata by worktree id,
with proper fallback for unlinked workspaces. Updates both commit
message and PR field generation paths; includes integration and e2e
coverage.
* Keep cached linkedIssue when metadata is unavailable
Return undefined from getWorktreeLinkedIssue when live metadata cannot be read
(store not ready), distinguishing it from null (unlinked). The caller now falls
back to the cached worktree value instead of treating unavailable as unlinked.
Also extract the linked-issue echo generator as a shared e2e test helper.
---------
Co-authored-by: Orca <help@stably.ai>
* Fix ssh-relay install on hosts with split shell/SFTP namespaces
On Synology DSM and similar hosts, the SSH shell and SFTP subsystem expose
different absolute paths for the same directory (e.g., /var/services/homes/alice
vs /homes/alice). The relay installer silently picked the wrong path and failed
discovery. This fix implements SFTP namespace detection: each install creates an
unguessable ownership marker and probes both namespaces to detect divergence.
When paths differ, SFTP writes redirect to the candidate namespace while shell
commands keep the canonical path. Markers are random tokens redacted from logs.
* Fix ssh-relay install on hosts with split shell/SFTP namespaces
Strengthen path validation to catch traversal and empty segments in
absolute POSIX paths, preventing security issues. Improve split-namespace
handling with comprehensive wire tests for uploads and file writes.
Ensure system SSH connections bypass namespace mapping entirely rather
than attempting incorrect retargeting.