Commit Graph
7291 Commits
Author SHA1 Message Date
NeilandOrca c06bf64b48 test(e2e): make Codex typing-latency harness measure real echo latency (#10660)
* 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>
2026-07-25 20:06:19 -07:00
NeilandOrca ae614443b4 fix(crash-reporting): coalesce suppressed process-gone breadcrumbs so a recoverable-service crash loop can't erase the crash trail (#10648)
Co-authored-by: Orca <help@stably.ai>
2026-07-25 19:49:46 -07:00
NeilandOrca bc2bdfc52e test(gpu): pin the win32-only fallback invariant the macOS Graphite fix relies on (#10646)
Co-authored-by: Orca <help@stably.ai>
2026-07-25 19:49:29 -07:00
9042ef9792 fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default (#10588)
* 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>
2026-07-25 19:43:29 -07:00
Jinjing daba81e8cf fix(terminal): recover wedged panes after input (#10617)
* fix(terminal): recover wedged panes after input

* refactor(terminal): make wedge probes explicit

* fix(terminal): require quiet parser before recovery
2026-07-25 19:33:09 -07:00
JinjingandOrca ca70be8318 Add {linkedIssue} template variable for commit and PR generation (#10640)
* 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>
2026-07-25 19:31:44 -07:00
Jinjing 9f81f97a0c Fix SSH relay installs on split shell/SFTP namespaces (#10645)
* 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.
2026-07-25 19:20:17 -07:00
Jinjing 16482623b3 Move language setting to primary interface section (#10594)
* Move language setting to primary interface section

Language is a first-class presentation preference alongside Theme and
Zoom, so it shouldn't be buried in the Advanced disclosure. Advanced
now contains only platform-chrome controls.

* Show selected language in Interface section summary

Extracts the interface summary logic into a dedicated module that includes theme, language, and font selections. This makes the chosen language visible in the collapsed Interface section summary, addressing the discoverability issue where language was hidden until Advanced was expanded.
2026-07-25 19:02:32 -07:00
NeilandOrcaWin 2e6467710a fix(mac): disable unstable Skia Graphite renderer (#10643)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-25 18:31:40 -07:00
Jinjing 17617b1a6c fix(terminal-pane): resolve React #185 setState loop via state identity (#10632)
Preserve object identity in pane-title overlay rect state to fix infinite
re-renders. A fresh {} literal creates a new reference on every call, causing
React to treat the state as changed even when logically equivalent, triggering
the layout effect to continuously re-measure and re-set state (React #185).

Add cold-park verdict flip telemetry to diagnose crash cluster C5: records
whether parking state churns in the field for next crash bundle analysis.

Document that AddressPicker crash (cluster C6) originates in radix-ui's
SelectItem unmount cleanup, not in our component code.
2026-07-25 18:09:23 -07:00
github-actions[bot] c4b7aeddd7 Update README downloads badge 2026-07-26 00:59:08 +00:00
Jinjing 912c2495fa Show Terminal and Window settings by default in Appearance pane (#10628)
Terminal and Window & Sidebar sections now expand alongside Interface by
default so users don't miss advanced settings. Sections remain independently
collapsible and each can be force-open on deep-link navigation without
collapsing siblings. Search disables toggles to prevent unexpected collapse
when query clears. Remove unused "ghostty" translation key; product name
stays untranslated for search consistency.
2026-07-25 17:42:34 -07:00
JinjingandOrca 468f5b77b5 test(e2e): fix two stale E2E specs failing on main (runtime host seeding, alt-screen snapshot) (#10614)
* test(e2e): register a real runtime host and publish the alt-screen frame as its snapshot

Two long-running scheduled-E2E failures on main were stale test setup, not
product defects.

`onboarding.spec.ts:420` seeded the Active Server by faking a runtime
environment in the renderer store and writing `activeRuntimeEnvironmentId`
through the generic `settings:set` IPC. Since #10011 that setter strips the
key, and the dedicated `settings:set-active-runtime-environment-preference`
handler resolves the id against the main-process environment store — CI
logged `RuntimeEnvironmentStoreError: Unknown environment: env-e2e` from
`runtimeEnvironments:subscribe`/`:call` alongside the assertion failure.
Register the host for real via `runtimeEnvironments:addFromPairingCode`
(offline; no live server) and write the preference through its own channel.

`terminal-tab-switch-visual-restore.spec.ts:604` wrote alt-screen frames
straight into the renderer's xterm, so those bytes never transited the PTY
and main's model could not contain them. On cycle 0 the freshly spawned
shell still has queued startup output, so hiding the pane makes main's
hidden-delivery gate drop bytes and latch a reveal restore, which repaints
main's snapshot over the fabricated frame; later cycles run against an idle
shell and survive. Arm the existing `setHiddenSnapshotOverride` seam (already
used by sibling tests in this file) with the same frame so the live-write and
restore paths render identically, and keep the `markerPresent` assertion.

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

* test(e2e): keep the alt-screen restore path observable

Numbering the snapshot frame one higher than the live-written frame keeps
the marker assertion path-agnostic while leaving the frame number on
screen as the signal for which path painted. An unrecognised frame now
fails, and the per-cycle path is recorded rather than asserted because
which cycles latch a restore is load-dependent.

Frame authoring and readback move to a helper module; the additions
crossed the spec's max-lines cap.

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

* Escape regex metacharacters in alt-screen marker pattern

Marker is treated as a literal string, so escape regex metacharacters
to prevent them from being interpreted as regex syntax.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 15:58:40 -07:00
Neil eb545aaa59 fix(worktree): collapse duplicate "Local Mac" run targets in the host picker (#10472)
* fix(worktree): collapse duplicate "Local Mac" run targets in the host picker

A linked worktree added as its own project projects a second ready host
setup on the same project+host, so the run-target picker rendered N
identical "Local Mac" rows differing only by path. Only the first was
reachable — resolveWorkspaceCreationTarget takes the first project+host
match — so the extras pointed at paths that may no longer exist.

- Dedupe ready setup options by host in the picker (display fix for
  profiles that already hold duplicates).
- Canonicalize a stale draft's setup id to the setup the picker shows,
  so the displayed path is the path the workspace is created in.
- Reject a linked worktree at repos:add when its main checkout is
  already tracked, preventing new duplicates.

* fix(worktree): only dedupe a linked worktree against a git main checkout

Review follow-up: the repos:add guard matched any tracked repo on the main
checkout path, including a folder-kind record. A folder repo does not
project onto the same project as the git worktree, so matching it would
suppress a legitimate add without deduping anything.
2026-07-25 15:38:35 -07:00
Jinjing 56d3e2cb2e fix(editor): preserve Markdown focus handoffs (#10618)
* fix(editor): preserve Markdown focus handoffs

* fix(editor): scope focus requests to panes via viewStateId

When opening a file to focus it, tag the pending request with the pane's
viewStateId. This prevents split siblings from claiming each other's requests
and stops later remounts from stealing focus. Both Monaco and rich-markdown
editors now retire requests on mount.
2026-07-25 15:23:13 -07:00
Jinjing 97175ed92b fix(diff): keep scroll restore armed through layout shifts (#10615)
* fix(diff): keep scroll restore armed through layout shifts

* test: add scroll-restore convergence and user-scroll disarm cases

Verify that a converging restore withstands layout shifts and continues
retrying, while unmarked user scroll disarms the restore attempt. Stabilize
marks and offset objects across renders to preserve the bookkeeping state
that guards restoration retries.
2026-07-25 15:11:42 -07:00
Jinjing e564603d54 Observe daemon health failures and fix e2e test races (#10595)
- Add E2E_FORCE_DAEMON_HEALTH_UNREACHABLE env to simulate failed health checks
- Log when replacing a failed daemon, but stay silent on cold starts
- Simplify daemon-slow-health-check-preservation: use forced-unreachable health instead of SIGSTOP/SIGCONT
- Add --no-sandbox flag to electron launch args for Ubuntu CI
- Support extraEnv option in restart session launches
2026-07-25 13:42:28 -07:00
github-actions[bot] 9eff3728a3 Update README downloads badge 2026-07-25 18:35:56 +00:00
Brennan Benson 505967eba0 fix(runtime): report the effective ask timeout so a clamped wait isn't misreported (#10550)
The 30-min clamp was silent: the ask result carried no timeout figure, so
the CLI printed the value the caller *sent*. A worker passing
--timeout-ms 3600000 was told "ask timeout after 3600000ms" after only
30 min of real waiting — off by 2x, and accurate before this PR added the
clamp. Echo the effective budget on every ask return and print that.

Additive optional field; older clients fall back to the requested value.
2026-07-25 05:42:05 -07:00
Brennan Benson b31e9bb03d fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze workspace creation (#10540)
* fix(worktree): bound the .worktreeinclude copy so a huge include can't freeze creation

`.worktreeinclude` copying was bounded in entry count (1000) but unbounded in
bytes and files, and awaited inline during worktree creation. A repo listing
`node_modules` froze creation for minutes behind the create dialog on Linux and
Windows, where the fallback is a full `fs.cp` (macOS gets a cheap APFS clone).

Measure each copy-mode source against a cumulative budget (2 GB / 50k files)
before the first byte is written, and refuse the entries that bust it. Refused
entries ride the existing `CreateWorktreeResult.warning` channel so a workspace
never silently comes up missing its included files.

Pre-measurement rather than mid-copy abort: `fs.cp` ignores its `signal`
option, so a started copy cannot be cancelled and would strand a partial tree.
Refusing up front means there is no partial state to clean up.

* fix(worktree): don't charge bytes for copy-on-write clones, and bound the sizing walk

Two defects in the copy budget, both found by review:

- The byte limit was applied on macOS, where the copy is an APFS clonefile.
  Measured: a 2.7 GB tree clones in 22 ms and consumes no disk. Refusing it on
  a 2 GB byte ceiling denied work that was already free — a regression on the
  one platform this bound was never meant to touch. Bytes are now charged only
  when a byte-for-byte copy will actually run; the volume probe that decides
  this is the same cached df+diskutil pair the clone runs, and writes nothing,
  so the "refuse before the first byte" invariant holds. The entry limit still
  applies everywhere: inodes are real work even on the clone path.

- A refused entry consumed no budget, so a `.worktreeinclude` listing many
  over-budget directories paid a fresh full-limit walk for each one — up to
  1000 x 50,000 lstat calls, re-creating the stall this bounds. The walk is now
  charged against its own ceiling whatever the verdict.

Also documents that `admit()` must be awaited sequentially (CodeRabbit).

* fix(worktree): give the sizing walk headroom so one huge entry can't starve the rest

The walk ceiling added in the previous commit was seeded with maxEntries, the
same number the entry limit uses. Sizing an entry that busts the file-count
limit walks maxEntries + 1, driving the ceiling negative, so every later
`.worktreeinclude` entry was refused without being measured at all.

That regressed the common case: a repo listing `node_modules` plus `.env` used
to get `.env`; it silently got nothing. Reproduced, and now covered by a test
that fails when the headroom is removed.

The walk now gets 5x the entry budget, so total sizing work stays bounded
(<=250k lstat per materialization, vs the 1000 x 50k this ceiling exists to
prevent) while ordinary lists never reach it. Entries refused because earlier
ones exhausted the walk report a distinct 'sizing' reason, so the warning stops
quoting size limits at a 4-byte file that was never measured.

* fix(worktree): bill a failed clone's bytes, and blame the right ceiling

Two follow-on defects from the copy-on-write fix:

- A predicted APFS clone that then failed mid-copy (EPERM, ENOSPC) fell through
  to a real `fs.cp` whose bytes were never charged, because the entry had been
  admitted on the premise that cloning is free. That reopened the unbounded
  copy on macOS. The measured size is already known, so the fallback now bills
  it and refuses if it no longer fits, reporting the entry as skipped instead
  of silently copying gigabytes. A clone that was never viable
  (ApfsCloneUnavailableError) was already charged as a real copy, so that path
  keeps falling back as before.

- The walk ceiling is also applied inside the measurement via
  min(remainingEntries, remainingWalk), and when the walk term bound, the
  refusal was still reported as 'entries' — telling the user a 3-file directory
  busted a 4-file limit. It now attributes to whichever ceiling actually bound.

Also fixes the singular warning text, which said "entry X was not copied ...
copying them would exceed ... Copy them in manually".

* fix(worktree): flag a partial clone leftover, cap the warning, cover two branches

- A clone that fails partway only removes an *empty* reservation, so leftovers
  can survive at the target. Reporting that entry as simply "not copied" sent
  the user to copy it in manually, straight into a half-populated directory.
  Those skips now carry mayBePartial and the warning says to check the path
  first. Cleaning up the leftovers stays the deferred follow-up it already was.

- The warning enumerated every skipped path. `.worktreeinclude` allows 1000
  entries and all of them can be skipped, so it now names five and counts the
  rest — an unbounded string is a poor look in a PR about bounds.

- Two load-bearing branches had no test, both proven by surviving mutants: the
  `bytesAreCopied` short-circuit (reachable when a wedged df/diskutil makes the
  volume probe answer "no clone", so bytes are charged up front and must not be
  billed twice), and chargeBytes actually consuming budget for later entries.

* fix(worktree): only flag directory clones as partial, and cap that list too

- mayBePartial was set for every refused clone fallback, but only a *directory*
  clone can leave anything behind: the file path clones into a temp name and
  publishes with link(2), so a failure leaves nothing at the target. Sending
  the user to inspect a path that does not exist is its own small lie.

- The partial-copy sentence sliced to five names without the "and N more" that
  the other sentence appends, so entries past the fifth were surfaced nowhere.
  Both sentences now share one nameList helper.
2026-07-25 05:11:03 -07:00
Brennan Benson 159057c5d4 test(git): cover the false-positive class the header fix also removes (#10547)
The old anchored regex matched neither branch on a `[section "sub"]key = value`
line, so the parser never left `[core]` and credited the next indented line to
it — reporting sparse for a worktree git says is not. Fails on the pre-fix
parser (returns true where git reports unset).
2026-07-25 04:36:40 -07:00
Brennan Benson baf25785e0 fix(tasks): keep repos with a pending remote-identity probe in the picker (#10527)
* fix(tasks): keep repos with a pending remote-identity probe in the picker

Task-repo eligibility filtered on `hasProjectRemoteIdentity`, which is
populated by a background `git remote -v` probe. When the probe could not
reach git — an SSH-hosted repo whose connection is not up yet, a cold
launch — the repo silently vanished from the Tasks picker and stayed
hidden for the full 5-minute negative-cache TTL, even after the host came
back. GitHub repos were largely shielded because a persisted `upstream`
satisfies the identity projection through a different route; GitLab and
other providers depend on the probe.

Distinguish unknown from settled instead of hiding both:

- `probeGitRemoteIdentity` reports `resolved` / `no-remote` (git answered,
  no usable remote) / `unavailable` (never reached git).
- Enrichment persists `gitRemoteIdentity: null` only on `no-remote`,
  mirroring the existing `upstream: null` "not a fork" marker. An
  unreachable host leaves the identity undefined.
- Persistence keeps the explicit `null` instead of dropping it.
- `getTaskEligibleRepos` keeps a repo whose identity is still pending;
  folders and settled remote-less repos stay filtered out.

* test(tasks): cover the SSH probe exec paths for remote-identity status

Addresses CodeRabbit review: the unavailable-on-error case only exercised
the local git runner. Adds a connected-provider whose exec rejects, and an
SSH repo git answered for with no remotes.

* test(tasks): pin that a settled no-remote repo still resolves once it gains a remote

Three independent reviewers flagged that the candidate filter's `!repo.gitRemoteIdentity`
looks like an oversight next to the new null marker. Tightening it to `=== undefined`
would silently stop detecting a remote added after the marker landed. Document that the
re-probe is deliberate and pin the behavior with a test.
2026-07-25 04:04:19 -07:00
Brennan Benson 713fa40505 fix(runtime): reserve long-poll headroom so orchestration.ask can't starve terminal.wait (#10529)
* fix(runtime): reserve long-poll headroom so orchestration.ask can't starve waits

orchestration.ask joined the long-poll set, which also opted it into the
single server-wide activeLongPolls counter. Because ask blocks on a reply
for its full timeout (600 s default, previously unbounded via a caller
timeoutMs), 16 asking workers could hold every slot and shed terminal.wait
and check --wait with runtime_busy for every other client — mobile, web,
CLI, SSH and relay all share this runtime.

Meter ask as its own long-poll class with a sub-cap of half the budget, and
clamp the caller-supplied timeoutMs at 30 min. The keepalive and abort-signal
wiring that motivated the original change is unchanged.

* test(runtime): cover the ask sub-cap and counter release on the WebSocket path

The admission fence is shared by both transports but only the Unix-socket
path was exercised, so a WS-only regression in admitLongPoll/releaseLongPoll
would have shipped silently. Drives handleWebSocketMessage with a 'runtime'
scoped device (orchestration.ask is absent from the mobile allowlist) and
asserts the overflow ask is shed without burning a reserved slot, that
check --wait still gets the other half, and that both counters return to
zero when the socket closes.
2026-07-25 04:04:12 -07:00
Brennan Benson 9d02782969 fix(git): read core.sparseCheckout the way git does (#10537)
* fix(git): read core.sparseCheckout the way git does

Sparse-checkout detection parsed git config line-by-line and only accepted a
section header alone on its line, so git's legal same-line form
`[core] sparseCheckout = true` matched neither branch and was silently skipped:
a genuinely sparse worktree lost its badge and partial-checkout warning. It also
read `config.worktree` unconditionally, although git honors that file only while
extensions.worktreeConfig is on, so a stale worktree config could override the
repo's real setting.

Headers are now consumed left-to-right off each line (further headers and one
assignment may follow), and config.worktree is read only behind the extension
gate. Every new expectation was confirmed against real `git config --get`.

* test(git): correct what git actually does with a trailing-junk config value

Git does not reject `[core] sparseCheckout = true bogus = false` outright: it
parses the line and takes the whole tail as one value (`git config --list`
reports `core.sparsecheckout=true bogus = false`), then fails only the boolean
coercion. The expectation is unchanged; the comment now matches the binary.
2026-07-25 03:51:26 -07:00
Brennan Benson fc513233cb fix(release-cut): gate an explicit RC against its own series (#10525)
* fix(release-cut): gate an explicit RC against its own series

semver_gt compares through strip_pre(), so the explicit-version override
only ever checked the stable line: 1.4.156-rc.0 read as 1.4.156, cleared
a 1.4.155 stable, and republished an RC below what clients already run.
Anchor a prerelease request on highest_rc_for_base -- the same rc history
the kind path uses -- so the override can only advance the series.

Two sibling gaps in the same block:
- version_suffix was silently dropped when version was set, because the
  append lives in the kind branch the override skips.
- the shape regex rejected X.Y.Z-rc.N.suffix, so a suffixed RC the rc
  path can produce could never be re-cut explicitly.

* fix(release-cut): close both ends of the rc-number range the gate compares

The new explicit-rc gate compares with `[[ -le ]]`, i.e. bash machine-width
integers, and the author closed only the low end. Past INTMAX bash saturates,
so `version=1.4.156-rc.99999999999999999999` reads as "above the published
rc.3" and the gate falls open — then the tag it cuts pins
highest_rc_for_base at 1e20 for that base forever, and every later cut wraps
to a lower rc the fleet never updates to. Bound the rc number to nine digits.

Also reject leading zeros on an all-digit prerelease identifier. `npm version`
renormalizes rc.4.01 to rc.4.1 while the tag step keeps the literal input, so
the shipped package.json version and its own release tag name different
releases. The explicit path's embedded identifier now goes through the same
validator the kind path uses instead of only the shape regex.

* fix(release-cut): stop the refusal pointing minor/major RCs at the wrong series

kind=rc derives its base from bump(latest_stable, patch), so the remedy the
refusal suggested only works when the requested base *is* that next patch. A
1.5.0-rc.N series exists only because this override created it, so an operator
resuming a stuck 1.5.0-rc.2 was told to dispatch kind=rc, which would have cut
an unrelated 1.4.156-rc.4. Spell the condition out and give the fallback that
does work for a non-patch base.

Also correct the mechanism in the comment I added in 698c5beeaa: bash wraps
two's-complement, it does not saturate, which is why the hole is
value-dependent (rc.10000000000000000000 wraps negative and failed closed,
rc.99999999999999999999 wraps to 7766279631452241919 and sailed through).
And name both inputs in the suffix error, which now serves version_suffix and
the trailing identifier in version.

* fix(release-cut): count a suffixed RC from its commit subject, not just its tag

The new explicit-version gate only fails closed on a deleted tag because
highest_rc_for_base also reads `release: v<base>-rc.N` subjects. That fallback
did not parse the suffixed form: rcNumberFromTag accepts an optional
.identifier, rcNumberFromReleaseSubject did not, so `4.perf` failed its
`(\d+)(\s|$)` anchor and returned null.

So deleting a v1.4.156-rc.4.perf tag dropped the series back to rc.3, and an
explicit 1.4.156-rc.4 was waved through — below the rc.4.perf build
perf-channel clients already run. Same under-count already made kind=rc
recompute rc.4 over a deleted suffixed tag.

Mirror the tag form's optional identifier. Covered by a unit assertion and a
git-fixture test that both fail with this reverted.

* docs(release-cut): correct four operator-facing claims in the explicit path

All four are wording or consistency, no behavior change (harness: 26/26 before
and after, on bash 3.2 and bash 5.2).

- The trailing-identifier comment justified itself as preserving a shape that
  "can never be re-cut through the override", but re-cutting a suffixed rc at
  or below the series head is exactly what the new gate refuses. State what it
  actually admits: a second spelling of version=X.Y.Z-rc.N + version_suffix.
- version_suffix's input description still said "rc kind only" after this PR
  made it apply to an explicit bare X.Y.Z-rc.N.
- The suffix guard's own rc pattern was unbounded while the shape check twelve
  lines up is bounded to nine digits; reuse the bounded one so a later edit to
  either cannot silently drift.
- "which recovers the existing tag" was unconditional, but kind=rc recovery is
  also gated on tag_matches_current_ref, so a tag cut from a ref main has moved
  past advances to rc.N+1 instead.
2026-07-25 03:50:25 -07:00
NeilandOrca 7d47e9e1d8 fix(window): stop viewport reflow from moving screen geometry (#10543)
* feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes

Renderer timers stop across OS sleep, so a multi-hour `renderer_memory`
heartbeat gap looks identical to a wedged renderer. That ambiguity sent the
uber-crash investigation down a deadlock path that the telemetry later
disproved -- and a healthy machine's own trace shows a 427-minute mid-session
gap with reason=interval, so the gap alone proves nothing either way.

powerMonitor 'resume' was already wired for renderer wake recovery but left no
breadcrumb. Stamp suspend and report the measured span on resume so the next
freeze report can be told apart from a laptop lid.

* fix(diagnostics): only record sleeps long enough to hide a heartbeat

Adversarial review flagged that the first cut would flood the 30-entry
breadcrumb ring and evict the crash evidence it exists to explain. Measured on
7 days of pmset history: 70 user-visible sleep cycles, worst 60-min burst of 7.
Median span is 2 SECONDS -- only ~24% run past 60s, so most of that traffic
could never explain a gap anyway.

Cycles are counted Sleep -> next FULL Wake, since powerMonitor's resume maps to
NSWorkspaceDidWake, which does not fire for dark wake.

Drop the suspend breadcrumb (suspend now only stamps a timestamp) and emit a
single `system_slept` on resume, gated at 60s. That cuts 70 cycles to 17 over
the same week (worst burst 3) while still catching every sleep long enough to
swallow a 60s renderer heartbeat.

* test(diagnostics): assert resume listeners detach by identity

The off mock deleted by event name alone, so teardown detaching a
different closure than the one registered still passed -- a leak of the
real powerMonitor listener would have gone unnoticed. For 'suspend' that
leak has no other observable effect through the public API.

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

* fix(diagnostics): span from the first suspend across dark wake

powerMonitor 'resume' maps to NSWorkspaceDidWake, which does not fire for
dark wake, so macOS can deliver suspend -> suspend -> resume. Overwriting
the stamp reported only the trailing segment, and when that segment fell
under the 60s gate a 90-minute sleep recorded nothing at all -- leaving
the gap looking like the unexplained freeze this is meant to rule out.

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

* docs(diagnostics): correct the threshold rationale to match measurement

The comment claimed maintenance sleeps would flood the ring. Re-measuring
pmset over 6 days (78 sleeps, 29 full wakes, 51 dark wakes) shows they
resolve as DarkWake, which never fires 'resume', so they never recorded a
breadcrumb at all. Real rate is 29 breadcrumbs / 6 days, worst 60-minute
burst 4 against a 30-entry ring. The gate's actual job is narrower: skip
sleeps shorter than the 60s heartbeat, which cannot open a gap to explain.

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

* fix(window): stop viewport reflow from moving screen geometry

Blink's ScreenMetricsEmulator::Apply checks screen_size and view_position
before the desktop/mobile branch, so screenPosition:'desktop' does not make
them inert -- despite Electron documenting both as mobile-only. Passing the
content size and 0,0 overrode screen.width/availWidth and the window origin
for the whole 32ms hold, so a browser context menu opened mid-reflow would
translate against 0,0 and land in the wrong place (BrowserPane.tsx:3167).

Empty screenSize means 'no override', and an omitted viewPosition stays
nullopt in Electron's converter, so the real position survives. Only the
scale factor moves now, which is what the reflow actually needs.

Also record a breadcrumb when the restore exhausts its attempt budget: the
renderer is left at the wrong scale factor until some later reveal fixes it,
and that was previously silent.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 03:35:54 -07:00
Brennan Benson 5faf3b2ea0 fix(i18n): keep the macOS "Local Network" wording searchable in every locale (#10536)
Settings search indexed the macOS privacy-toggle name as English-only
aliases on the LAN keyword, so a Chinese user typing the term macOS
System Settings actually shows them (本地网络) got no hit — zh's catalog
value had been changed to 局域网 (LAN).

Split the two wordings onto their own catalog keys so each locale carries
both: 87620e6416 = LAN, fa3239cd42 = Local Network (its true content
hash). Localized values come from the repo's own LAN title translations
and from macOS 26's SecurityPrivacyExtension Localizable.loctable
(LOCAL_NETWORK), so nothing is invented. ja/ko/es already matched macOS
and are unchanged apart from gaining the LAN key.
2026-07-25 03:35:13 -07:00
Brennan Benson 9bc640addb fix(terminal): make primary-selection paste suppression single-shot (#10526)
* fix(terminal): make primary-selection paste suppression single-shot

Middle-clicking in the terminal armed a 750ms window that swallowed every
native paste event, not just Chromium's one follow-up — so a real Ctrl+V
inside that window was silently dropped on Linux.

Consume the deadline on first use (`shouldSuppress*` -> `consume*`) so the
arm owes exactly one event; 750ms stays as that event's expiry bound.

* test(terminal): cover the paste event xterm actually forwards to the PTY

Review follow-ups on the single-shot suppression change; no source change.

The new real-module file only synthesized `beforeinput`. An Electron
probe (Chromium 150) showed `paste` fires first, is cancelable, and
cancelling it at document capture suppresses `beforeinput` entirely — and
xterm registers `handlePasteEvent` for `paste` only, never `beforeinput`.
So `paste` is the sole event that can double-write the PTY, and it was
the one event the end-to-end file did not exercise. Add it; it fails with
the fix reverted.

Consuming mutates, so `isTerminalNativePasteTarget(...) && consume()` is
now load-bearing: swapped operands would burn the arm on an unrelated
paste and let the real follow-up double-paste. The guarding test asserted
only `defaultPrevented`, which survives the swap — assert `consume` is
never reached instead.

Rename the mocked-file case that claimed to prove single-shot. With the
module mocked its sequence is dictated by the mock; it pins that the hook
re-asks per event rather than caching, so name it that.

* test(terminal): name the beforeinput dispatcher for the event it dispatches

Round-2 review nit on my own round-1 change: once `dispatchClipboardPaste`
existed alongside it, a helper named `dispatchPaste` that dispatches
`beforeinput` inverted the reader's expectation. Match the sibling file's
`dispatchNativePasteBeforeInput` convention.
2026-07-25 03:33:53 -07:00
Brennan Benson 70b4d12067 perf(worktree): park the git-common existence poll while the window is hidden (#10528)
startGitCommonNarrowWatch was the only watch entry point that never received
WorktreePollerWindowVisibility, so its `worktrees/` existence poll kept
stat'ing every repo without a linked worktree forever in the background
(0.5 stat/sec/repo at the 2s default). Its siblings — the primary-metadata
snapshot poller and the non-darwin git-common polling — already park.

Threads visibility through and matches the snapshot poller's park/re-arm
pattern: the poll stops on the first hidden tick, and re-checks immediately on
onWindowBecameVisible so a worktrees dir created while hidden still upgrades to
the native stream and emits its create event. The visibility listener is
dropped in the dispose path.

darwin-only: the narrow watch is the `platform === 'darwin'` branch.
2026-07-25 03:33:10 -07:00
NeilandOrca f8b9b5c508 feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes (#10530)
* feat(diagnostics): record OS suspend/resume so sleep gaps aren't read as freezes

Renderer timers stop across OS sleep, so a multi-hour `renderer_memory`
heartbeat gap looks identical to a wedged renderer. That ambiguity sent the
uber-crash investigation down a deadlock path that the telemetry later
disproved -- and a healthy machine's own trace shows a 427-minute mid-session
gap with reason=interval, so the gap alone proves nothing either way.

powerMonitor 'resume' was already wired for renderer wake recovery but left no
breadcrumb. Stamp suspend and report the measured span on resume so the next
freeze report can be told apart from a laptop lid.

* fix(diagnostics): only record sleeps long enough to hide a heartbeat

Adversarial review flagged that the first cut would flood the 30-entry
breadcrumb ring and evict the crash evidence it exists to explain. Measured on
7 days of pmset history: 70 user-visible sleep cycles, worst 60-min burst of 7.
Median span is 2 SECONDS -- only ~24% run past 60s, so most of that traffic
could never explain a gap anyway.

Cycles are counted Sleep -> next FULL Wake, since powerMonitor's resume maps to
NSWorkspaceDidWake, which does not fire for dark wake.

Drop the suspend breadcrumb (suspend now only stamps a timestamp) and emit a
single `system_slept` on resume, gated at 60s. That cuts 70 cycles to 17 over
the same week (worst burst 3) while still catching every sleep long enough to
swallow a 60s renderer heartbeat.

* test(diagnostics): assert resume listeners detach by identity

The off mock deleted by event name alone, so teardown detaching a
different closure than the one registered still passed -- a leak of the
real powerMonitor listener would have gone unnoticed. For 'suspend' that
leak has no other observable effect through the public API.

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

* fix(diagnostics): span from the first suspend across dark wake

powerMonitor 'resume' maps to NSWorkspaceDidWake, which does not fire for
dark wake, so macOS can deliver suspend -> suspend -> resume. Overwriting
the stamp reported only the trailing segment, and when that segment fell
under the 60s gate a 90-minute sleep recorded nothing at all -- leaving
the gap looking like the unexplained freeze this is meant to rule out.

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

* docs(diagnostics): correct the threshold rationale to match measurement

The comment claimed maintenance sleeps would flood the ring. Re-measuring
pmset over 6 days (78 sleeps, 29 full wakes, 51 dark wakes) shows they
resolve as DarkWake, which never fires 'resume', so they never recorded a
breadcrumb at all. Real rate is 29 breadcrumbs / 6 days, worst 60-minute
burst 4 against a 30-entry ring. The gate's actual job is narrower: skip
sleeps shorter than the 60s heartbeat, which cannot open a gap to explain.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 03:11:59 -07:00
Brennan Benson ba434a4a93 fix(sidebar): stop a worktree drag preview from closing the Agent Dashboard (#10531)
The companion-board mutual-exclusion Effect keyed on
`workspaceBoardRenderedOpen`, which is `workspaceBoardOpen ||
workspaceBoardDragPreviewOpen`. WorktreeList sets the drag preview at the
start of every card drag — including a pure reorder within a group that
never opens the board — so dragging any card closed the Agent Dashboard
drawer, and cancelling the drag never restored it.

Key the Effect on `workspaceBoardOpen` so only the user actually opening
the board evicts the dashboard. The reciprocal Effect is unchanged.
2026-07-25 03:05:33 -07:00
Neil d8e0f112c6 perf(terminal): avoid repeated pending drain snapshots (#10163) 2026-07-25 02:38:20 -07:00
OrcaWin e1eca7f311 fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL (#10328)
* fix(agent-hooks): install OpenCode status plugin into the WSL guest so status works over WSL

OpenCode reports agent status via a JS plugin dropped into OPENCODE_CONFIG_DIR
(unlike Claude/Codex, which use managed hooks.json scripts). Over the WSL
runtime that plugin was never materialized inside the guest and
OPENCODE_CONFIG_DIR never crossed into the guest, so OpenCode status never
reached Orca's sidebar (the workspace stayed green). Codex already worked; this
was OpenCode-specific.

Mirror the SSH plugin-overlay path for WSL:
- Guest relay registers AGENT_HOOK_INSTALL_PLUGINS_METHOD, byte-caps the source,
  and materializes an OpenCode config overlay via PluginOverlayManager (the same
  electron-free path the SSH relay uses), returning overlayDirs.opencode.
- Host manager ships the plugin source over the existing stdio channel after
  installers run (and on mid-session reinstall) and records the guest overlay
  dir; -32601 / CONNECTION_LOST / DISPOSED are swallowed like ssh-relay-session.
- PTY env points OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR at the guest
  overlay; until the relay reports it (first spawn / older guest bundle) it drops
  those vars rather than crossing the Windows overlay path into WSL — so
  in-guest OpenCode falls back to its own config (pre-fix behavior, no
  regression).
- WSLENV passes OPENCODE_CONFIG_DIR/ORCA_OPENCODE_CONFIG_DIR through (/u for
  guest paths).

SSH is untouched: the same JSON-RPC constant is reused and the guest response
merely gains an optional overlayDirs field the SSH host ignores.

Runtime repro: native opencode launched in a WSL Orca terminal had
ORCA_AGENT_HOOK_PORT/ORCA_PANE_KEY but no OPENCODE_CONFIG_DIR and no Orca plugin
in ~/.config/opencode, so agentStatusByPaneKey stayed empty.

* fix(agent-hooks): stop the WSL OpenCode overlay leaking Windows paths and churning under running agents

Review fixes on top of the WSL OpenCode plugin install:

- Never cross a Windows OPENCODE_CONFIG_DIR into the guest. The /p flag was
  not a defensive default but WSLENV's translate-and-deliver flag, and
  buildWslRelaySpawnEnv spreads process.env while the daemon merge resurrects
  keys buildPtyHostEnv only deleted -- so a Windows value reached the guest as
  /mnt/c/... and was adopted as its OpenCode config root. Register the two
  vars only when the value is already a guest POSIX path.

- Make guest materialization idempotent. The overlay id is instance-scoped,
  and the host re-ships on every reinstall (60s after connect, and on later
  pane spawns), so materializeOpenCode's remove-and-rebuild wiped the config
  root under running agents and raced panes spawning against the path just
  handed to them. Rebuild only when the shipped source changed or the overlay
  went missing.

- Mirror the guest's default ~/.config/opencode (honouring XDG_CONFIG_HOME)
  when no explicit dir is discoverable, so pointing OPENCODE_CONFIG_DIR at the
  overlay no longer silently drops the user's models/agents/skills/mcp.

- Carry opencodeOverlayDir across relay relaunch; it is instance-keyed and on
  the distro's persistent filesystem, so dropping it only blanked status on
  panes spawned mid-relaunch.

* fix(agent-hooks): stop advertising a WSL OpenCode overlay the guest failed to rebuild

Round-2 review fixes:

- materializeOpenCode wipes before rebuilding, and every failure path after the
  wipe returns null leaving the dir present but plugin-less. The host treated
  that null the same as "no handler / teardown" and silently kept the previous
  value, so a pane could be pointed at an empty config root -- worse than the
  documented fallback of dropping the var. requestGuestOpenCodeOverlayDir now
  distinguishes 'none' (guest answered, no dir) from 'unavailable', and the
  manager clears the recorded dir on 'none'.

- The handler cache keyed only on plugin source, so a ~/.config/opencode created
  after the relay connected was never mirrored for the relay's lifetime. Key on
  the resolved source dir too, and validate the cache by the plugin file rather
  than the directory -- the directory is exactly what a failed rebuild leaves
  behind, so checking it alone made the bad state stick.

* fix(agent-hooks): don't mirror the XDG default OpenCode config into the WSL overlay

OPENCODE_CONFIG_DIR is APPENDED to OpenCode's config-dir list, not a
replacement for it. Verified against the shipped binary: the list is built as
[Path.config, ...project .opencode dirs, ...OPENCODE_CONFIG_DIR ? [it] : []],
and Path.config is derived independently from XDG_CONFIG_HOME/$HOME/.config.

So ~/.config/opencode is read whether or not Orca overrides the var, and the
earlier fallback that mirrored it into the overlay made OpenCode load the
user's config -- and their plugins -- twice. Resolve only an explicitly-set
dir, which is the one case that genuinely leaves the list when Orca overwrites
the variable. This also restores parity with the SSH and local paths.

* docs(agent-hooks): correct the WSL install-plugins cache comment and test framing

The per-call source-dir re-resolution comment still described the XDG default
branch that 6745eba90 removed. The relay's env is fixed for its lifetime and
the rc scan behind it is memoized, so the sourceDir cache key is defensive
rather than live -- say so, and retitle the test that simulates it by mutating
the captured env, so neither reads as coverage of a production scenario.
2026-07-25 02:30:59 -07:00
Neil f009500677 ci(release-cut): always show resolved commit, branch, and tag in summary (#10482) 2026-07-25 02:05:58 -07:00
Brennan Benson d9aa919e3c perf(terminal): drop the JSON structural pre-scan from the history read path (#10499)
* perf(terminal): drop the JSON structural pre-scan from the history read path

readTerminalHistoryJson/Async walked every character of checkpoint.json in
interpreted JS before handing the same string to native JSON.parse. On a
26.8MB checkpoint that scan measured 63-330ms — 2.7-12x the JSON.parse it
guards, and 57% of the whole read path — all of it synchronous main-thread
work. readTerminalHistoryJsonAsync exists so cold-restore reads do not block
the main thread; running the scan inline right after the async read defeated
its own stated purpose.

The scan also had a correctness cost: TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS
was 1_000_000, and oscLinks is unbounded at ~10 structural tokens per link, so
roughly 100k OSC-8 hyperlinks tripped the assert, history-reader swallowed the
throw to a null checkpoint, and the terminal restored blank — the same
user-visible loss #10479 just fixed for the byte cap.

checkpoint.json and meta.json are our own SerializeAddon output, not untrusted
input: the byte cap still bounds the read, and a corrupt file fails JSON.parse
into the same catch. The shared helper stays for the call sites that do handle
untrusted JSON.

* test(terminal): pin iterative JSON.parse for the dropped nesting-depth cap

The retired pre-scan enforced two limits; the new tests only covered the
structural-token half. Dropping the 128-level nesting cap is safe solely
because V8 parses JSON iteratively — depth costs heap, not stack — so a
deeply nested checkpoint parses instead of overflowing. Verified: 10M-deep
arrays and objects parse without throwing on V8 14.6.

That property is an engine guarantee this code now silently depends on, and
a recursive parser would abort the daemon outright rather than throw into
the callers' catch. The test fails on main with "JSON nesting exceeds 128
levels" and costs 4ms.

* docs(terminal): drop the rot-prone benchmark figure from the reader comment

Keeps the durable rationale for skipping the pre-scan (self-authored input,
byte cap still bounds the read, corrupt files land in the callers' existing
catch) and moves the "~57% of the read path" measurement to the PR body,
where it cannot go stale against a later change to this path.

Addresses the CodeRabbit comment-length nitpick against the repo's
one-line-if-possible comment guideline.
2026-07-25 00:41:18 -07:00
Brennan Benson a0971e0f9b test(terminal): pin checkpoint-only cold restore of a large checkpoint (#10500)
* test(terminal): pin checkpoint-only cold restore of a large checkpoint

Triaging a report that checkpoint-only cold restore renders blank at every
checkpoint size found no v1.4.156 regression: an A/B of v1.4.155 against
origin/main returned byte-identical ColdRestoreInfo at 1.01, 5.56, 11.30 and
20.61 MiB, all 300 marker lines intact on both refs. Blankness tracks the
meta.endedAt eligibility gate, not size — a cleanly ended session refuses to
cold-restore at any size, which is by design and unchanged between the refs.

What the triage did surface is a coverage gap. #10179's 16MiB checkpoint read
cap sat under what the unbounded writer emits, so a large checkpoint threw,
was swallowed to checkpoint=null, and the pane reopened empty; #10479 raised
the cap but nothing pinned the round trip it had broken. history-reader-memory
covers the bounded reader at its limit, not writer→reader recovery.

Adds that round trip through the real HistoryManager writer and HistoryReader
over a header-only log, and pins the endedAt gate that has now been mistaken
for a size regression twice. Fails at the pre-#10479 cap with the exact
production symptom (detectColdRestore returns null).

* test(terminal): fail loudly when writeSync is unavailable

writeLargeScrollback ignored writeSync's boolean, which is false when
xterm's private _core.writeSync goes away. The size assertions catch that
in the large-checkpoint test (416 bytes vs 16MiB), but the endedAt gate is
size-independent, so that test silently passed on an empty snapshot —
pinning the gate over no scrollback at all.

* test(terminal): cut large-checkpoint fixture peak RSS from 1.2GiB to 800MiB

The filler colored per line, not per cell as its comment claimed, so the
serialized seed only tracked plain-text size and needed 26k buffer rows to
clear 16MiB. The xterm buffer costs rows x cols, which made this single file
raise the whole src/main/daemon/ suite's peak RSS 5.2x (241MiB -> 1244MiB) —
a real OOM risk on CI right after #10299 bounded readers for that reason.

Carrying the bytes in SGR runs instead of rows reaches a larger seed from
5.5k rows: suite peak RSS 1244MiB -> 793MiB, and the margins improve too
(seed 25.16MiB = 1.57x the threshold vs 1.19x before, checkpoint.json
46.1MiB = 2.88x vs 1.20x). Also makes the fixture match its own comment and
be more representative of real colored agent output.

Re-verified all three mutations still fail: cap at 16MiB -> "expected null
not to be null"; endedAt gate deleted -> test 2 fails; writeSync
unavailable -> both fail.

* test(terminal): tighten timeout, reuse prod dir-name helper, dispose first

Review follow-ups, all test-only:
- Import getHistorySessionDirName instead of hand-rolling encodeURIComponent.
  Equivalent today, but that helper exists to absorb encoding changes, so the
  hand-rolled copy would silently diverge from the writer it is checking.
- Index FILLER_ROWS by its own length so growing the array cannot leave rows
  unused.
- 300_000ms -> 60_000ms. The file runs in ~2.8s and vitest's own default is
  30s; a 5-minute ceiling turns a hung regression into a stalled job rather
  than a failure. 60s keeps Windows headroom.
- Take the snapshot, dispose, then checkpoint, so a throwing checkpoint()
  cannot leak the buffer. Note this is memory-neutral, not a saving: peak RSS
  is set at getSnapshot(), where the buffer and the serialized strings
  coexist, and measured ~800MiB either way.

Mutations re-verified: cap at 16MiB -> "expected null not to be null";
endedAt gate deleted -> test 2 fails; writeSync unavailable -> both fail.
2026-07-25 00:04:34 -07:00
github-actions[bot] be0212e278 Update README downloads badge 2026-07-25 07:03:47 +00:00
Brennan Benson 7a01910f20 fix(skills): advance the release ledger at the cut so shipped revisions freeze (#10483)
* fix(skills): advance the release ledger at the cut so shipped revisions freeze

#10340 made the released-skill registry a function of the committed ledger
instead of a git tag walk, and #10460 reverted the cut step that advances that
ledger because it violated the #9119 contract (a version-only cut must not
regenerate or stage the content-addressed skill artifacts). Both were right;
the result is a ledger that never advances.

generate-skill-bundle-manifest.mjs:390 derives releasedCount solely from
release-mapping.json and :461 assigns a changed skill releaseRevision =
releasedCount + 1, while :518 protects only committedReleasedCounts[name] —
so index releasedCount is unprotected. A tag ships that tail revision, nothing
records it, and the next skill change rebuilds the same revision number over
different bytes. Installs carrying the shipped digest then match no snapshot
and degrade to unrecognized, which cannot be updated.

Restore the advance in a form the #9119 contract can keep enforcing:
--release now verifies that current-manifest.json and snapshot-registry.json
already match the ref being tagged, appends the mapping row, and writes only
release-mapping.json. The cut stages just that file, so it still cannot move a
content-addressed artifact — the failure #9119 guarded against — and now fails
loudly instead of recording a revision the tag does not ship.

The contract test is narrowed to match: it asserts the cut runs --release
(never --write) and stages exactly package.json and release-mapping.json.

* test(release-cut): close the staging bypasses the narrowed gate left open

The narrowed contract test anchored its `git add` scan to line start and
only inspected staged paths, so three ways to reintroduce #9119 stayed
green: a `git add` chained after `&&`, a write that never calls `git add`
at all, and `pnpm run generate:skill-bundle-manifest` — the package.json
alias for `--write`, which the hyphenated ban never matched. That last one
also passed the pre-#10460 assertions, so it was never covered.

Drop the anchor, require every `resources/skills` mention in the step to
be exactly what is staged, and ban the alias and `commit -a`. Comments are
stripped first so prose cannot trip a ban. Verified each bypass fails and
the real workflow passes.

* fix(release-cut): make the new provenance failure actionable to an operator

Verifying the content-addressed artifacts is the only new way the cut can
block, and it fails inside a step named "Bump package.json and tag" with a
lint-shaped message. That names the files and the command but not the two
things the operator needs: the regeneration has to land on main, and the
cut is safe to re-run afterwards. Say so.

Also pin down why assertReleasedHistoryPreserved takes the pre-append
mapping. It pairs with artifacts.releasedSnapshotCounts, which seeding
fixed before the row existed; handing it the post-append mapping makes
every cut throw "Released snapshot history is incomplete", which points
at tag fetching rather than the real cause. Nothing enforces the pairing.

* test(release-cut): gate the whole cut job, not just the bump step

Round-2 review defeated the previous gate twice, both proved by running
the full contract file green with #9119 reintroduced.

Every step in the cut job shares one workspace and one index, but the
contract test only inspected `Bump package.json and tag`. A step inserted
earlier could run --write and `git add resources/skills`, and the bump
step's own commit swept it into the version commit and the tag. Assert
job-wide instead: only the bump step may name the directory, and no step
may regenerate under either the flag or its package.json alias. That lives
in the generator suite because the contract file is at its max-lines cap.

Two regexes were also evadable. The mention scan required a trailing
slash, so a path held in a variable was invisible; it now matches the
directory itself. The `commit -a` ban matched nothing at all — `commit\s`
ate the only separator, so `-a`, `-am`, and `--all` all survived while
only a trailing `-a` was caught. `--allow-empty` stays allowed.

* fix(release-cut): assert the index, not the workflow text, before committing

Round-3 review defeated the job-wide grep three ways, each proved by
running both test files green with #9119 reintroduced into the tagged
commit: an `env:` block holding `--write` and `resources/skills`, a
composite action whose steps the workflow never spells out, and plain
shell concatenation (`root=resources; leaf=skills`).

Grepping shell source for path literals is inherently evadable, and the
previous fix only relocated round-2's variable-indirection hole one step
over. Move the invariant to where it cannot be dodged: immediately before
committing, the cut diffs its own index and refuses anything that is not
package.json or the release-mapping row. That does not care which step
staged what, or how the path was spelled.

The workflow grep stays as a cheap tripwire for literal spellings, now
paired with a positive assertion that the index guard exists and precedes
the commit — indirection cannot hide a missing guard. Mention matching
dedupes and trims quotes, since the guard names the row a second time.

* fix(release-cut): match the staged-path allowlist literally

`grep -vx` treats its patterns as regexes, so the `.` in `package.json`
matched any character: a staged `packageXjson` or a
`resources/skills/release-mappingXjson` was silently accepted by the
index guard. Verified both slip through `-vx` and are caught by `-vxF`.

Exercised the guard against a legitimate cut, an empty index, a staged
content-addressed artifact, paths containing a space and a non-ASCII
character (git quotes the latter, so it fails closed), and a staged
deletion. Only the two allowed paths pass.

* test(release-cut): assert the index guard aborts, not just that it exists

The positive assertion pinned the guard's shape and its position before
the commit, but not its effect: replacing `exit 1` with `:` left both
test files green while the cut logged the error and shipped the artifact
anyway. That is the same failure this whole gate keeps having — asserting
the shape of a defense rather than what it does.

Pin the abort too. Verified the neutered guard now fails the suite.

* test(release-cut): scope the abort check and catch clustered commit flags

Two holes in the guards this PR added, both in the same shape-not-effect
class the previous commit was meant to close.

The abort assertion's lazy match was not scoped to the guard's own block,
so it could borrow an `exit 1` from any later `if ... fi` in the step.
Degrading the guard to a warning while adding a plausible HEAD
precondition left every test green. Stop the match at the guard's `fi`.

The `commit -a` ban only matched when `a` led the flag cluster, so `-vam`,
`-va`, `-qam` and `-sam` all survived. That matters more than it looks:
`commit -a` stages at commit time, after the index guard has already
inspected a clean index, so it is the one way to defeat that guard. Match
`a` anywhere in a short-flag cluster; `--allow-empty` and `--amend` stay
allowed. Verified both mutants now fail.

* fix(release-cut): validate the commit, not the index, before tagging

The index guard asserted the wrong thing. `git commit` has a family of
forms that commit the working tree rather than the index — `-a`, `-i`,
`--only`, and a bare pathspec — so a rogue earlier step could leave
regenerated artifacts unstaged and any of those forms would carry them
into the tagged commit while the guard saw a clean index and passed.
Reproduced end to end: `git commit -i resources` put current-manifest.json
and snapshot-registry.json in the tag with all gates green, and
`--only resources` additionally dropped package.json from the tag.

Banning those flags one by one is the same enumeration game the earlier
rounds kept losing. Assert the outcome instead: after committing and
before tagging, diff-tree HEAD and refuse anything that is not
package.json or the release-mapping row. That is indifferent to which
step staged what and to how the commit was spelled.

Verified the whole family is now blocked (-i, --only, -a, -am, -vam,
pathspec, and an alias expanding to `commit -i`), that a stock commit and
an --allow-empty re-cut still pass, and that deleting, neutering,
un-anchoring, or relocating the guard each fails the suite.

* fix(release-cut): make the commit guard fail closed on a merge commit

Plain `git diff-tree` prints nothing for a merge commit, so the guard
would have passed silently instead of failing closed — the one direction
that matters on a release path. `-m --first-parent` reports the diff
against the first parent; verified byte-identical output for an ordinary
commit and still empty for the `--allow-empty` re-cut, so nothing else
changes. Not reachable today (nothing in the cut job creates a merge, and
npm version has no lifecycle hooks defined), but the failure mode is a
guard that looks like it ran.

Pin the flags in the assertion too, so neither dropping -m nor slipping in
a `--diff-filter` can weaken it without failing the suite.
2026-07-24 23:29:55 -07:00
Brennan Benson 9ae8f340ae fix(cli): explain SIGABRT serve exits instead of naming the signal (#10464)
* fix(cli): explain SIGABRT serve exits instead of naming the signal (#10461)

`orca serve` reported only "Orca serve exited via SIGABRT", which sent a P0
investigation down a code-signature path while a diagnostic crash report sat
unread on disk. On darwin + SIGABRT the signal-exit path now names the macOS
application-startup abort, its usual sandbox/SSH/CI causes, and points at
~/Library/Logs/DiagnosticReports/Orca-*.ips via the existing nextSteps channel.
Other platforms and signals get a clear message with no invented cause.

* fix(cli): stop asserting the SIGABRT exit happened at startup

* fix(cli): stop steering macOS SIGABRT users away from SSH serve
2026-07-24 23:28:26 -07:00
Brennan Benson 12fa5ff79e fix(mobile): heal an orphaned native-chat image paste across screen unmounts (#10480)
* fix(mobile): heal an orphaned native-chat image paste across screen unmounts

The stale-input marker lived in a per-screen `useRef`, but the condition it
tracks — a bracketed image paste sitting unsubmitted on the agent's composer
line — lives on the host and outlives the screen. Backing out of a session and
returning remounted the hook with an empty Set, so the next message submitted
on top of the orphaned paste and the agent received `<image path><text>`.

Move the marker to a module-level store keyed by terminal handle, and consult
and consume it from every write path that can submit the composer: the image
hook's text-only send, the controller send (which the chat overlay's question
card reaches directly, bypassing the image hook), and the ask-answer send.

Permission choices and the Escape cancel deliberately do NOT heal: they are
`enter: false` keys for an active overlay that swallows the clear, so healing
there would consume the marker without clearing the line and leave the next
real message corrupted. Desktop scopes its Ctrl+U the same way.

* fix(mobile): stop the ask heal from burning the marker on selector answers

The heal ran on every ask answer, but Claude's and Codex's selector shapes
cannot submit the composer: a single-select answer is a bare option digit and
every stepping group is written `enter: false` (the host coerces it), so the
clear is swallowed by the live overlay while the host still acks the write.
That consumed the one-shot marker and left the orphaned paste to corrupt the
next real message — the same failure this PR exists to fix, through a new door
that main did not have.

Scope the heal to the pasted-label shape, which does commit the composer.
Desktop splits it the same way: use-native-chat-interactive-send.ts routes only
the non-stepping answer through the clearing sender and never pre-clears
sendNativeChatAskAnswer.

Also pin the three deliberate skips (selector answer, permission choice, Escape
cancel) with tests, so the PR's central design argument is an invariant rather
than a comment, and guard the failed-heal toast with the generation check every
other error surface in answerAsk already uses.
2026-07-24 23:10:13 -07:00
Brennan Benson 6c03ecf8e1 fix(terminal): stop cold restore dropping all scrollback on large checkpoints (#10479)
* fix(terminal): stop cold restore dropping all scrollback on large checkpoints

The checkpoint read cap shipped without its write bound. history-reader.ts
reads checkpoint.json through a 16MiB cap that throws past the limit, and the
catch swallows it to checkpoint=null; history-manager.ts still writes the
checkpoint with an unbounded JSON.stringify. Every fallback then collapses
(stale-generation log, unlinked legacy scrollback.bin), so the terminal
reopens empty with nothing surfaced.

Raise the read cap to cover the largest checkpoint the writer can legitimately
emit, derived from the scrollback policy's 50k-row max preset so it cannot
drift back under the writer. A bound is kept so a corrupt file still cannot
OOM the main process.

Bounding the writer instead would not recover the scrollback: the stringify
throw lands in handleWriteError, which adds the session to disabledSessions
and permanently stops history recording for it.

* fix(terminal): anchor checkpoint read cap to its own reasoning

The cap was derived as 2 * LEGACY_TERMINAL_SCROLLBACK_BYTES_100_MB, but that
constant is a legacy byte-preset setting value with no other consumer, and the
50k-row bucket it was attributed to has no upper byte bound. Same value, stated
without the false policy linkage.

* fix(terminal): assert the checkpoint byte cap, correct its rationale

The retained oversized-checkpoint test passed with the byte guard removed
entirely — 200MB of NUL fails JSON.parse, so detectColdRestore returned null
either way. Assert the bounded reader directly, as the amplification test does.

Also: a 50k-row max preset of ordinary text measures ~14MB serialized, not
'far below' by an unbounded margin — per-cell-colored output can still exceed
the cap, which is what a writer-side snapshot trim has to fix.
2026-07-24 22:55:16 -07:00
Brennan Benson 6592c01592 fix(daemon): keep agent-completion detection alive on pre-v27 daemons (#10478)
* fix(daemon): keep agent-completion detection alive on pre-v27 daemons

DaemonPtyAdapter.inspectProcess() threw terminal_liveness_unavailable when
the connected daemon predated protocol v27. The intended provider-level
fallback only fires when a provider lacks inspectProcess, so for the daemon
adapter the throw propagated: agent-completion-coordinator swallowed it into
consecutiveInspectionErrors and retried forever, killing process-exit
completions and pending-title validation.

Daemons intentionally survive app updates, so updating in place with agent
terminals open routes those PTYs to a legacy adapter and permanently
disables agent-finished notifications until the terminal is recreated.

Compose the inspection client-side from getForegroundProcess, which v26
fully supports. No new wire traffic and no new daemon capability.

* test(daemon): pin null-foreground semantics on the pre-v27 inspect fallback

The legacy composition had no coverage for a null foreground, which is the
one daemon response shape whose semantics diverge from v27: there
inspectProcess goes through getAliveSession() and throws for a vanished
session, while getForegroundProcess is deliberately null-not-throw. It is
also the only shape that reaches a user-visible completion, so reading it
as idle is a deliberate choice that should not change silently.
2026-07-24 21:48:36 -07:00
Brennan Benson 2653794c82 fix(terminal): verify Windows PTY root identity before taskkill /T /F (#10484)
* fix(terminal): verify Windows PTY root identity before taskkill /T /F

killWithDescendantSweep guarded its Windows tree kill with ownsRoot()
alone, which is JS state only. node-pty's ConPTY exit watcher closes the
last shell handle before it queues the JS exit callback, so Windows can
recycle the PID while the session map still looks live — force-killing an
unrelated process and its whole descendant tree.

Walk the recycled PID's ancestry back to this process before taskkill:
skip the sweep when the root is gone or resolves to a stranger, and keep
the sweep when identity is unknown so #10004 orphan cleanup still runs.
Also gate the local provider's ownsRoot on observed physical exit.

* fix(terminal): dedupe the Windows root-identity scan, drop dead exit gate

Review fixes on the PID-identity guard.

The probe read the process table through a new uncached export, bypassing
the reader that worktree teardown depends on: worktree-teardown.ts fans out
32-wide inside a 10s deadline, so a delete forked 32 powershell cold-starts
(the churn windows-foreground-process-rows.ts:25-32 warns about, #6288/#6667).
getFreshSnapshot() already guarantees a scan that starts after the request --
the exact property the bypass existed for -- and coalesces concurrent callers,
so use it. Measured on the new test: 32 scans -> 1.

The PhysicalExitTracker.hasExited gate could never fire. markExited() is only
reached at local-pty-provider.ts:985/:1431, and both are followed synchronously
by clearPtyState(), which deletes the ptyProcesses entry -- so ownsRoot's map
check is already false whenever hasExited is true. Reverting it broke no test.
Drop it and the shared getter it added; the identity probe already covers every
ownsRoot caller from inside killWithDescendantSweep.

Also point the Windows terminal-restart E2E job at the files that own this
behavior, so a change to the new Windows-only module runs the one job that
executes on a real Windows host.

* docs(terminal): state what the Windows root probe actually proves

The probe checks subtree membership, not root identity: a recycle that lands
on another Orca descendant (another pane's shell, an agent CLI, a git.exe we
spawned) still reads `own`, and that is not remote during teardown when Orca
is itself allocating pids. It bounds the blast radius rather than closing the
class. Say so at the type and at classifyWindowsTreeKillTarget, and name what
a real close would need (a CreationDate baseline -- the analogue of the POSIX
lstart check already used here -- or an inherited handle / Job Object).

Also note why our own pid must classify `foreign`.

* ci(windows): trigger the terminal-restart E2E on the shared snapshot reader

The Windows root-identity probe now reads through getFreshSnapshot, so an edit
to that module changes Windows teardown behavior without touching any path the
job already watches.

* test(terminal): guard the teardown probe against a reintroduced scan bypass

The existing volume guard covers queryWindowsProcessRowsFresh directly, but the
identity-probe cases all inject readRows, so nothing exercised the DEFAULT
reader wiring -- a bypass reintroduced inside windows-pty-root-identity would
have gone unnoticed. Drive verifyWindowsTreeKillTarget 32-wide through the real
reader and assert one scan. Verified it fails at 32 when the bypass is put back.
2026-07-24 21:39:58 -07:00
NeilandOrca 879aad7dd6 oom(foundation): bound shared readers/limits + add BoundedMap primitive (#10299)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

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

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

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

* oom(03): A3-shared-fs-listing — reintroduce #10179 subset

Files: 21 applied, 0 deleted (from 6eb70d8370)

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

* oom(04): A4-shared-remote-relay — reintroduce #10179 subset

Files: 8 applied, 0 deleted (from 6eb70d8370)

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

* oom(05): A5-shared-misc — reintroduce #10179 subset

Files: 28 applied, 0 deleted (from 6eb70d8370)

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

* oom(06): B-shared-wiring — reintroduce #10179 subset

Files: 81 applied, 0 deleted (from 6eb70d8370)

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:36:57 -07:00
NeilandOrca cafa958e70 fix(ui): stop the reveal reflow from dismissing popovers (#10487)
* fix(ui): stop the reveal reflow from dismissing popovers

The main process reflows the renderer on every reveal, resume, and restore.
On macOS 26 that nudges the emulated device scale factor, and Chromium fires
a real window resize for it even though innerWidth/innerHeight are identical.
Anything bound directly to resize treated that as a user resize.

The selection copy menu and the markdown link bubble both dismiss on resize
unconditionally, so they closed on their own whenever the window was revealed
or restored. Gate both on an actual dimension change.

Only fixes the macOS 26 path: the pre-Tahoe repaint jiggles the native frame
by a real pixel, so the renderer sees a genuine dimension change and no
change-detection guard can — or should — filter it.

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

* test(ui): cover the reveal-reflow guard at the component level

Pins both halves of the wiring: a same-size resize must not dismiss, a real
one still must. Mutation-checked — reverting to a bare resize listener, or
dropping the listener entirely, each fails the suite.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 21:11:48 -07:00
NeilandOrca 46942783c4 fix(window): reflow via scale factor so terminals stop re-gridding (#10485)
The +1px emulated viewport changed the CSS box, so a terminal sitting one
pixel under an xterm row boundary gained a row. The pane fit observer's
two-frame stability check reads that transient grid as stable well inside
the 32ms hold, forwards a real PTY resize, then reverses it on restore —
two SIGWINCHes per reveal, measured on 3/54 window heights (~1/cellHeight).

Nudging the device scale factor instead re-runs layout with byte-identical
CSS geometry. Same reflow, 0/54 SIGWINCH, no WebGL atlas rebuild or context
loss, and webview guests stop seeing spurious native resizes too.

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:53:04 -07:00
NeilandOrca 81bfb3c396 oom(02/29): bound shared image/media/PDF memory limits (#10295)
* oom(01): A1-shared-readers — reintroduce #10179 subset

Files: 18 applied, 0 deleted (from 6eb70d8370)

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

* oom(02): A2-shared-image-media — reintroduce #10179 subset

Files: 7 applied, 0 deleted (from 6eb70d8370)

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:31:06 -07:00
NeilandOrca c419aadb19 oom(01): A1-shared-readers — reintroduce #10179 subset (#10294)
Files: 18 applied, 0 deleted (from 6eb70d8370)

Co-authored-by: Orca <help@stably.ai>
2026-07-24 20:30:31 -07:00
Neil c468e3f8b8 fix(macos): close both macOS 26 main-thread deadlock doors and restore the reveal reflow (#10473)
* fix(window): restore the macOS 26 reflow without touching the native frame

#10253 stopped the main-thread deadlock by skipping the repaint size nudge on
macOS 26, but invalidate() repaints without reflowing, so the h-dvh root kept a
stale viewport height and the status bar stayed clipped off-screen (STA-2383) on
every Tahoe reveal, restore and wake.

Drive the reflow through device emulation instead: a +1px emulated viewport,
reverted a frame later, makes the renderer recompute layout without any NSWindow
mutation, so the FrontBoardServices re-entrancy that wedged the main thread for
109 minutes is still never triggered.

Verified against real Electron 43.1.0 on macOS 26.3.1 (Darwin 25.3.0): the
renderer sees the resize and relayouts, the native frame is untouched, the
viewport and devicePixelRatio restore exactly across zoom levels, overlapping
calls collapse to one cycle, and a 1px delta never crosses a terminal cell
boundary so no pane reports new geometry (no SIGWINCH to running shells).

Also close two gaps in the surrounding code:
- the pre-Tahoe size jiggle now clears its WeakSet latch in a finally block, so a
  throwing setSize can no longer suppress every later repaint for that window
- cover powerMonitor 'resume' under the Tahoe guard, the other AppKit dispatch
  context implicated in the freeze

* fix(tray): keep NSStatusItem scene updates off the AppKit callout stack

The main-window repaint was only one of the two doors into the macOS 26
FrontBoardServices deadlock. Showing or restoring the window calls
setTrayAttention(false) straight from the window event handler, and
tray.setImage/setToolTip drive an NSStatusItem scene update — the same
re-entrant scene mutation from inside AppKit's own dispatch, matching the
stackshot in openai/codex#23695.

Defer the native mutation to a fresh event-loop turn so the callout frame is
vacated first. The attention flag itself still flips synchronously: rapid
show/hide would otherwise mis-dedupe against a value that had not landed yet.

Bursts collapse to a single repaint, and because the deferred pass reads current
module state rather than a captured value, a coalesced schedule can never apply
a stale icon. applyTrayImage already no-ops on a destroyed tray, so a repaint
still queued when the tray goes away is harmless.

* fix(window): reflow maximized and fullscreen windows on macOS 26 too

The maximized/fullscreen bail-out predates the Tahoe path and exists only to
keep the size nudge from resizing a window out of those states. Emulation never
touches the frame, so that guard was suppressing the reflow for no reason — and
a maximized window strands its dvh layout exactly like a normal one.

Run the Tahoe branch before the guard. Verified on macOS 26.3.1 that the
emulated viewport reflows a maximized and a fullscreen window while leaving both
states intact.

* fix(window): retry the viewport restore instead of stranding the renderer

If disableDeviceEmulation threw while the webContents was still alive, the
previous code swallowed the error and cleared the latch anyway, leaving the
renderer pinned at the emulated 1px-taller viewport for the rest of the window's
life — and letting the next reveal stack a fresh cycle on top of it.

Retry the restore on a bounded schedule and hold the latch while a retry is
pending. A destroyed webContents still short-circuits, since the emulated
viewport dies with it, and the attempt budget keeps a permanently failing
restore from pinning the latch forever.
2026-07-24 19:54:25 -07:00
OrcaWin b4718dd05c fix(terminal): resume hibernated agents that reattach with no payload (#9648)
* fix(terminal): resume hibernated agents that reattach with no payload

When agent hibernation is on, a stopped (done) agent's PTY is killed and a
passive sleeping record is kept; returning to the worktree relies on the pane
reattaching on remount. On the daemon path (Windows/local worktrees) the daemon
can reattach the hibernation-killed session as already-live (isReattach,
isNew:false) and return no snapshot/replay/coldRestore — and, being a reattach
rather than a fresh spawn, it silently drops the --resume command passed on
connect. The renderer adopted that empty session, leaving a blank terminal with
nothing running and the sidebar history still pointing at the dead tab (the
sleeping record never cleared).

A reopened pane that owns a resumable slept session must always be re-driven
with its resume command, never left as a bare empty attach. handleReattachResult
now discards a contentless isReattach for a pane with a hibernation record and
re-drives the prepared resume. This excludes the healthy cases: a fresh session
the daemon created (isReattach falsy — it already ran the command) and a live
reattach (carries a snapshot/replay). Forward the isReattach signal the
transport was dropping so the two cases are distinguishable.

Adds a deterministic regression test that fails without the guard and passes
with it.

* fix(terminal): preserve provider ownership on resume

* chore(skills): refresh generated skill bundle manifests

Regenerate the skill bundle artifacts against the full release-tag set so
the freshness verify check passes. Append-only additions for the newer
release tags; no released snapshot history is rewritten.
2026-07-24 18:40:05 -07:00