Commit Graph
1487 Commits
Author SHA1 Message Date
Brennan Benson 90692ba40b fix(daemon): preserve audit evidence polarity (#11626) 2026-07-30 17:07:40 -07:00
Brennan Benson d4cfee76be Add audit-only daemon incarnation evidence (#11606)
* feat(daemon): add audit incarnation evidence

* fix(daemon): isolate audit evidence observers
2026-07-30 16:02:08 -07:00
5165cd1e19 fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348) (#11351)
* fix(browser): scope Cmd/Ctrl+F find to the focused split (#11348)

The browser pane's renderer-path Find handler is a window-global
capture-phase keydown listener, but it armed on `isActive` (the active
tab within its own group) rather than on whether its split holds focus.
In a terminal+browser split, the browser was therefore `isActive` even
while the terminal held keyboard focus, so it swallowed Cmd/Ctrl+F and
opened find-in-page in the browser instead of find-in-terminal.

Thread a focused-split signal (`isFocused`) from BrowserPaneOverlayLayer
— derived from `activeGroupIdByWorktree` — down to the Find handler and
gate the listener on it. This mirrors how terminal leaves already gate
global shortcuts via `focusedGroupId` in TabGroupSplitLayout. Floating
browser panels omit the prop and fall back to `isActive`, preserving
their behavior. The IPC path (webview guest focused) is unchanged; it
only fires when the guest genuinely has focus.

Not platform-specific: the chord resolves through `keybindingMatchesAction`
(Mod -> metaKey on macOS, ctrlKey elsewhere), so the same path is fixed on
macOS, Linux, and Windows.

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

* fix(browser): preserve Find before split focus settles

* fix(browser): handle stale focused split IDs

* fix(browser): route guest Find to source page

* test(browser): wait for split address bar

* test(browser): focus split before Find routing

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-30 14:17:26 -07:00
KyuJoo HanandOrcaWin 6f3845baa4 fix(checks): rank successful checks above skipped and neutral (#11337)
* fix(checks): rank successful checks above skipped and neutral

Checks were ordered with `skipped` (4) and `neutral` (3) ahead of
`success` (5), so a PR with a long tail of skipped jobs pushed every
passing check below the fold — you scroll past a wall of "Skipped" to
find out whether anything actually ran.

Rank the no-signal conclusions last (`success` 3, `neutral` 4, `skipped`
5) and pull the order out of its three duplicated copies
(checks-panel-content, PullRequestPage, GitHubItemDialog) into
`src/shared/pr-check-severity-order.ts`. Unknown conclusions now sink to
the bottom instead of silently ranking as `neutral`.

* fix(checks): look up check ranks through a Map, not an object literal

An object-literal rank table resolves `constructor`, `toString`, and
`__proto__` off Object.prototype, so those keys returned a function
instead of falling through to UNKNOWN_CHECK_RANK — the comparator then
subtracted functions, went NaN, and left the list in arbitrary order.
Conclusions come from provider payloads, so keep the lookup on a Map and
cover prototype property names in the test.

* test(checks): cover provider-neutral ordering states

* fix(checks): preserve actionable provider states

* fix(checks): preserve unresolved provider rollups

* fix(checks): keep unknown GitLab rollups neutral

* fix: preserve neutral review check summaries

* fix: complete provider-neutral check ordering remediation

* fix: use provider-neutral mobile review status input

* fix: hydrate GitLab mobile review status

* fix: type mobile GitLab review hydration

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-30 13:29:41 -07:00
Brennan Benson 8ea8fe1a6d fix(skills): stop a project's own skill copy driving global freshness (#11474)
* fix(skills): stop a project's own skill copy driving global freshness

A pristine global install plus a drifted copy inside a work directory showed
Settings -> Computer Use as amber "Needs attention", with the copy labelled
"may be modified ... Remove it if you want Orca to update this skill", while the
same page said "Computer Use is ready" and the update command correctly printed
"All global skills are up to date". No user action could clear it.

Orca's updater only ever passes --global, so a copy a project owns has no remedy
by design. Two defects made it drive the global verdict anyway:

- locationChip tested byte status before topology, so an unrecognized repo-scope
  placement returned early and never reached its 'in-a-repo' case. Because
  SKIPPED_REASON_PRIORITY ranks unrecognized above in-a-repo, the summary
  sentence was wrong too.
- isSkillCopyNeedingAttention excepted plugin-cache but not repo-scope.

Stated by scope rather than by byte status: an outdated or unreadable project
copy is as far outside the global updater's reach as an unrecognized one, so
pinning only the reported status would leave the same bug reachable through
another. Chip precedence is now explicit -- a read failure outranks ownership so
that rule cannot hide a real fault, and ownership outranks byte status.

Ownership suppresses the group, never a location's visibility: a project copy is
still listed whenever another placement earns the row.

The badge predicate deliberately omits the shared helper's outdated carve-out, so
a non-eligible outdated copy stays amber. Collapsing the two into one predicate
would flip that to green while the dialog still shows its reinstall row, so the
distinction is preserved and pinned by a regression guard.

Eligibility needed no change: it already filters to convergent placements.

* fix(skills): keep a project copy from explaining a global skill's skip

Review follow-up. The chip and the group no longer treat a project-owned copy as
global drift, but three surfaces still read it as one:

- skippedReason derived its one sentence from the highest-priority chip among a
  group's locations, with 'in-a-repo' ranked above 'duplicate', 'external-link',
  'broken-link' and above the no-chip case that hands over the reinstall command.
  A repo-scope copy can no longer earn a group, so whenever it won it explained a
  skip it had no part in — and swallowed the one runnable remedy. SkillLocationRow
  now carries whether the update judged it, and only judged rows explain. That also
  covers the scan-limit sentinel, which is repo-scope and chips 'inaccessible'.
- hasSkillCopyNeedingAttention counted project copies as the presence that makes a
  plugin-cache read failure a skill's problem, while the status function skipped
  them — the disagreement the two exist to prevent.
- The nudge mixed project copies into its dismissal fingerprint, so re-checking out
  a repo re-raised a nudge the user had already dismissed.

plugin-cache is untouched: it stays on the judged side everywhere, because updating
the plugin is a remedy a project copy does not have.
2026-07-30 13:21:03 -07:00
Wooseong KimandBrennan Benson 9e0a9ebc7d fix(pty): do not create unused Pi/OMP home dirs on bare shells (#10198)
* fix(pty): do not create unused Pi/OMP home dirs on bare shells

Bare terminals used to materialize ~/.pi/agent and ~/.omp/agent (and install
managed extensions) for possible later shell-launched agents. Users who never
use those agents still saw the directories recreated after deletion.

Only create the default agent home when launching that agent explicitly, or
when the home already exists. Bare-shell OMP status still uses the userData
fallback so typed `omp` keeps the shell wrapper extension.

Closes #10196

* fix(relay): OMP bare-shell status fallback without ~/.omp

CodeRabbit: relay materializePi returned null on bare shells with a missing
OMP home, so SSH PTYs never set ORCA_OMP_STATUS_EXTENSION. Local already
wrote a userData-managed status extension in that case.

Write the status file under ~/.orca-relay/omp-managed-status-extension and
return MaterializePiResult so relay.ts can export ORCA_OMP_STATUS_EXTENSION
without ORCA_OMP_SOURCE_AGENT_DIR or creating ~/.omp. Also fix the local
withOrcaManagedExtensionMarker typo on the bare-shell path.

* fix(pty): only materialize Pi home for Pi launches

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-30 12:57:45 -07:00
650dd48ec9 feat(cli): add orca account add / account list for headless hosts (Claude + Codex) (#9177)
* feat(cli): add `orca account add` / `account list` for headless hosts

The desktop "Add account" UI is disabled when the renderer drives a remote
runtime (isRemoteAccountScope === kind:'environment'), so a headless server
reached from a remote desktop/web client has no way to register managed
Claude accounts. Add a host-local CLI path that reuses the existing capture
logic:

- ClaudeAccountService.addAccountFromConfigDir(): register a managed account by
  capturing credentials from an already-authenticated CLAUDE_CONFIG_DIR instead
  of spawning the interactive browser login (extracted persist/rollback helpers
  shared with the existing add flow)
- RPC accounts.addClaudeFromConfigDir, bridged via OrcaRuntime; rejected for
  mobile device tokens (host-local only)
- `orca account add` runs `claude login` in the user's own terminal into a temp
  CLAUDE_CONFIG_DIR, then registers it via the local runtime; `orca account list`
  lists managed accounts

Switching (select) already works from a remote client; only adding was blocked.

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

* feat(cli): support Codex in `orca account add` / `account list`

Mirror the Claude headless-account CLI for Codex:

- CodexAccountService.addAccountFromHome(): register a managed Codex account by
  importing auth.json from an already-authenticated CODEX_HOME, reusing a shared
  persist helper extracted from doAddAccount (no interactive login spawned here)
- RPC accounts.addCodexFromHome + OrcaRuntime.addCodexAccountFromHome bridge,
  rejected for mobile device tokens (host-local only)
- `orca account add --agent claude|codex` (default claude); `orca account list`
  now renders both Claude and Codex managed-account blocks

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

* test: cover headless account-add capture paths (Claude + Codex)

- ClaudeAccountService.addAccountFromConfigDir: registers a managed account by
  capturing an authenticated CLAUDE_CONFIG_DIR; rejects and rolls back when the
  dir has no .credentials.json
- CodexAccountService.addAccountFromHome: imports auth.json from an
  authenticated CODEX_HOME into a managed account; rejects when auth.json is
  missing

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

* fix: address CodeRabbit review on headless account-add flows

- CLI login spawn uses a shell on Windows so `.cmd` agent shims resolve without
  ENOENT (args are fixed literals, no injection risk)
- Claude capture skips the `.credentials.json` precheck on macOS, where creds
  live in the Keychain and captureAuthFromConfigDir reads them
- Claude add rollback is best-effort: a failed rematerialization no longer skips
  managed-auth cleanup or masks the original add error
- Codex persist restores the prior account/selection if a post-write sync or
  rate-limit refresh fails, so a failure can't leave a dangling managed account
- Codex sync passes the account's selection target (correct runtime for WSL)
- Add JSDoc to the new public service methods and CLI functions

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

* fix(cli): harden headless account capture

* fix(cli): correct account command flag surface and interrupt cleanup

- `account` commands no longer accept or advertise the browser `--page`
  flag; `supportsBrowserPageFlag` allow-listed them by omission, so
  `orca account list --page x` was silently accepted and `--help`
  rendered a browser-only option
- account specs declare GLOBAL_FLAGS, so `--help`/`--json` render in the
  Options block like every other command
- `--agent` on `account add` documents the account provider instead of
  the terminal TUI-agent meaning inherited from the shared flag table
- a SIGINT/SIGTERM during the interactive login now removes the temp
  login dir (and restores the macOS Keychain item) before exiting 130;
  Node terminates without unwinding `finally`, which stranded live OAuth
  credentials on disk

* perf(cli): stop `account list` forcing a provider usage refresh

`accounts.list` awaited refreshAccountsForMobile(), which runs
fetchAll({ force: true }) — bypassing both the poll throttle and the
per-provider Retry-After gate — then O(N) serial per-account round
trips. `orca account list` renders only emails and the active ids, so
all of that work was discarded. The RPC now takes `refreshUsage`
(default true, so mobile and web keep the forced lane) and the CLI opts
out. Older hosts declare `params: null` and ignore the field, so a newer
CLI degrades to the previous behavior rather than failing.

Also documents on `account list` that `--environment` does not retarget
it, matching the host-local behavior of shouldIgnoreRemoteSelection.

* fix(cli): survive repeated and hangup signals during account add

withInterruptCleanup latched cleanup behind a boolean, so a second signal
got an already-resolved promise and its process.exit fired while the first
cleanup was still inside a Keychain call (3s each) — the temp dir's OAuth
credentials and the swapped macOS Keychain item both survived. Memoize the
cleanup promise so every signal awaits the same run, and register with
`on` instead of `once` so a second Ctrl-C cannot fall through to Node's
terminate-immediately default mid-cleanup.

Handle SIGHUP too. This flow exists for headless/SSH hosts, where the most
likely interrupt is the connection dropping, which hangs up the login's
terminal and previously ran no cleanup at all.

Warn when the interrupt lands after sign-in completed: the runtime finishes
the add independently of this process, so exiting 130 silently would tell
the user it was cancelled when the account may exist.

Reject a valueless `--agent`; the parser turns it into boolean true, which
silently ran a full OAuth login for Claude when the user asked for another
provider.

Also lock two behaviors the refactor changed but left uncovered: a WSL Codex
add must sync the WSL runtime lane rather than the default host lane, and
rename the account-spec help test to describe the Options block it actually
asserts rather than the usage string it never reads.

* fix(build): bundle the main modules the account CLI imports

electron-vite cleans out/main and emits only its declared entries, and
`build:desktop` runs it after `build:cli`, so the tsc-emitted copies of
`claude-accounts/keychain`, `codex-cli/command` and `win32-utils` were
deleted before packaging. Both `orca account add` and `orca account list`
then died at require time with "Cannot find module
'../../main/claude-accounts/keychain'" — reproduced against a real
`--serve` host. `agent-hooks/managed-agent-hook-controls` already carried
an entry for exactly this reason; these three were missing.

Adds a parity test so any future CLI import of a `src/main` module fails
in CI rather than at a user's shell after packaging.

* test: cover the desktop add-path behavior this PR changes

Both changes ride in the persist/rollback helpers the existing GUI add
flow shares with the new headless path, and neither had coverage:

- Claude: rollbackAddAccount now guards forceMaterializeCurrentSelection-
  ForRollback, so a rejecting rematerialization no longer replaces the
  real add error nor skips safeRemoveManagedAuth. Asserts the original
  error surfaces and the throwaway auth dir is gone.
- Codex: the desktop add now passes the account's selection target to
  syncForCurrentSelection, matching reauthenticate and select. Asserts
  the host target alongside the existing WSL assertion.

Both fail when the corresponding change is reverted.

* fix(cli): close the remaining account-add interrupt and preflight gaps

The round-1 interrupt fix detached the signal handlers before running the
finally-path cleanup, so the very window it was meant to protect — the two
serial 3s `security` calls plus rmSync on the success/error path — was
still covered only by Node's terminate-immediately default. Both review
lanes reproduced it independently. Await cleanup first, detach in a nested
finally, and stop a cleanup failure from replacing the error that actually
explains why the add failed.

Do not burn the interactive login when the runtime is unreachable. The
RuntimeClient is lazily constructed and the first call was the registration
RPC itself, so "Requires the Orca runtime to be running" was discovered
only after the user completed a full OAuth round trip. Preflight with the
now-cheap `accounts.list { refreshUsage: false }`.

Reject `--environment` / `--pairing-code` on `account add`.
shouldIgnoreRemoteSelection pins account commands to the local runtime, so
`orca account add --environment homelab` silently registered the account on
the laptop instead of the headless host it names.

Survive a daemon that cannot spawn `claude`. `allowFailure` is honored in
onClose but not onError, and unlike the GUI flow nothing has run `claude` in
the daemon before this point — so a launchd/systemd daemon with a minimal
PATH hard-failed an add the user had already signed in for, even though
identity resolves fine from the config dir's oauthAccount.

Also align the `--agent` help description with the global flag column.

* fix(cli): reject runtime selectors on `account list` too

`orca account list --environment homelab` was accepted and silently
listed the LOCAL machine's accounts, because shouldIgnoreRemoteSelection
pins account commands to the local runtime. Documenting that in --help
does not reach someone who already typed the flag, and answering with the
wrong host's accounts is the specific wrong answer they would act on.

`account add` already errors; this makes the new command group internally
consistent. The other groups in shouldIgnoreRemoteSelection keep their
existing silent-ignore behavior — changing those is not this PR's job.

* test: harden account-add signal tests and cover cleanup failure

- Identify the handler under test by set difference instead of
  `process.listeners(sig).at(-1)`. Vitest installs its own once-wrapped
  SIGINT teardown, so the positional lookup could grab the wrong listener;
  the helper also asserts exactly one new listener was added.
- Mock rmSync while keeping the real implementation by default, so the
  temp-dir assertions elsewhere stay honest.
- Cover that a cleanup failure in the `finally` does not replace the error
  explaining why the add failed. Fails when that guard is removed.

Completes the review loop's final round; the loop died on an API error
before it could commit this, and its `import()` type annotation would
have failed oxlint.

* fix(cli): harden interactive account add

* test(cli): make account cancellation coverage portable

* fix(cli): preserve merged skills runtime modules

---------

Co-authored-by: Dominik <marketing@gavaplast.sk>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-30 12:50:07 -07:00
Sebastián Castañoandscastanoh21 676ef7fab8 feat(cli): add orca skills install and orca skills update for headless skill setup (#9201)
Adds `orca skills install` and `orca skills update` so skills can be set up without the GUI — SSH hosts, containers, CI. Previously `orca skills` had only `list` and `get`, so there was no headless path.

**Agent targeting is scoped explicitly rather than delegated to detection.** The `skills` CLI decides which agents to install into, and with `-y` and zero detected agents it takes `targetAgents = validAgents` — all ~75. That is not a corner case for a headless CLI: a fresh SSH box or container with no agent installed is the normal starting state. Measured on a bare host, the unscoped command created **52 top-level agent directories and 54 junctions** (one real payload in `~/.agents/skills`, the rest links) on Windows, and 52/53 on macOS.

The CLI now passes `--agent` derived from Orca's own detection, mapped to the `skills` key namespace, plus `universal`. Supplying `--agent` makes `runAdd` use it directly and never call `detectInstalledAgents()`, so the fan-out branch is unreachable. On a bare host it now refuses with `No coding agent detected on this host` and exit 1, creating nothing. Same command with scoping: **1 directory, 0 junctions.**

`universal` alone would under-install — Claude Code is not in that set, and 19 of 28 mapped keys write agent-private homes `universal` never touches. `--agent '*'` is the bug itself. The mapping is hedged three ways: `null` for any agent whose key could not be confirmed, `satisfies Record<TuiAgent, …>` so a new Orca agent is a compile error, and a test pinning every mapped key against the CLI's own valid list.

Fixed during review — two holes that each restored the full fan-out through a different door:
- `--agent ','` trimmed to nothing, which skipped the refusal *and* emitted no `--agent`.
- `--agent -y` passed an emptiness check, and the vendor CLI silently drops `-`-leading values, re-emptying its list.

The real invariant is argument *shape*, not emptiness, and it is now enforced at the choke point in `buildAgentFeatureSkillInstallArgs`, so no caller can emit `-y` without a usable target. `*` remains allowed — asking for every agent explicitly is a choice, not an accident. Verified with 51 hostile inputs through the built binary, each recorded argv replayed through the vendor's own parser.

Also fixed: the `ORCA_CLI_CWD` refusal now runs before target resolution (it was quoting the wrong host's agent list), and `--dry-run` is refused in a forwarded shell rather than printing a command naming the wrong machine.

Validated on a real Windows host across PowerShell 7, PowerShell 5.1, cmd.exe and Git Bash: `.cmd` shims route through `cmd.exe` and `.exe` shims spawn directly (proved with instrumented shims, not inferred), the ENOENT path produces an actionable error rather than a silent failure, and `skills update` genuinely restores a corrupted skill byte-for-byte.

Known, not addressed here — both upstream behaviours this only forwards: a partial install failure exits 0, and "no installed skills found" exits 0. Both are invisible to the headless callers this feature exists for.

Co-authored-by: scastanoh21 <scastanoh21@gmail.com>
2026-07-30 11:20:29 -07:00
Jinjing e20554bfd7 fix(terminal): reduce inactive pane dimming (#11591) 2026-07-30 11:19:45 -07:00
Brennan Benson bbb3e7e5ee fix(native-chat): mirror multi-line launch drafts into the chat composer (#11253)
* fix(native-chat): mirror multi-line launch drafts into the chat composer

seedNativeChatLaunchDraftForAgentTab rejected any text containing a newline,
so every Linear launch ("Linked Linear issue: X\n<url>") and any GitHub launch
with a typed note was invisible in chat. The rejection existed because the send
path pre-cleared the TUI with a single Ctrl+U, which cannot clear a buffer with
embedded newlines.

Orca injects the draft itself, so when the composer still holds exactly what was
injected the buffer already IS the message: the send becomes the submit key
alone — no clear, no paste, nothing that can concatenate, and multi-line submits
as one turn for free. Only the edited case needs real buffer replacement, and
that now clears every line and verifies against the agent's rendered input line
instead of firing blind.

Measured on real PTYs against Claude Code and codex (both agree exactly):
clearing N logical lines costs 2N-1 Ctrl+U. See src/shared/agent-tui-input-clear.ts
for the law, the sequences that do NOT work, and why an upper bound is safe.

* fix(native-chat): send the mobile clear burst as its own write

Live QA caught the bundled form failing: a multi-line burst prefixed onto the
body in the SAME terminal.send reached the agent as LITERAL Ctrl+U characters,
so the parked draft survived and the message arrived as
draft + 21x \x15 + body. Sending the burst as its own non-submitting write —
the shape the image paste has always used — clears as intended.

The body write's own single-Ctrl+U prefix is dropped once that dedicated clear
ran, for the same reason: a Ctrl+U immediately followed by body text in one
write lands as a literal control character and headed the received message.

Re-verified live end to end: received prompt is exactly the draft, one turn,
zero control characters.

* test(native-chat): invert the multi-line Linear launch-draft mirror expectation

The Linear work-item launch seeds `Linked Linear issue: ENG-42\n<url>\n`.
This test pinned the pre-relaxation rule (multi-line drafts withheld), which
the send path no longer needs now that it submits the TUI buffer in place or
clears every line first — so it asserted the exact behavior the fix removes.

Assert the seeded payload instead of absence, so the test fails if the mirror
regresses to single-line-only.

* fix(native-chat): preserve launch draft send contents

* fix(native-chat): preserve confirmed send queue ordering

* fix(native-chat): preserve send pacing after renderer stalls

* test(native-chat): align activation with multiline draft mirroring

* fix(native-chat): clear launch drafts from any cursor

* fix(native-chat): retire mobile-consumed launch drafts

* test(mobile): stabilize QR capacity boundary fixture
2026-07-30 11:08:56 -07:00
OrcaWinandOrcaWin ab665a3ce7 fix(remote): preserve terminal recovery across control refresh (#11513)
* fix(remote): recover stalled terminal streams

* fix(i18n): localize manual disconnect error

* fix(remote): park paired terminals with host snapshots

* test(remote): mock authoritative resync snapshots

* fix(terminal): defer startup mounts until hydration

* fix(remote): raise paired terminal stream capacity

* fix(remote): harden terminal recovery lifecycle

* fix(remote): preserve calls across control refresh

* test(remote): harden paired recovery oracle

* test(workspace): seed Jira source context

* test(remote): assert raw host terminal identities

* test(terminal): keep restore sentinels atomic

* test(terminal): keep restore sentinel on one row

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-30 03:05:10 -07:00
Jinjing 9eede0084d fix(relay): refuse silent fallback when pairing invite fails (#11528)
* fix(relay): refuse silent fallback when pairing invite fails

When Orca Relay pairing fails, don't silently degrade to a LAN-only QR under the Relay label. Instead, surface structured failure information so the UI can clearly inform the user and offer recovery options.

* fix issues
2026-07-30 02:13:47 -07:00
Neil 0fe1278244 fix(sidebar): stop background workspace creation from scrolling the sidebar (#11530)
* fix(sidebar): stop background workspace creation from scrolling the sidebar

Creating a workspace in the background still spawns its terminals, and the
renderer treated "no presentation stated" as "point the user at this
terminal" -- revealing (scrolling to) the owning workspace.

Split adoption from surfacing with an explicit surfaceOwner flag: background
worktree creates and worker dispatch adopt their tabs silently, while
`orca terminal create` keeps its discoverability reveal.

* fix(sidebar): keep split-mode setup panes silent, tighten surfaceOwner

Review catch: with setupScriptLaunchMode split-vertical/horizontal the Setup
terminal goes through splitTerminal, whose reveal payload had no surfaceOwner,
so a background create still scrolled the sidebar in that configuration.

Also narrow surfaceOwner to `false` so "surface it" can only be expressed by
omitting the key, and fold the repeated conditional spreads into ownerSurfacing.
2026-07-30 02:08:01 -07:00
64a1269409 perf(orchestration): bound mutation ledger and run pages (#11432)
* perf(orchestration): bound mutation ledger and run pages

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

* fix(orchestration): close retention pagination gaps

* fix(orchestration): preserve unpaginated run listing

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

* fix(orchestration): reject malformed run cursors

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-07-30 00:49:23 -07:00
Jinjing a60aa85592 fix: make remote server pairing failures actionable (#11510)
* fix: make remote server pairing failures actionable

* refactor: extract daemon router event types

* fix: address remote pairing review findings

* fix: address final remote pairing review feedback
2026-07-30 00:31:34 -07:00
561e2d32cd fix(floating-workspace): persist Markdown tab renames (#11398)
* fix(floating-workspace): route markdown renames locally

* test(floating-workspace): strengthen rename regression

* test(floating-workspace): verify rename restart persistence

* fix(filesystem): serialize local rename destinations

* fix(filesystem): serialize Unicode rename aliases

* fix(filesystem): align rename locks with native aliases

* fix(filesystem): canonicalize rename parent locks

---------

Co-authored-by: Dzmitry Bachko <dbachko@users.noreply.github.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 21:43:45 -07:00
Brennan Benson f8b553b7d5 fix(agent-hooks): skip unavailable agent homes (#11442)
* fix(agent-hooks): skip unavailable agent homes

* refactor(agent-hooks): separate Pi and OMP home fix

* test(agent-hooks): update merged protocol harnesses

* fix(agent-hooks): avoid redundant reconciliation

* fix(agent-hooks): harden reconciliation and detection

* test(agent-hooks): cover settings reconciliation

* fix(agent-hooks): hydrate PATH for paired clients
2026-07-29 20:19:18 -07:00
Brennan Benson f0eca5fe32 fix(sidebar): isolate runtime reconnect refreshes (#11472) 2026-07-29 20:10:44 -07:00
OrcaWin bf894ef150 fix(remote): recover and safely park paired terminals (#11416) 2026-07-29 20:04:55 -07:00
Jinjing 74563b6498 feat(jira): link Jira issues from the workspace create dialog (#11296)
* Link Jira issues from workspace create dialog

Add Jira issue linking to workspace creation, matching existing GitHub and Linear workflows. Users can paste Jira issue URLs in the smart name field to auto-populate workspace names and link the issue to the created workspace/worktree.

Linked Jira issues appear on workspace cards via the new 'jira-issue' card property. Implements cancellable searches and summary reads to prevent stalled requests from blocking the shared Jira pool. Persists paired issue + source context metadata with validation of provider/site identity.

Fixes git-username rate-limit handling to reject malformed JSON responses so garbage never becomes branch prefixes.

* feat(jira): link issues during workspace creation

- Display linked Jira issues on worktree cards
- Fetch issue summaries and timestamps via Jira API
- Gate Jira linking behind runtime capability check
- Preserve user-typed names during async lookups

* Enforce git check-ref-format rules in login validation

Extend isBranchSafeHostedLogin to reject usernames that git rejects as
invalid branch components: trailing dots, consecutive dots, and .lock
suffix. Prevents invalid branch names from login usernames.

* Enforce filesystem filename cap for branch-safe logins

Loose refs store logins as single filenames, so the real constraint is the
255-byte filesystem cap, not git check-ref-format rules. This allows longer
provider-agnostic logins while staying platform-safe.
2026-07-29 19:50:18 -07:00
Yunqian Fanandfanyunqian.1 791577861b fix(project-host-setup): carry identity across hosts (#9413)
Allow setup when the selected project exists only on another host by carrying its validated provider identity with the request instead of reverse-parsing project IDs. Preserve host-qualified provider identity and reject mismatched payloads before linking.

Make linking atomic for local and runtime imports, including clone setup: roll back only newly registered repos and invalidate the same caches as canonical removal. Cover local, runtime, host-qualified identity, mismatch, clone rollback, and renderer routing paths.

Co-authored-by: fanyunqian.1 <fanyunqian.1@bytedance.com>
2026-07-29 18:57:50 -07:00
Brennan Benson 64aa726301 fix(quick-open): support projects past 10k files (#11440) 2026-07-29 18:32:21 -07:00
Neil d0f341ad69 fix(computer-use): make modifier clicks interruption-safe (#11451)
* fix(computer-use): make modifier clicks interruption-safe

* fix(computer-use): pace modified Windows multiclicks

* fix(computer-use): address modifier safety review
2026-07-29 18:29:10 -07:00
Brennan Benson 5517bfcbd2 fix(native-chat): make the launch-draft mirror reachable (#11222)
* fix(native-chat): make the launch-draft mirror reachable

Seed the chat-composer copy of unsent launch context on every originating
draft path, then let those launches open in chat by default.

Three paths delivered a draft to the TUI without mirroring it into chat:
folder-workspace create, the local argv-prefill branch of launchAgentInNewTab,
and the web-host equivalent. The first was invisible; the other two were hidden
only because draft launches were forced into terminal view.

The view-mode decision now gates on the same predicate as seeding
(canMirrorLaunchDraftToNativeChat), so a draft can never open in chat with a
composer chat would refuse to fill.

* fix(native-chat): gate draft view mode on argv-prefill launches too

The draft view-mode gate read `startup.draftPrompt`, which only the
post-ready-paste delivery sets. An argv-prefill launch carries its draft
inside `launchCommand`, so the gate never saw one and the tab opened in
chat unconditionally — a multi-line draft was correctly not seeded yet
still opened chat, leaving an empty composer beside a filled TUI input.

Adds `launchDraftText` to the activation startup payload as a view-mode-only
field, deliberately distinct from `draftPrompt` so it cannot double-deliver
the draft through pty-connection's bracketed paste, and sets it at all four
originating producers.

* fix(native-chat): reconcile backend draft launch tabs
2026-07-29 18:28:17 -07:00
Jinjing 8d4e975ff7 fix(new-workspace): stop UI flashing when typing ahead of search (#11436)
* fix(new-workspace): stop UI flashing when typing ahead of search

Hold branch results while queries settle, show the spinner only on
initial load, use stable cmdk values, and guard selections against
stale rows. This prevents the highlight from jumping around when
typing faster than the debounced search settles.

* fix(new-workspace): keep dropdown visible while typing within settled qu

Hold the last search results while the user extends or trims the query,
only hiding them when the query diverges completely. This prevents the
dropdown from flashing empty between debounced keystrokes and removes the
guard that made provider rows unselectable during typing.

* fix(new-workspace): align held provider results with live typing

Cap prefix hold by length delta, hide GitHub/GitLab/Linear rows when the
field is cleared ahead of debounce, and re-sync the cmdk arm when search
settles so the highlight cannot lag the resolved selection.
2026-07-29 17:10:58 -07:00
JinjingandOrcaWin 5f7807497e feat(ssh): bound relay PTY output end to end (#11005)
* docs: design SSH relay PTY backpressure

* fix(ssh): bound relay frame decoding

* fix(relay): bound PTY output publication

* fix(ssh): bound PTY model admission

* fix(ssh): settle closed model admissions

* feat(ssh): negotiate bounded PTY consumer sessions

* fix(ssh): fence exit on renderer settlement

* feat(ssh): track PTY source credit end to end

* fix(ssh): recover bounded PTY output across reconnect

* feat(ssh): complete relay PTY output backpressure

* fix(ssh): close final PTY source credit races

* docs(ssh): record final backpressure validation

* feat(ssh): complete relay PTY source-credit lifecycle

* test(ssh): complete provider notification fixture

* fix(ssh): preserve terminal source credit across rotation

* fix(ssh): fail closed on recovery cancellation

* fix(ssh): prioritize mux control writes after drain

* fix(ssh): retire canceled relay restore deliveries

* fix(ssh): order exit cancellation cleanup

* fix(ssh): gate provisional source activation

* test(ssh): register mux drain-priority coverage

* fix(ssh): type stale owner recovery mismatches

* fix(ssh): close projection replacement races

* fix(relay): contain streaming edge failures

* fix(ssh): secure relay endpoint credentials

* docs(ssh): reconcile final backpressure lifecycle

* fix(ssh): bound main IPC output lifecycle

* fix(ssh): close recovery ownership gaps

* docs(ssh): record exact artifact validation

* fix(ssh): reject reclaimed snapshot replacements

* fix(ssh): fence model admission across reconnect

* fix(ssh): contain migration failure per PTY

* docs(ssh): record final exact-head validation

* test(ssh): align deploy fixtures with credential publication

* feat(ssh): add per-target bounded output setting

* fix(ssh): close source recovery review gaps

* fix(ssh): latch source credit environment override

* feat(ssh): make PTY source credit the default

* docs(ssh): record always-on relay validation

* docs(ssh): bind validation to current main

* test(ssh): grant source credit in IPC fixture

* test(ssh): grant source credit in fake relay

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 17:03:15 -07:00
Brennan Benson 24a2accc3c fix(hibernation): reap restored subagent rows with no live agent process (#11219)
* fix(hibernation): reap restored subagent rows with no live agent process

A pane whose Claude session had a subagent in flight can be locked out of
agent hibernation for good. A PTY that dies while Orca is down never runs
the teardown that clears pane state, so hydrate rebuilds a subagent roster
that nothing can retire: the existing reap needs the parent to emit a
complete `background_tasks` inventory, and a parent that went idle before
the restart never emits one. The restored row keeps gating the pane
'working', and hibernation only accepts 'done'.

Observed locally: six panes parked at SubagentStop in state 'working' for
17 to 145 hours, each still holding a working child row.

Adds a second reap path. Hydrate seeds are marked `restoredFromSnapshot`,
cleared by any live lifecycle event or an id-exact running inventory entry.
One post-restore sweep drops the rows still unconfirmed when the pane's PTY
is absent from the live local inventory, then re-derives the child-gated
'working' to 'done'.

The scan is local-only by construction: panes with a relay connection id are
skipped and SSH-scoped PTY ids resolve as live, since a remote agent runs on
the far host and could never appear in a local listing. An unreadable
inventory is not evidence that anything exited, so it is a no-op. Panes that
have reported to this runtime are left alone.

`stateStartedAt` and `stateHistory` are untouched, so a draft typed while
the pane was working still blocks hibernation.

* fix(hibernation): prove local ownership before restored reap

* fix(hibernation): require authoritative restored PTY absence

* fix(hibernation): probe restored PTY liveness authoritatively

* fix(hibernation): restart idle window after restored reap

* fix(hibernation): type restored reconciliation timing

* fix(hibernation): respect worktree host ownership

* fix(hibernation): preserve same-id restored PTY rebinds

* fix(hibernation): fence batched restored PTY probes
2026-07-29 16:30:44 -07:00
Brennan Benson c74fb3c71b feat(browser): let Shift invert link routing instead of always forcing the system browser (#10991)
* feat(browser): let Shift invert link routing instead of always forcing the system browser

Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a
no-op when that is already where links go. Users who keep Link Routing off have
had no gesture to pull a single link into Orca's built-in browser.

Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes
the modifier open a link the opposite way from the setting. It ships off, so the
one-way escape hatch is unchanged for every existing user.

The title and description name the destination the modifier actually reaches and
flip with the parent setting, since "the opposite" is meaningless on its own.

- openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so
  every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes
  identically. forceSystemBrowser stays for callers that must bypass settings.
- The terminal hover hint names Orca when the modifier would open there, and is
  re-resolved per hover so toggling applies without recreating panes.
- Link Routing's own copy drops "always uses your system browser", which the new
  toggle can falsify; the nested row states the live destination instead.

* fix(browser): route the Checks panel hosted-review link through the shared modifier

The "Open on GitHub" button had its own Shift+Cmd/Ctrl escape hatch that passed
forceSystemBrowser directly, so it kept the old one-way behavior while every
other surface honored the invert setting. Route it through modifierHeld like
the terminal and markdown paths.

Also wraps the modifier row's description in translate(); the title in the same
file was localized but the description returned raw English (caught in review).

* fix(browser): make link-routing modifier copy true in every state

Review follow-ups on the Shift-inverts-routing change.

- The nested row promised "⇧⌘+click opens one in Orca" in the present tense
  while its own toggle was off, so the out-of-box state described behavior the
  user did not have. Phrase it as enabled-state copy, matching sibling rows.
- The parent Link Routing description gained "opens a link the other way",
  which is false in the default state and contradicts the child row when
  inverting is on. No fixed sentence there is true in every state, so the child
  row — which knows the live destination — now owns the claim. That leaves
  getBrowserLinkRoutingShortcutLabel unused, so drop it.
- The rich markdown editor still forced the system browser while the preview of
  the same file honored the modifier, so one link routed two ways depending on
  which view it was clicked in.
- A remote runtime pins every link to the system browser, so the hover hint
  could promise Orca for a click that lands elsewhere. Gate the hint on the same
  condition openHttpLink uses.
- Index both modifier titles: the search entry is built with openLinksInApp
  false, so the row was unfindable by the title it renders when routing is on.
- Drop the ariaLabel that shared no words with the visible label (WCAG 2.5.3)
  and add the four missing settings-search keyword keys to all five catalogs.

* test(editor): pin the rich markdown Shift+click routing hop

The editor half of the modifier fix had no coverage — reverting it to
forceSystemBrowser left the suite green while the same link routed one way in
the markdown preview and the other way in the rich editor. Also pins that a
non-local source owner survives the hop, since that is what keeps an SSH file's
links out of Orca's browser.

* test(editor): cover the Ctrl chord for rich markdown link routing

This file is the only test of handleRichMarkdownEditorClick, and it exercised
metaKey alone, so the isMac branch of modKey had no coverage off macOS. Also
stop claiming the source-owner case proves SSH links stay out of Orca — it
proves the owner survives the hop; http-link-routing.test.ts enforces the rest.

* style: trim review comments to the one-line house rule

Both explained the change adequately in two lines; the extra lines were worked
examples, not information.

* fix(browser): keep Link Routing copy unchanged until inverting is enabled

Removing the "⇧⌘-click always uses your system browser" sentence outright
reworded the row for every user on upgrade, including everyone who never turns
the opt-in on. Restore it verbatim in the default state and only hand the chord
sentence to the nested row once inverting makes "always" untrue.

* revert(editor): keep rich markdown Shift+click on the system browser

Per Brennan: the editor's Shift path hands the link to the client OS and should
not follow the invert setting — the preview opening in Orca is the intended
divergence, not a bug. Restores main's call exactly; the test now pins the
divergence so a future consistency pass cannot erase it silently.

* fix(browser): surface the inverted modifier on the hosted-review link

The hosted-review click path now passes modifierHeld, so with inverting on and
Link Routing off the chord opens in Orca — but the hint stayed gated on
openLinksInApp, hiding a live gesture. Resolve the destination instead of a
boolean. Default-off output is unchanged.

* test(browser): pin the inert modifier hint when links already open in Orca

* refactor(terminal): require the pane link hint so dropping the wiring fails to build

The optional option fell back to a duplicated copy of the legacy hint string, so deleting the hook wiring reverted the tooltip silently with every test green.

* fix(browser): trim the runtime id before hiding the hosted-review modifier hint

openHttpLink and terminalUrlOpenHintOptionsFor both trim, so a blank runtime id hid the hint while the click still reached Orca.
2026-07-29 12:30:48 -07:00
OrcaWinandOrcaWin 363e478909 fix(orchestration): preserve active workers across updates (#11271)
* fix(orchestration): preserve active workers across updates

* test(ssh): model absent legacy adoption

* test(orchestration): align compatibility contracts

* fix(windows): escape updater PowerShell booleans

* fix(windows): restore stock uninstall process check

* fix(orchestration): keep recovery off renderer startup barrier

* fix(orchestration): harden legacy recovery migration

* fix(orchestration): close recovery review gaps

* fix(orchestration): complete legacy worker cutover recovery

* fix(orchestration): preserve legacy workers across updates

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 11:31:35 -07:00
NeilandOrca 3a80fbe162 Revert terminal rendering changes from #10692, #10794, #10871, and #10907 (#11338)
* Revert "fix(terminal): avoid flash while restoring parked terminals (#10871)"

This reverts commit 5a6a9e0b28.

Reverted for terminal rendering regressions (flashing, lost content).
Conflict resolution preserves the forwardRef signature from #10433 and
drops the parked-presentation gating #11016 fed with its effective set.

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

* Revert "fix(terminal): limit pre-paint WebGL resume to macOS (#10794)" and "fix(terminal): stop switch bold flash and Windows lag (#10692)"

This reverts commits 4681edb520 and
8f5a45401f.

#10794 was itself a partial revert of #10692, so both are reverted
together: the Windows retained-WebGL LRU and the macOS pre-paint
(layout-phase) visibility transition that survived it. Terminal
visibility resume returns to passive disposal and recreation on every
platform, and the WebGL context ceiling returns to a flat 128.

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

* Revert "fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)"

This reverts commit 97cb32c1cc.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-29 01:47:20 -07:00
JinjingandOrca a7c8b8e071 fix(terminal): bound SSH & remote hidden-worktree terminal retention (C1) (#10625)
* fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)

SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH
worktree retained every pane forever (C1: renderer heap climbs to the V8
ceiling). SSH bytes transit local main — fact-mode watchers already cover
them, and main keeps a headless model served over pty:getMainBufferSnapshot
that the SSH reattach path never consulted.

- isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded
  through both park verdicts, both selectors, watcher coverage, and the
  watcher start guard. Remote-runtime/fail-open/foreign/null unchanged.
- Parked-SSH reveal paints from main's headless model (dimension-matched,
  ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a
  non-empty source==='headless' payload — never a blank/stale paint.
- Kill switch: settings.terminalSshViewParking (default on).

DESIGN.md records the approved plan and the H1 magnitude non-claim.

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

* fix(terminal): bound hidden-worktree retention with a force-park budget (C1, slice B)

Un-parkable worktrees (remote-runtime ptys, uncoverable tabs, SSH with the
slice-A switch off) had unlimited retention: the parking cap/TTL only ever
saw eligibility-passing worktrees, so one bad tab pinned a whole worktree's
panes forever. Retention is now memory-bounded, not eligibility-bounded.

- terminal-hidden-worktree-retention.ts: retention budget (12 hidden / 45min
  TTL, sized from the measured 2.5-19MB per-pane V8 cost, DESIGN.md §2) over
  hidden worktrees ordinary parking can never evict; reuses the hot-retain
  ranking so last-active exemption, deterministic ties, and deadline-driven
  rechecks hold. Fail-open/foreign-pty tabs are eviction-exempt (a remount
  would fresh-spawn and orphan the live shell).
- Terminal.tsx: force-parked ids join the parked set AFTER the coverage veto
  (darkness for uncoverable tabs is the accepted cost); buffers captured via
  the sleep-flow registry before the unmount render; retention TTL added to
  the recheck deadlines for budget candidates only.
- Verdict stays out of its own effect deps; policy test asserts idempotence
  and time-monotone membership (flip-loop dwell regression).
- Kill switch: settings.terminalHiddenWorktreeRetentionBudget (default on).

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

* fix(terminal): demote hidden scrollback for eviction-exempt worktrees (C1, slice C)

The retention budget (slice B) must exempt worktrees holding fail-open or
foreign-worktree ptys — a remount would fresh-spawn and orphan the live
shell — which would leave that class unbounded again. Instead, past the same
45min retention TTL their hidden panes drop to the minimum scrollback tier
(measured: ~19MB -> ~1.3MB V8 heap per 50k-row pane; trimmed history is
gone by design, reveal restores the configured cap for future output).

- terminal-hidden-scrollback-demotion.ts: module-state verdict registry
  (parked-watcher pattern) with content-equality notify damping; applied in
  the existing scrollback-rows effect in use-terminal-pane-lifecycle.
- selectScrollbackDemotedTerminalWorktrees: pure, TTL-gated, time-monotone.
- Retention TTL wakeups now also cover exempt worktrees so demotion fires.
- Kill switch: settings.terminalHiddenScrollbackDemotion (default on).

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

* fix(terminal): paint the SSH model snapshot inline, not via nested coordinator (C1 slice A fix)

applyMainBufferSnapshot runs its own structuralReplayCoordinator.run; calling
it from applyReattachPayload (already inside the coordinator when a relay
replay exists) deadlocks on the coordinator's tail chain. The model paint now
mirrors the daemon-snapshot branch inline (folded scrollback + rehydrate +
screen, dimension-matched, escape tail last) and arms the restored-snapshot
seq baseline so deferred/live chunks the snapshot covers dedupe instead of
double-painting. Also falls through (no early return) so reattachPayloadApplied
still latches. Adds the folder-workspace id parity unit case.

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

* test(terminal): SSH park+reveal e2e round-trip + as-built design notes (C1)

Docker-gated (ORCA_E2E_SSH_DOCKER=1) spec: SSH tab parks behind a decoy and
reveal restores marker content at multi-viewport scrollback depth. DESIGN.md
records the as-built deltas (inline paint, force-park shape, last-active
floor) and the residuals so follow-ups aren't lost.

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

* fix(terminal): paint SSH reveal from main's model even when the relay replay is empty (C1 review #1)

A relay restart empties the replay buffer; the reveal previously painted
nothing even when main's headless model held the session. The reattach now
prefetches the model snapshot when no structural replay exists (SSH-shaped
ptys only) and paints it inside the coordinator; emptiness is judged on the
composed payload (scrollbackAnsi + data + pendingEscapeTailAnsi) so an
alt-screen snapshot with an empty screen frame still paints.

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

* fix(terminal): decouple scrollback demotion (slice C) from the retention-budget switch (C1 review #2)

Per the approved contract each slice reverts behind its own switch: slice C
now requires only the master terminalHiddenViewParking plus its own
terminalHiddenScrollbackDemotion flag. The TTL wakeup timer fires for
demotion candidates even with the budget switch off. No DEFAULT_SETTINGS
entries exist for sibling flags (defaults are the '!== false' optional
pattern), so no explicit defaults are added.

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

* fix(terminal): scope eviction exemption to the tab, not the worktree (C1 review #3)

One eviction-exempt tab (fail-open/foreign pty) previously vetoed force-park
for its whole worktree, pinning co-located remote-runtime tabs forever. The
worktree now force-parks while exempt tabs keep their mounted panes via a
per-tab exclusion mirroring the Activity-portal pattern (legacy watcher sync,
legacy render, and the overlay cold-parking hook). Ordinary parking is
untouched — a worktree with an exempt tab still cannot ordinary-park.
Slice C now also demotes exempt tabs' panes as soon as their worktree
force-parks under the count budget (they are the only panes left mounted).

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

* fix(terminal): demote un-parkable worktrees the force-park lever spared (C1 review #4)

The last-active exemption means a single hidden un-parkable worktree never
force-parks — and slice C previously only targeted exempt-tab worktrees, so
its panes held full scrollback forever. Demotion now also covers un-parkable
non-exempt worktrees past the retention TTL that are absent from the
force-parked set (last-active spared, or slice B switched off). Membership
stays time-monotone for fixed inputs; covered by new idempotence/monotone
selector tests.

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

* fix(terminal): keep the hidden clock running through transient background-measure windows (C1 review #5)

Whole-worktree background mounts (browser-automation bootstrap lease, mobile
mounts, agent wakes) open a ~3s self-clearing measure window that previously
deleted hiddenSince — every remount restarted the 30s hysteresis and the
45min retention TTL, so a periodically re-mounted force-parked worktree
never re-parked. The measure window still pauses parking/eviction verdicts
(all selectors skip measuring candidates); only the clock survives, so the
prior verdict resumes as soon as the window closes. Visible and
portal-holding worktrees still reset the clock.

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

* test(terminal): make the SSH park+reveal depth assertion prove the model paint (C1 review #6a)

Pad the session with ~180KB of output after the numbered markers so the
earliest marker falls outside the relay's 100KiB rolling replay buffer while
staying inside main's ~5k-row headless model; asserting marker_1 after
reveal now proves the headless-model paint rather than passing under the
relay fallback.

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

* docs(terminal): rewrite DESIGN.md as the single as-built C1 contract (review #7)

One contract matching the code: status IMPLEMENTED around force-park (not
the unmount proposal), real kill-switch names with coupling + revert
matrices, the true retention-floor formula with measured per-pane and
demotion numbers, an explicit when-OOM-is-still-possible paragraph naming
the H2 pendingSideEffects residual, the applyMainBufferSnapshot deadlock
constraint inside the slice-A section, stable-signal phrasing instead of a
capability latch, fail-open AND foreign-worktree exemption class, verified
cites, and a planned/landed/follow-up test matrix.

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

* fix(terminal): resolve the eviction exemption per pane, not per tab (C1 review #8)

isEvictionExemptTerminalTab read only tab.ptyId — the FIRST leaf's pty —
while the coverage veto that makes a worktree a retention candidate walks
every pane. A split tab whose second leaf held an unrestorable pty therefore
failed coverage (→ force-park target) yet looked exempt-free, so force-park
unmounted it and orphaned the live shell. The exemption now resolves panes
through the same resolveParkedTerminalPaneCandidates, keeping tab.ptyId in
the union for the no-layout/no-capture case.

Also from the same review round:
- force-park's capture passes includeLocalBuffers:false like every other
  shutdownBufferCaptures caller; it was serializing up to 512KB/pane of
  scrollback into the store inside a fix meant to bound renderer heap.
- Terminal.tsx unmount resets the scrollback-demotion registry — module
  state with no reset path, read by a pane effect that runs before the host
  effect that would clear it, so a stale verdict trimmed restore replays.
- memoize watcher coverage per tab within the parking pass; the retention
  candidates re-asked it for every mounted worktree, not just the parked few.

* docs(terminal): drop DESIGN.md — the as-built C1 contract moves to the PR body

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

* fix(terminal): cap the deferred PTY side-effect queue (C1 residual H2)

pendingSideEffects grew without bound under background timer throttling
(~64 drained/s vs hundreds queued/s overnight). Cap at 512 entries with
oldest-first eviction: titles drop (last-wins), a pending bell latches
onto the next survivor, agent-status payloads collapse onto the survivor
keeping the newest 16 (last-wins store state, KB-scale strings).

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

* fix(terminal): carry command-lifecycle facts through parked watchers (C1 follow-up)

Parked fact-mode watchers omitted onCommandFinished/onCommandCode*, so
OSC 133;D and Command Code scrape signals went dark while parked. New
parked-terminal-command-status.ts ports the store-level subset: git-UI
nudge on every command finish, same-turn status-row drop for SSH PTYs
(exact mounted-path parity — the foreground tracker refuses SSH ids),
and the Command Code working seed / 1500ms done settle. Byte mode scans
the same shared parsers for authority-off parity. Local-PTY status drops
stay with the mounted pane: they need pty-connection's process-confirm
ladder to tell a leaked nested-shell 133;D from a real agent exit.

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

* test(terminal): retention-budget force-park e2e with a retentionLimit override (C1 6b)

ORCA_E2E_TERMINAL_RETENTION_LIMIT flows preload → e2e-config →
getTerminalParkingPolicyOverrides (exposeStore-gated, positive-integer
only) so a spec can shrink the force-park budget to 1. The Docker-gated
spec opens two remote worktrees on one relay target (second pre-seeded
remote repo), disables terminalSshViewParking to make both un-parkable,
hides both behind the local context, and proves the older one force-parks
while the last-active exemption spares the newest; re-activating the
evicted worktree restores the marker tail via relay replay.

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

* test(terminal): retention-budget e2e via same-repo remote worktrees (passes docker lane)

The first draft added a second remote repo mid-session, whose pane pty
spawn misroutes to the local daemon with the remote cwd (pre-existing
multi-repo issue, reproducible without any retention override — a seeded
local repo plus one remote repo shows the same misroute). The spec now
budgets across three worktrees of the ONE connected repo, created through
the product createWorktree path (an external git-worktree-add only lands
as a detected worktree needing adoption) and polled through the relay's
transient post-connect reconnect window. Verified green on the local
Docker lane in 20.8s.

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

* fix(terminal): prevent remount thrashing during post-measure cool-down (

Implements the C1 retention contract: preserve worktree `hiddenSinceMs` through a
background-measure window (so TTL/ranking stay honest), but re-park waits for a
full `coldParkDelayMs` cool-down after the measure ends. Without the cool-down,
every ~3s measure lease on a past-deadline worktree thrashes remount/reattach.

Core changes:
- Terminal.tsx: add measure clock (measuringTerminalWorktreeIdsRef) and post-measure
  cool-down tracking (terminalWorktreeParkCooldownUntilRef); gate parking candidates
  until cool-down expires.
- Extract snapshot replay choreography to shared terminal-snapshot-replay-paint.ts
  (used by SSH reattach + daemon restore paths).
- Add SSH model snapshot timeout (750ms) with fallback to relay replay.
- Move cold-park recheck deadline logic to terminal-cold-park-recheck-deadlines.ts;
  add cool-down deadline to scheduling.
- useTerminalTabColdParking: implement matching measure-clock contract with per-tab
  cool-down gate to keep tab deadlines synced with worktree retention clock.
- Add resolveTerminalMountScrollbackRows() to demote new xterms under demoted
  worktrees (pane births during demotion must take the demoted tier at create).
- Add kill switches: terminalSshViewParking, terminalHiddenWorktreeRetentionBudget,
  terminalHiddenScrollbackDemotion.

* fix(terminal): detect Command Code completion in parked mid-turn panes

Seed the byte watcher with in-flight turn state from agent status: the
watcher is recreated per park cycle with no startup command to arm it,
and the banner scrolled away before parking. Also memoize
eviction-exempt checks and use SSH PTY ID builder in tests.

* fix(terminal): flush pending command-code settles on reveal remount

When a parked pane reveals mid-Command Code turn, the new detector
cannot re-observe the already-passed idle composer. Cancelling the settle
leaves the row stranded at 'working', so dispose now flushes the pending
settle instead.

Extract readInFlightCommandCodeTurn to shared space and seed detectors
with in-flight turns so remounts complete mid-flight commands. Also
memoize SSH model probes to prevent double timeouts on reattach.

* fix(terminal): remove scrollback demotion (C1 slice C)

The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.

* test(terminal): assert bounded probe during stalled reveal

Add assertion to verify that a stalled reveal operation makes exactly one
`getMainBufferSnapshot` call, ensuring retry logic doesn't introduce
redundant probes that would extend the timeout window before relay fallback.

* fix(terminal): implement C1 retention budget for hidden parked worktrees

Addresses OOM regressions in hidden parked terminals by force-evicting
worktrees past a retention budget: at most 12 mounted while hidden, none
past 45 minutes (absolute, not exempted by last-active). Eviction is
least-recently-hidden-first. Exempt tabs (unrestorable local PTYs) keep
their panes to avoid orphaning shells; worktrees are force-parked even
if they contain exempts, and their buffers released elsewhere. SSH/remote
worktrees serialize buffers pre-eviction for reveal; local worktrees keep
daemon snapshots. Command Code's done-settle window is transferred across
park/reveal boundaries so the row cannot strand at 'working'. Model probe
on SSH reattach is scoped to park-reveal only, not ordinary reconnects.
Includes new E2E suite proving the budget actually releases memory.

* memoize eviction-exempt terminal tabs to avoid redundant store reads

Each tab's exemption check re-reads the store and walks the layout tree.
Introduce selectEvictionExemptTerminalTabIds() to resolve all exempt tabs
for a worktree in a single pass, then memoize the result in Terminal.tsx
and useTerminalTabColdParking. This prevents O(n) store reads when checking
exemptions across multiple tabs and ensures the set remains stable across
unrelated re-renders.

* refactor: reformat hidden-worktree retention comments

Reflow to 80-character lines and remove internal ticket references
(C1, C1 slice C).

* fix(lint): split overlay slot and eviction-exempt tabs under max-lines

Static analysis failed because TerminalPaneOverlayLayer (401) and
terminal-parked-tab-watchers (304) exceeded oxlint max-lines. Extract the
slot component and eviction-exempt helpers into dedicated modules.

* test(terminal): stabilize retention budget e2e control arm

Stage un-parkable remote pty ids only after both worktrees are hidden, and
keep re-staging during the control-arm poll so a late updateTabPtyId cannot
flip the decoy back to park-restorable and ordinary-park it before budget
engages.

* test(terminal): pin retention e2e decoy to a mounted pane snapshot

Use the active pane-identity snapshot for the decoy tab instead of all
worktree tabs, and re-assert un-parkable ids after the control-arm hold so
a deferred/empty tab id cannot fail the budget-off mounted-count check.

* fix: memoize terminal eviction exemptions on layout leaf PTYs

Splits add leaf panes to the layout store without changing the tabs
array. A memo keyed only on tabs misses this change, leaving new panes
unexempted for unmount. Include layout leaf PTYs in the exemption memo
key so it recalculates when splits occur or PTYs are re-minted.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-29 00:47:18 -07:00
Brennan Benson 1df8aa5605 fix(dashboard): give the agent preview terminal a real pane's keyboard (#11015)
* fix(dashboard): give the agent preview terminal a real pane's keyboard

The dashboard's preview terminal is a bare xterm, not a pane, so it never
ran `resolveTerminalShortcutAction` — its only custom key handler covered
copy/paste and IME. Ctrl+Backspace therefore fell through to xterm's default
`\x08`, which readline binds to backward-delete-char: one character instead
of a word.

Route the preview's keys through the pane's own shortcut policy, so word and
line kills, Option chords, modified Enter, and scrollback chords encode
identically. Pane-scoped verdicts (splits, search, focus) are swallowed rather
than passed to xterm, which would send e.g. Ctrl+Shift+D as a bare Ctrl+D.

The policy needs three things the preview could not see:

- kitty-protocol flags — mirrored locally from the same PTY output stream
- the PTY's execution host — bytes follow the host, not the client OS, so a
  new `DashboardCard.terminalInput` profile is derived in the main renderer
  (the only one holding the store) and relayed to the pop-out
- host terminal options — the ConPTY backend and the kitty withhold now apply

Also brings the emulator itself up to a pane's: Orca's Unicode 11 width shim
(replayed CJK/emoji/ZWJ laid out wrong without it), Windows Ctrl+Alt chord
repair, user font/cursor/line-height/word-separator/sensitivity settings,
ligatures, the TUI wheel multiplier, lazy Arabic shaping, clickable links, and
the IME candidate anchor — extracted from pane-lifecycle so both surfaces share
one implementation.

The in-window drawer built its snapshot from a slice subset, which would have
degraded the new profile to client-OS defaults there; it now reads the full
store non-reactively.

* fix(dashboard): enumerate pane-scoped chords instead of a default case

The switch-exhaustiveness gate rejects a `default` over the shortcut-action
union — it would let a newly added action be swallowed silently instead of
forcing a decision at the preview's boundary.

* fix(dashboard): preserve native shortcuts and PTY host routing

* chore: keep merge scope limited to dashboard

* perf(dashboard): avoid full-store copies for terminal profiles

* fix(dashboard): validate terminal input profiles at IPC boundary

* fix(dashboard): sync preview terminal refs on commit, not during render

react-compiler rejects ref writes in the render pass; every reader is an event handler or a post-await continuation, so a commit-phase sync is equivalent.

* test(dashboard): cover the three seams that relay the host-input profile

Reverting any of them left every suite green: the dialog's terminalInput prop
(the only reader of DashboardCard.terminalInput), the drawer's hand-threaded
store slices, and the pop-out's republish triggers. Each new assertion was
mutation-checked against its source line.

* fix(dashboard): follow the WSL host for a preview terminal's byte routing

The card resolver handed resolveTerminalInputHostPlatform a transport with no
getLocalSessionMetadata, so a WSL pty on a Windows client resolved to win32
while its own pane resolves linux — Shift+Enter would then encode CSI-u where
the pane sends alt-enter. Mirror the pane transport's own gate.

* fix(dashboard): republish on every slice the host-input profile resolves from

The compare set covered 4 of the ~11 slices that decide a card's execution
host, so a change to the rest (folder workspaces, project groups, the runtime
catalog, detected worktrees) never triggered a publish. On a quiet board there
is no later publish to heal from, and the pop-out — which cannot re-derive the
profile — keeps encoding bytes for the host the pty used to run on.

* fix(dashboard): sync preview terminal refs on layout, not on a passive effect

xterm's keydown is a native listener, so React never flushes a passive effect
before it. A just-relayed host profile could therefore miss the next keystroke.

* test(dashboard): pin the preview's replay-vs-live kitty scan

The existing A/B passed either way: a lone CSI > u sets the same flags through
scan and scanReplay. Redeliver the push across a snapshot and its replay so
stack semantics would leave the TUI's single pop on a stale frame.

* fix(dashboard): rebuild the drawer's snapshot on every host-input slice

The in-window drawer read ~12 host slices through getState() while subscribing
to none of them, on the premise that agent activity drives the next rebuild. A
quiet board has no such rebuild: an SSH handshake completing with every agent
idle leaves the preview terminal encoding bytes for the pre-connect host, and
agentStatusEpoch only ticks on live status changes so it never heals. Watch the
same set useDashboardPopoutBridge republishes on — each writer bails out when
nothing changed, so the added deps are far quieter than agentStatusByPaneKey,
which already rebuilds this memo on every status ping.

The existing coverage mounted a second hook, which always recomputes; the new
test re-renders the same hook after changing only sshConnectionStates.

* fix(dashboard): key the preview by the user's terminal shortcut policy

The preview passed 11 of the 12 inputs the pane's policy takes and let the
12th default to orca-first. Under terminal-first a remapped tab.close chord is
meant to yield to the shell — Ctrl+W is a word-kill there — but the preview
kept claiming it as a pane close and swallowed the bytes.

* test(dashboard): pin the host-input profile to card snapshots only

The count path main added in #11042 renders no cards, so it must not pay a
per-pty host resolution on every agent-status tick. Both assertions run against
a card that does have a live pty, so only the gate keeps the profile off.

* refactor(dashboard): extract the board's client-host read

The merge of main's label bounding pushed build-dashboard-snapshot.ts to
302 lines. The client's own platform facts are a distinct concept from the
pty host each card keys against, so they move out rather than earn a
max-lines bypass.
2026-07-28 21:57:41 -07:00
afbd98d8a4 Support Windows drives in the remote host filesystem picker (#7439)
* Support Windows drives in the remote host filesystem picker

The remote picker was locked to the system drive on Windows hosts: the
breadcrumb root resolved to C:\ and typed drive paths (M:\dev) were
treated as filter text, so projects could only ever be created on C:.

- Server: answer host-root browses ('/') on win32 with the mounted
  drives instead of resolving to C:\.
- Client: recognize drive-anchored input (M:\, M:/, m:) as path mode,
  resolve segments from the normalized drive root, and make
  joinPath/parentPath/breadcrumbs drive-aware. Up from a drive root
  returns to the host root (the drive list).

Fixes #7438

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

* Document why joinDrivePath uses a literal backslash

Review feedback suggested path.win32.join, but the renderer bundle
imports no Node builtins anywhere and runs sandboxed, so path.win32 is
not available here. The backslash targets the remote Windows host
regardless of client OS; say so at the call site.

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

* Complete Windows drive browsing over SSH

* fix remote Windows drive browsing

* fix(ui): key remote breadcrumbs by path

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 20:51:14 -07:00
余辉andOrcaWin 3f53287554 fix(mobile): accept WebSocket pairing addresses (#9912)
* fix(mobile): accept websocket pairing addresses

* fix(mobile): align manual pairing address validation

* docs(mobile): correct custom address grammar comment

* fix(mobile): enforce pairing endpoint size limit

* fix(mobile): reject canonical IPv6 wildcard addresses

* fix(mobile): handle unscannable pairing offers

* fix(mobile): reset custom address dialog on close

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 20:34:26 -07:00
9150ac65cb Fix Windows setup sequencing wrapper quoting (#8806)
* fix(setup): correct Windows sequencing wrapper quoting

* test(setup): preserve spaced Windows batch paths

* refactor(setup): dedupe PowerShell encoder, clarify wrapCmd comment

Route the Windows setup-sequencing and Hermes startup planners through the
shared renderer-safe encodePowerShellCommand instead of two verbatim btoa
copies, and make that shared encoder renderer-safe (Buffer is unavailable in
the sandboxed renderer where both planners also run). Reword the wrapCmd
comment so it describes the current single-outer-quote behavior instead of the
old quote-doubling bug.

* test(setup): cover Windows metacharacter paths

* fix(setup): keep Windows runner paths out of cmd source

* test(setup): preserve Windows setup failures

* docs(setup): explain safe cmd path handoff

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 19:58:52 -07:00
NeilandOrca 5c59c84c7a fix(plugins): close four trust-boundary holes in the plugin system (#11232)
* fix(plugins): close trust-boundary holes in the plugin system

Move five security decisions to their chokepoints rather than leaving them
enumerated at individual call sites.

- Kill-list revocation reaches content packs: PluginContentPackRegistry now
  takes an isKilled predicate and intersects it with any caller-supplied
  approval, so a killed plugin's VM recipes can no longer reach
  spawn(..., { shell: true }) through either reconcile() call site.
- Bound kill-list generatedAt to a 24h future skew at the parse chokepoint.
  A far-future timestamp previously made every genuine later list look
  "older" and disabled revocation permanently, persisted across restarts.
- Protect the whole auto.components.settings.Plugin* translation subtree
  instead of an enumerated prefix list, so language packs cannot forge the
  consent provenance badge or rewrite install-error security copy.
- Resolve manifest panel icons by own-key only; "constructor"/"__proto__"
  previously yielded non-component prototype members that crashed the
  right sidebar to its error boundary.
- Give panel liveness frames a reserved control budget so a panel that
  saturates its action budget can still answer the watchdog.

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

* fix(plugins): keep the kill-list future bound off the cache read path

The schema-level generatedAt bound re-judged the on-disk cache against the
device clock at every launch, so a client whose clock ran behind the last
genuine publication discarded its whole cached kill list and started with
zero revocations. Move the bound to the two fetch chokepoints instead.

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

* fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU

Review follow-ups on the trust-boundary fixes:

- The reserved liveness lane had a per-window count equal to the ping
  interval, so a panel's own pong-shaped traffic could spend it and drop
  the next genuine reply — reintroducing the starvation the lane exists to
  prevent. The lane is now size-bounded only; rate stays bounded because
  every pong is also charged to the data budget.
- Only schema-valid pongs take the lane now, so near-miss pong-shaped junk
  cannot drain it. readPanelPongId replaces the zod parse on this
  guest-controlled path (a rejected safeParse allocates an issue list, ~90x
  the accepted-path cost) and is pinned to the schema by a parity test.
- Re-read the kill list inside approveAtomically: approvedKeys is snapshotted
  before an awaited verification phase, so a plugin killed during that wait
  could still publish VM recipes and language packs.
- Assert the curated icon resolves to FileText; the old equality also passed
  when both sides fell back to Plug.

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

* fix(plugins): match zod's safe-integer bound in the pong reader

readPanelPongId used Number.isInteger, but zod's .int() rejects anything
above 2**53-1, so pingIds like 1e100 took the reserved lane the schema
would have refused. The parity test never probed that boundary.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-28 17:49:20 -07:00
Brennan Benson 681c4ba458 fix(skills): stop calling the updater's own install a modified copy (#11249)
After a successful headless update the CLI installs source-repo HEAD,
which legitimately runs ahead of any shipped bundle. The scan classified
those bytes 'unrecognized', so the row went amber ('may be modified…
remove it') seconds after our own Update button ran, and the advice
looped: remove + reinstall lands the same newer content.

Scan half: a canonical/alias placement whose observed git tree sha
equals the updater lock's skillFolderHash is the CLI's own install, not
a user edit — reclassify it 'newer-known'. Display half: 'newer-known'
is recognized official content ahead of this build with nothing to fix,
so it no longer marks the copy blocked. Eligibility is deliberately
unchanged: ahead of the bundle means there is nothing this build can
update to, and offering one risks the provably-unperformable update
(#11110) when source HEAD still equals the lock.

Copies whose sha does not match the lock, copies with no lock entry,
same-name copies outside the placements the command writes, and
plugin-cache behavior all stay flagged exactly as before.
2026-07-28 17:27:26 -07:00
NeilandOrca 1d7e7656e3 fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales (#11241)
* fix(ui): preference sync, picker arming, zoom, chat status, and reverted locales

7.1 ui.set rejected whole preference payloads on enum drift. The new
AssertNoMissingKeys guard is key-only, so it could not see that
LegacyWorktreeCardProperty omitted 'cli' (in DEFAULT_WORKTREE_CARD_PROPERTIES)
or that rightSidebarTab omitted 'workspaces'/'pr-checks' and every plugin tab.
UiUpdate is .strict(), so one bad value failed the entire batch and silently
dropped sidebarWidth/groupBy/sortBy/filterRepoIds riding the same debounced
write. Both enums now derive from the shared unions, AssertNoMissingValues
catches value drift by name, and UiUpdate drops an unknown value instead of
rejecting the batch around it. Unknown KEYS still reject.

7.2 The SSH shell-ready fallback moved from first-output to spawn, so a remote
shell needing >1.5s to prompt got the bracketed-paste startup command before
readline armed it, with no recovery afterward. The short deadline now applies
only once output proves the shell is talking; a silent-since-spawn shell gets a
longer budget and still delivers eventually.

7.3 The project picker armed in rank order but rendered in section order, so
with a folder group present the BOTTOM row was armed on open and Enter created
the workspace in the wrong place. Row keys now derive from the same sections
that render. The folders bucket also gains the recent-exclusion guard the
projects bucket has; that duplicate was unreachable, so this is symmetry, not a
live bug fix.

7.4 setBrowserPageZoomLevel now compares before writing, so a pane reasserting
a level the host already holds no longer emits a redundant host-wide
HostZoomMap write. The user-applied level also moved to a module-level map
keyed by page id: the guest webview outlives its React pane, so the pane-local
ref re-seeded from the shared Settings default on every remount and let a later
default retroactively hijack an already-zoomed tab. See PR notes on the part of
this finding that could not be fixed as prescribed.

7.5 A non-null sessionId short-circuited the live-work escape hatch, forcing
'loading' over hook 'working' and rendering an idle pane mid-turn: Send instead
of Stop, no typing indicator, no streaming preview. Status stays 'working'; the
empty-transcript loading SURFACE moves to selectNativeChatViewState, which keeps

7.6 #10770 merged from a base predating #8549, reverting 182-187 translated
strings per locale to English (es 182, ja/ko/zh 187) plus en.json's recipesHelp.
Restored by script, only where the English source is unchanged between the two
shas, so later legitimate edits are preserved: 0 keys added or removed, every
value sourced from 97e4776dfe, and the four other English-source changes since

7.7 Match highlighting indexed by UTF-16 code unit but rendered by code point,
so an emoji-named folder showed marks one glyph late. Cosmetic.

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

* fix(ssh): keep fast startup delivery on the short fallback deadline

The 15s no-output budget added for the shell-ready fallback was applied to
every SSH launch, including 'fast' delivery. Fast delivery waits for no
marker and pastes nothing prompt-sensitive, so it gained a 10x startup
delay for nothing.

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

* fix(rpc): generalize the ui.set value-parity guard to every shared key

Naming worktreeCardProperties and rightSidebarTab left the next field to drift
exactly as unguarded: dropping 'pr-status' from groupBy typechecked clean.
Check the value domain of every shared key instead, against z.input (what a
client may send) rather than z.infer (post-transform).

Also move the pure mergeNativeChatLiveSession suite beside the module it covers;
the hook's test file owns an IO harness and was at the max-lines cap.

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

* fix(i18n): re-apply only the locale strings still reverted at HEAD

#10770 merged from a base predating #8549, so its stale locale copies
overwrote ~185 already-translated strings per locale back to English. A
present catalog value always beats the English translate() fallback, so
those strings render English with nothing to signal the loss.

Since that finding was written, #11205 and other upstream translation
passes independently re-covered most of ja/ko/zh. Replaying #8549's
catalogs wholesale would now overwrite that newer work, so this re-applies
a key ONLY where all of the following hold at origin/main: it was
translated at 97e4776dfe, #10770 reverted it, its English source is
unchanged since, and no upstream commit has touched it since the revert.

  es 182  ja 32  ko 17  zh 55

Everything else is left to upstream. Verified zero upstream translations
reverted: every changed key still matches its #10770 value at main. Keys
upstream deleted are not resurrected, and keys whose English source was
edited since are skipped as legitimate source changes rather than reverts
(this is what keeps zh CPU on #11205's deliberate "CPU" over #8549's
"中央处理器"). Key count and order are unchanged in all five catalogs.

en.json's own recipesHelp was reverted by the same stale base and no
upstream commit has touched it since, so it is restored to match the live
source at EphemeralVmsPane.tsx:252.

* fix(rpc): keep null in the ui.set value-parity guard

NonNullable stripped null as well as undefined, so dropping .nullable()
from a `| null` field passed the guard while still rejecting the batch at
runtime -- the exact drift class the guard exists to catch. Proven: making
visibleWorkspaceHostIds non-nullable typechecked clean before, now errors
by name. Also pins the 15s silent-shell budget so it cannot silently
shrink back toward the short deadline.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-28 17:25:05 -07:00
NeilandOrca a721125d06 fix(perf): correct three 07-27 perf regressions (#11234)
* fix(perf): correct three 07-27 perf regressions

Traversal capacity cap no longer scales with worker concurrency
(#11026). retainWorkspaceSpaceScanEntry charged a traversal-wide entry
counter, so N workers each holding a listing multiplied the live charge.
At concurrency 48 a 48x2,100 tree (100,848 entries) hit the 100,000 cap
while 100x1,500 (150,100 entries, 50% more) passed, and scanLocalWorktree
treats the capacity error as terminal, reporting an intact worktree as
"Unavailable" with sizeBytes 0. The cap is now per directory listing --
the only quantity fixed by directory shape -- restoring the invariant
docs/workspace-space-scan-resource-bounds.md already states. Aggregate
live retention stays bounded by the unchanged 64 MiB byte cap.

Note: releasing each entry's charge at dispatch (the originally suggested
fix) was measured and does not help; the peak is set at admission, before
any entry is dispatched.

Repo image icons are no longer fully base64-decoded on every snapshot
publish (#11012). sanitizeRepoIcon reached decodeBase64Prefix, which
sized its buffer to the whole payload to read a 24-byte header, running
synchronously inside ipcMain.handle at a 250 ms throttle. Validation is
now memoized on source+src in a BoundedMap. Measured for 10 icons x
256 KB: 37.34 ms -> 0.67 ms per publish.

One over-long card label no longer discards the entire snapshot (#11012).
isDashboardSnapshot was all-or-nothing and dashboard-popout returned
early with no log while replaying lastSnapshot, so `orca terminal rename
--title "<1025+ chars>"` froze the pop-out board on its last good paint
with nothing surfaced. Labels are truncated at the producer, the
validator drops only the offending card, and both the rejection and the
drop are logged. The bound now lives in the shared snapshot contract so
producer and validator cannot drift.

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

* fix(perf): charge a scan listing's parent path once, not per entry

The 4.1 fix made the entry cap per-listing but left the 64 MiB byte cap
charging parentPath.length for every entry in a listing. Because a
listing's entries all share one parent-path string, that multiplied the
path by the directory's width, so the byte cap measured checkout depth
rather than live heap.

The reported symptom therefore still reproduced at the production default
limits: 48 x 2,100 @ concurrency 48 raised a capacity error once the
worktree path passed ~58 characters, while the same layout at concurrency
1 succeeded. The shipped regression test could not see this because it
passes maxRetainedBytes: Number.MAX_SAFE_INTEGER, disabling the only cap
still in play. Measured at a real 65-char worktree root, 3 of the report's
4 documented layouts still failed.

The parent path is now charged once per listing, with its first entry, so
an empty listing strands no charge. Per-entry overhead is unchanged at
512 B + name, which still dominates the estimate, so the OOM protection
the original PR added is preserved.

Adds a production-default-limits case covering the report's layouts under
a deep root, plus an assertion that a short and a deep root reach the same
verdict -- the path independence docs/workspace-space-scan-resource-bounds.md
requires and which no existing test enforced.

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

* fix(perf): prove the icon cache by decode count, not wall clock

The caching test asserted a per-publish millisecond budget, which failed
on CI at 5.64 ms against a 5 ms ceiling. Any threshold flakes on a loaded
box, so count real sanitizeRepoIcon entries instead: 10 repos x 20
publishes is 200 icon checks against exactly 1 decode. Added cases pin
the cache key (payload and source both re-decode; a cached image verdict
never answers for an emoji) and that a rejection is cached too.

Also drops budget.entries, which the per-listing cap left as a
traversal-wide counter no check reads -- exactly the shape a future
guard could reintroduce the concurrency bug from.

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

* fix(dashboard): bound the project filter label the whole board rides on

#11042 added snapshot-level filterOptions whose project labels are
repo.displayName -- the same unbounded source this PR already bounds for
card.repoName, but one level up where dropping a card cannot recover it.
An over-long project name would fail isDashboardFilterOptions and take
the entire snapshot with it, which is the exact frozen-board failure the
per-card drop was added to end. Workspace-status labels are already
capped at 32 by workspace-statuses.ts, so only projects needed this.

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

* fix(dashboard): disambiguate the repo icon cache key

The memoization key joined `source` and `src` with a space, but the
sanitizer's base64 pattern admits whitespace inside a valid `src`. A
rejected icon can therefore split the same concatenation differently and
inherit an accepted icon's cached verdict, reaching the pop-out's
`<img src>` without ever being sanitized. Length-prefix the source.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-28 17:08:14 -07:00
Brennan Benson f790d9cbe8 fix(skills): stop the skill review dialog contradicting the badge that opens it (#11128)
* fix(skills): stop the skill review dialog contradicting the badge that opens it

A skill whose only fault was an edited copy or one Orca could not read turned the
setup-rail badge amber and offered Details — and Details opened a dialog headlined
"All installed Orca skills are up to date." over an empty list. The badge says
something is wrong, the dialog it points at says nothing is.

The grouping only returned skills with an out-of-date copy, so those two states
produced no row and the summary fell through to the all-clear headline. Include a
skill when a copy needs attention as well, using one shared predicate so the badge
and the dialog cannot disagree again. A plugin's own copy of a same-named skill
stays out: that is the vendor's, not the user's drift.

* test(skills): pin that a routine outdated copy raises no attention marker
2026-07-28 16:49:18 -07:00
Brennan Benson 747b241145 feat(main): record main-thread hangs so we can measure them (#10256)
A deadlocked main thread never crashes, so it leaves no crash report and no
artifact — incidence has been unmeasurable (n=1 confirmed, macOS 26.5.1,
FB24004458 / electron#52437). This forks a plain-Node watchdog sibling under
ELECTRON_RUN_AS_NODE that survives the deadlock, listens for a 2s heartbeat,
and after 45s of silence writes a marker to userData. The next launch consumes
it, records a durable crash breadcrumb, and emits a main_thread_hang_detected
telemetry event carrying unresponsive_ms and self_recovered.

Observes only — it never kills or relaunches the parent. A true positive
recovers nothing force-quitting wouldn't, while a false positive would SIGKILL
a live main thread mid-write. self_recovered counts exactly the stalls such a
killer would have gotten wrong, so recovery can be built on evidence if the
field numbers justify it.

macOS-only, packaged-only (ORCA_HANG_WATCHDOG_FORCE=1 to test), with sleep-gap
suppression and idempotent shutdown on will-quit.
2026-07-28 16:43:30 -07:00
Brennan Benson d3681f6306 fix(runtime): surface desktop RPC startup failures (#11037)
* fix(runtime): surface desktop RPC startup failures

* fix(runtime): isolate RPC failure telemetry

* fix(runtime): satisfy the changed-code quality gate and kill vacuous dialog tests

The `no-floating-promises` label span covers the whole `app.whenReady().then()`
callback, so adding lines inside it made a long-standing finding overlap changed
code. `void` is the linter's own suppression; no `.catch()` on purpose.

The startup-failure tests were vacuous: mutation runs showed the wait-for-show
deferral, the destroyed-window guard, the `closed` companion event, listener
cleanup, the cause walk, the cycle guard, and the truncation bound could all be
deleted with every test still green. The "not called yet" assertion ran before
any microtask, so it passed either way.

* test(runtime): de-brittle the desktop RPC-failure source assertions

Anchoring the slice on the full destructure and matching the whole dialog
call expression made an innocuous rename break the test with a cryptic
'expected -1'. Match the shape that is actually the contract instead.

* test(runtime): repair the silently-unbounded desktop startup slice

The desktopEnd anchor comment lost a word in 98b00d3a64, so indexOf returned
-1 and slice(start, -1) covered index.ts to EOF. Moving the dialog call to a
path that never runs at startup still passed. Anchor on code instead, and
assert both bounds so a future reword fails loudly.

* test(runtime): bound the attach anchors in the startup ordering slice

Round 3 bounded the desktop pair but left attachStart/attachEnd unguarded in
the same test: deleting the PTY startup barrier from attachMainWindowServices()
and breaking the rateLimits.attach(window) end anchor still left the case green.

* test(startup): bound the last two unguarded slice anchors in this file

Rounds 3 and 4 fixed the desktop and attach pairs; two instances of the same
class survived in the same file, both proven vacuous by mutation:

- it #3 never bounded readyEnd. Renaming the `pairing:` payload key makes it
  -1, widening readyPayload from 372B to ~52KB. Moving the reconciliation
  status out of the serve-ready payload (its whole point) but leaving it later
  in index.ts then kept all 6 cases green.
- it #2 bounded desktopWindowStart against reconciliationStart rather than
  serveEnd. An earlier `Promise.resolve(openMainWindow())` steals the anchor,
  collapsing desktopStartup to '' while every existing guard still passes, so
  its only assertion — a negative — succeeds against an empty string.

Both mutants now fail. `src/main/ipc/pty-startup-barrier-ordering.test.ts:11`
has the same latent shape; left alone as out of scope for this PR.

* fix(runtime): keep walking the cause chain past an unmapped code

getErrorCode returned the first code it found, so an outer wrapper carrying
an unrecognised code masked a nested EACCES/ENOSPC and classified it unknown.
Only a mapped code ends the walk now; every other input classifies as before.

Unreachable today (writeSecureFile rethrows raw fs errors with .code intact),
but the classifier's job is surviving whatever error shape reaches it.

* fix(runtime): tell the user what to fix, not just to restart

The dialog's only advice was "Restart Orca to try again", which is true for
address_in_use and wrong for the rest: permissions, a full or read-only disk,
and a missing data folder all survive a relaunch, so the user restarted, hit
the same failure and had no next step.

Route the error class we already compute into the copy so each cause names the
thing the user has to change. Guidance and telemetry now derive from the same
classifier, so they cannot drift apart.

* fix(runtime): guide users through long RPC paths

* fix(runtime): avoid false window listener warning

* fix(runtime): guard destroyed window before web contents
2026-07-28 16:05:05 -07:00
Brennan Benson 13c193a00a feat(dashboard): add agent status search board (#11042)
* feat(dashboard): add agent status search board

* fix(dashboard): keep idle controls reachable

* chore: drop merge-only formatting drift

* fix(dashboard): compare sparse subagent snapshots safely

* fix(dashboard): satisfy settings handler lint

* fix(dashboard): address review feedback

* fix(dashboard): complete search and localized status copy

* fix(dashboard): pad active filter row

* fix(dashboard): keep idle control in board settings

* fix(dashboard): source filters from workspace state

* fix(dashboard): clarify PR and MR status filter

* fix(dashboard): preserve review and board parity
2026-07-28 15:51:43 -07:00
Brennan Benson 50f46889d9 fix(ai-vault): resume a bridged Codex session under the selected account's home (#11224)
* fix(ai-vault): resume a bridged Codex session under the selected account's home

The account session bridge hardlinks every rollout into each per-account
CODEX_HOME, and vault dedup keeps the lexicographically-smallest alias, so
Resume could pin an inline CODEX_HOME naming a peer account — running the
session under that account's auth.json and quota. At resume time the owning
host now substitutes the selected account's home when it holds the same
rollout at the same sessions-relative path, declining on any uncertainty so
resume degrades to today's behavior instead of failing.

* fix(ai-vault): repin dropped sessions without a cwd instead of resuming under the wrong account

The drag payload only carried sessionCwd when session.cwd was truthy, so a
null-cwd codex session dropped onto a pane silently fell back to the prebuilt
command - which pins the wrong account's CODEX_HOME, the exact defect this PR
eliminates on the other resume surfaces.

- Serializer always sends sessionCwd (null when the session has no cwd), so
  absence now only means an older-serializer payload.
- The repin rebuild accepts a null cwd (the builders already omit the cd
  prefix), matching the sidebar Resume/Copy paths which repin regardless of cwd.
- An unrepinnable payload (absent sessionCwd) now fails loudly with guidance
  instead of silently resuming under the wrong account's home.
2026-07-28 15:18:07 -07:00
Jinjing ca5a821600 Stop relaunching creation-time agents on workspace activation (#10647)
* fix(activation): stop relaunching the creation-time agent on workspace activation

Activating a workspace with zero renderable tabs launched the agent it was
created with, unprompted and in approval-bypass mode. Navigation is not consent
to start a process: the same fallback fired from post-delete focus handoff, the
jump palette, keyboard cycling, CLI/relay activation, and notification clicks.

The mechanism was superseded. #1814 added it when relaunching the created agent
*was* the resume feature; #4706 later added real provider-session resume six
lines above and left the fallback in place. What remained fired whenever a
workspace had no renderable tabs -- including when nothing had ever slept -- and
reported itself as `request_kind: 'resume'` while resuming nothing, discarding
any resumable session a plain tab close had already purged.

No caller depends on it. All seven intent-carrying callers pass an explicit
`startup` on the branch where they intend a launch, and every no-startup branch
either declined an agent, already has one running (host `didSpawnStartup`), or
is this same defect arriving over IPC.

Drops the now-orphaned imports, retargets the stale comment in
launch-work-item-direct that cited reopen-relaunch as the reason to persist
`createdWithAgent`, and moves the WSL default-args quoting assertion to
launch-agent-in-new-tab, whose launch path still resolves those args.

Regression tests are revert-sensitive -- all four fail if the fallback returns.

* test(activation): name the relaunch regression tests after what they reach

Three tests were named after scenarios they never invoked, which is the
failure mode that lets a coverage gap read as closed.

- The "host-originated" test's `notifyHostRuntime: false` is inert here: both
  gates resolve through `isWebRuntimeSessionActive`, false with no runtime
  environment seeded, so it was byte-identical to the plain reopen test. It no
  longer claims to cover the host `didSpawnStartup` leg, which lives in main and
  is unreachable from this layer.
- The "post-delete focus handoff" test never deleted anything and never touched
  `prepareActiveWorktreeFocusAfterDelete`. That caller is asserted directly in
  active-worktree-focus-after-delete.test.ts, which locks out any opts.
- The activate/close loop resets state instead of calling `closeTab`, so it does
  not exercise the sleeping-record purge its comment claimed.

Also folds the primary reopen test onto `seedEmptyActivatableWorktree` — the
fixture extracted for exactly that state, which its inline copy had drifted from
by hardcoding a POSIX repo path.

`preflight` is dropped from the launch-work-item-direct comment: the trust
preflight reads the create-time argument (worktree-remote.ts), not the persisted
meta. Removal safety and ownership do read the field and remain accurate.

Renames the ported quoting test to what it pins. Under vitest's node
environment `navigator.userAgent` carries no "Windows", so platform resolution
bails before the WSL branch and the WSL preference is inert — the real coverage
is single-quote escaping of user-configured agentDefaultArgs.

* transfer large terminal history seeds across bounded protocol messages

- Oversized cold-restore snapshots (>1MB) now upload via chunked startHistorySeedTransfer/appendHistorySeedTransfer protocol instead of inline, avoiding NDJSON line-size violations
- Checkpoints automatically trim oldest rows to fit within configured byte limit (200MB) before commit
- Protocol v30 required for chunked transfers; v29 daemons gracefully fall back to renderer-only recovery
- NDJSON encodeNdjson() validates line size and rejects oversized payloads; notifications silently swallow encoding errors

* fix(daemon): drop held output when teardown checkpoint fails to serializ

When a final snapshot checkpoint fails to serialize (returns retryable), the
pending output records must not be appended later—doing so would splice them
over the seq gap left by the failed snapshot, defeating gap detection. Drop
the records and retry the checkpoint instead.

* Bump daemon protocol version to 30

* Bump daemon protocol version to 30
2026-07-28 15:12:44 -07:00
Brennan Benson 930ff96152 fix(skills): stop the scan issue budget evicting a read failure (#11221)
The per-scan issue budget kept an issue only when it explained a candidate
or truncated the walk. Neither set intersects the attention set, so
'io-error' — the sole reason a plugin-cache scan can raise "Needs
attention" — was droppable. Once 16 ordinary issues filled the budget (16
'outside-root' vendor symlinks is an install shape the scan itself
documents as normal), a later read failure was evicted for a generic
'issue-limit' row that raises neither attention nor truncation, and the
dialog headline read "All installed Orca skills are up to date" over a
path that could be hiding a stale copy.

Attention issues now outrank the budget, capped at a small reserve so an
adversarial tree of unreadable folders cannot pin one issue per folder.
2026-07-28 14:41:27 -07:00
Brennan Benson a81f17c189 fix(skills): trust the updater's lock when a run installs content newer than the bundle (#11220)
skills update installs source-repo HEAD, which routinely runs ahead of the
revisions a shipped build bundles. The post-run re-scan hashed that content
'unrecognized' (the registry has never seen it) and the verdict counted it as
a failure — so a clean update reported "The update didn't finish / Updated 0
of N", and Retry repeated the false failure forever because the CLI now
no-ops (lock == source). The 'newer-known' escape hatch never fires: the
generator always points the manifest at the registry's newest snapshot, so no
observed content can hash to a revision newer than the bundle.

The verdict now computes the git tree sha of the observed bytes (a port of
the generator's hashing, verified byte-for-byte against git write-tree and
against every shipped skill's manifest gitTreeSha) and accepts an
unrecognized placement when that sha equals the lock's skillFolderHash: the
lock is the CLI's own record of what it installed, so disk matching lock
means the command did its job — the bundled registry simply has not seen
that revision yet.

Half-written bundles (sha mismatch), unreadable copies, removed skills,
degraded aliases, and outdated copies at the lock hash all still fail.
2026-07-28 14:37:31 -07:00
Brennan Benson 6d4e335001 feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459)
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml

Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new
worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable
directories. Copying `node_modules` per worktree is slow and duplicates disk,
and each worktree's install then diverges.

Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of
gitignored directories that are symlinked (shared) into every new local
worktree, so one install serves them all. Adds to, never replaces, the per-user
Worktree Shared Paths setting.

`createWorktreeSharedPaths` uses a new 'share' materialization mode that always
symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give
each worktree an independent node_modules and defeat the point; 'link' and
'copy' behavior are unchanged.

Entries must exist as gitignored directories in the primary checkout; absolute
paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a
malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips
this, as it does symlink paths and `.worktreeinclude`.

Closes #10451

* fix(worktrees): keep worktrees deletable after sharing a directory

A directory-only ignore rule (`node_modules/`, the common spelling) matches
the primary checkout's real directory, so the shared directory resolves and
gets symlinked — but it never matches the worktree's symlink, so Git reports
that link as untracked. Deletion only tolerated the per-user shared paths, so
every worktree in such a repo became permanently dirty: the clean preflight
threw "uncommitted or untracked changes" and `git worktree remove` refused
without --force.

Feed the configured `orca.yaml` shared directories into the same
tolerate-and-unlink machinery the per-user shared paths already use, at both
deletion call sites. The names are read unfiltered, since the create-time
resolver drops exactly the entry deletion needs most.

* test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock

orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for
../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports
lazily, so this only stays green because no runtime test configures a repo with
worktree.sharedDirectories — the first one that does would fail on a mock
resolution error rather than on its own assertion.

* fix(source-control): don't count shared symlinks as uncommitted changes

A directory-only ignore rule (`node_modules/`) matches the primary checkout's
real directory but never the worktree's symlink, so Git reports the shared link
as untracked for the life of the worktree. That made every affected worktree
read as dirty: a phantom row in the diff view, and Create PR blocked with
`blockedReason: 'dirty'` telling the user to commit an entry they cannot
commit, because it is a symlink Orca created.

Status and the review-creation preflight now drop untracked entries that are
both declared shared (per-user shared paths or orca.yaml sharedDirectories) and
actually symlinks on disk. Both conditions are required, so a regular file at a
declared name, or a symlink nobody declared, still counts as user work. The
decision fails closed: anything not positively identified stays dirty.

The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes
are compared raw rather than C-quoted, with a parser that consumes the origin
field a rename emits instead of reading it as its own record.

Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks
would pull APFS cloning, and its child_process dependency, into the status graph.

SSH is unaffected and left alone — remote worktree creation skips the symlink
and shared-directory passes, so a remote worktree never has one.

* fix(source-control): wire shared links into local status

* fix(worktrees): resolve the status repo once and reject uncollapsed shared paths

`git:status` resolved the registered worktree's repo twice per call — once
inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link
lookup — walking every repo's worktree meta on a polling path.

`apps/./web` also survived `sharedDirectories` normalization: `resolve()`
collapses it when the symlink is created but Git reports the collapsed path, so
every later comparison misses and the link reads as permanent untracked work.

Also stop resolving shared links for SSH repos in review creation: `repo.path`
names a path on the remote host.

Adds the missing wiring coverage for review creation and runtime status, plus
the untracked-only conjunct in both filters — all four were mutation-verified
to leave the suite green before these tests.

* test(worktrees): pin the resolver-to-status seam for shared directories

The resolver's output and the status filter were only tested apart — status
used a hardcoded `['node_modules']`. Feed the resolved directories back through
`getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever
returned a differently-spelled path can no longer leave the link showing as a
phantom untracked row.

* fix(worktrees): try a directory junction before a symlink on Windows

A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary
Windows user got EPERM, the per-path catch logged and continued, and the
worktree came up with no shared directory and no signal. A directory junction
needs no privilege, and the rest of the codebase already uses one for win32
directory links.

The symlink stays as a fallback rather than being replaced: a junction cannot
target a UNC path, and a WSL project's repo lives behind one, so replacing it
outright would trade the local-volume bug for a WSL regression.

Safe for the removal path either way — Windows reports a junction as both a
symlink and a directory, so the `isSymbolicLink()` unlink that runs before
`git worktree remove` still fires and still refuses to follow it.

* fix(worktrees): keep NUL bytes and tolerated links out of the removal error

The removal preflight switches to `git status --porcelain -z` whenever it has
shared links to tolerate, then attached that raw stdout to the error. `.trim()`
does not strip interior NULs, so the message reached the user as
`?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the
shared link, the one entry that is not the user's work and cannot be committed
away.

Parse the NUL-delimited output once and use it for both the clean verdict and
the error text, so the two can never disagree about what blocks removal. The
`-z` switch stays: it is what keeps paths with spaces or non-ASCII names
comparable against the configured entry.

* chore(worktrees): drop stray reformatting and note why the SSH guard exists

Committing the merge staged 792 files, so lint-staged ran the formatter across
all of them and rewrapped three renderer files that were already unformatted on
main. Nothing was lost — they were byte-identical to main ignoring whitespace —
but they showed up in the pull request as unrelated changed files. Restored to
main's exact bytes.

Committed with --no-verify on purpose: the pre-commit formatter is what
introduced the rewrapping, so letting it run again would simply reapply it.
Every check it would have run was run by hand instead — lint, typecheck, and the
IPC and source-control suites all pass, and the three restored files are
expected to fail a format check because that is main's current state.

Also records why the connection guard on the shared-link lookup is not dead
code: the remote dirty check ignores those paths, so the guard's only effect is
avoiding a stray local read and the bad cache entry it would leave behind.

* refactor(source-control): drop a scan-everything guard and freeze the cached list

The dirty check built a filtered array only to read its length, so it always
scanned every status record; asking whether any record is untracked stops at the
first one and reads the same either way.

The cached shared-directory list was also handhanded out by reference, so a
caller that mutated it would corrupt every read for the rest of the cache
window. Marking the return readonly prevents that at compile time; copying on
return would work too but would allocate on the status-polling path, and there
is exactly one caller, which only spreads it.
2026-07-28 14:04:41 -07:00
Brennan Benson b41e813cb5 fix(native-chat): surface draft launch context in desktop and mobile chat composers (#9802)
* fix(native-chat): surface draft launch context in chat composers

Creating a workspace from a GitHub issue delivers the issue link only into
the agent TUI's input buffer (argv prefill or startup paste), so the chat
view showed no trace of it on desktop or mobile.

Desktop: draft launches now seed an in-memory launch draft keyed by tab id
(direct work-item launches, background GitHub work-item creates, quick-create
composer, and new-tab draft deliveries). The chat composer adopts the seed
once as its editable draft, declines permanently if the composer already has
text, and drops an untouched copy when any user turn lands (the one-line TUI
input means the prefill was submitted or deliberately cleared) or on its own
send, whose existing input pre-clear retires the TUI copy.

Mobile: the host publishes the draft as an optional launchDraft field on the
mobile terminal tab snapshot (additive, no protocol bump) and the mobile
composer adopts it with the same once-only/decline/resolve semantics. Mobile
chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so
a pending prefill cannot concatenate with the sent message.

Completion seeding resolves the launch tab from the synced store tabs when
the backend spawned the terminal and activation reports no primaryTabId.

Split the Windows shell-quoting tests into their own file to stay within the
max-lines budget.

* revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch

The libc binding fields and the @typescript-eslint peer re-resolution came from
a local install, not from this change; mobile/package.json is untouched.

* fix(native-chat): resolve launch drafts without trusting cross-host clocks

The rule required a user turn stamped at or after the seed. Grok omits row
timestamps, so a Grok launch draft never resolved; and the seed time is a
renderer clock while the stamp comes from the executing host's JSONL, so a
remote workspace whose clock trailed never resolved either. Both left the
composer adopting an already-submitted prefill, which re-sends it as a
duplicate turn.

Resolve on any user turn that is not PROVABLY older than the seed (a launch
draft's session starts with zero user turns), with the existing cross-host
skew slack, plus a timestamp-free backstop for wider skew: a new tail user
turn since the draft was first observed. "Load earlier" prepends, so it
cannot move the tail and cannot over-resolve.

Split out of native-chat-pending.ts to stay under the max-lines ratchet.

* fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0]

Two defects in the completion seed:

- The tab was resolved by array position. buildStartupOpt returns undefined on
  the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no
  tab and the launchAgent guard was dead there. A repo with default terminal
  tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent,
  and then published it to mobile as THAT tab's launchDraft. Correlate on the
  backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId
  (which is the agent tab whenever the renderer owns startup); never tabs[0].

- Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree
  was empty at seed time and the seed was silently dropped for that whole host
  class. Defer to the first mirrored tab via the existing delayed-delivery
  queue, which now holds every pending delivery for a worktree instead of one
  (setup/issue commands and the seed both wait on the same first tab).

* fix(store): evict nativeChatLaunchDraftByTabId on every teardown path

The new map was absent from all four paths its sibling
nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal
sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded
entry is worse than a plain leak here because sync-runtime-graph keeps
publishing it to mobile as that tab's launchDraft.

* fix(native-chat): only seed single-line unsubmitted launch drafts

The unsubmitted-delivery branch seeded on every draft delivery, which also
caught the agent-session-fork path whose prompt is multi-line scraped context.
The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
multi-line prefill cannot be fully cleared and its earlier lines would glue
onto the next message. The GitHub work-item draft this feature targets is a
bare issue URL, so narrowing costs it nothing.

Also assert the composer retires the seed after a send — deleting that call
previously failed no test.

* fix(mobile): stop the chat pre-clear from wiping a just-pasted image

The text write set clearInputFirst unconditionally. On the image path that
Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image,
so the agent receives the text alone while acceptSend still renders the
thumbnail on the sent bubble — silent image loss.

Desktop's image path clears exactly once, before the paste, and never again;
mobile now matches: pre-clear only when nothing was deliberately pasted first.
The image paste already leads with its own Ctrl+U, so a launch-draft prefill
parked on the input line still cannot glue onto the message.

Pinned at both levels: the controller test drives the real send hook and
asserts clearInputFirst per branch, and the send module asserts the wire text
carries no leading \x15. The image-attachments test injects its own baseSend,
so it structurally could not observe this.

* fix(mobile): hold the launch-draft prefill until the transcript settles

session.tabs delivers launchDraft before the transcript read resolves, so the
seed effect could run against an empty in-flight message list and miss the
user-turn decline. Launching from an issue, submitting the prefill in the TUI,
and never opening desktop chat (nothing else clears the host seed) then
prefilled the mobile composer with the already-sent issue link — a send tapped
before it retracted duplicated it to the agent.

Thread the session's loading state through and skip the seed while the read is
in flight. idle/waiting-session still seed: no session means no user turns.

* fix(runtime): publish a launch draft to mobile only for the tab's own agent

The publish had no agent check while the desktop consumer declines on
mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so
mobile could adopt a draft desktop refuses — seed for claude, never open
desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with
the Claude-era issue link. Align publish with the consumer.

* fix(native-chat): take the launch-draft baseline only after the transcript loads

The timestamp-free backstop snapshotted the transcript's user turns on first
observation of the draft, which can happen while the read is still in flight and
`messages` is []. A pane bound to a session that already had user turns then
backfilled above that zero baseline with a different tail id, so clause 2
resolved and silently dropped the seed — the launch context never appeared, and
the feature no-oped for exactly the panes it was meant to serve. Clause 1 was
already correct there (that history is provably older than the seed).

Gate baseline capture and resolution on the transcript read settling, the same
shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged
list is empty anyway, and a pane with live appends is never reported 'loading'.

Also restore clause 1's short-circuit: it scans with .some() again and only
allocates the user-turn list when falling through to the backstop.

NativeChatView sat at exactly the 400-line cap, so the composer's two
launch-draft props are now spread from the hook result they already mirror.

* fix(native-chat): reject multi-line launch drafts inside the seed helper

The single-line guard lived in deliverLaunchPromptToAgentTab, so the two
other seeding entry points (worktree create, direct work-item launch)
bypassed it — and every Linear launch is multi-line by construction
("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI
with Ctrl+U, which kills to start of LINE, so those earlier lines stay
parked to glue onto the next message.

* fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs

The runtime-owned deferred path fell back to tabs[0], which the module's
own docstring forbids: with repo default tabs ("dev server", "logs") the
seed lands on a tab running no agent, where mobile withholds it and
desktop's agent check ignores it — the feature is silently dead for that
create and the entry leaks until tab close.

The queue entry is consumed before delivery, so there is no retry to fall
back on; accept the first mirrored tab only when it is the worktree's
only one and so unambiguously the agent's.

* fix(mobile): treat a launch-draft-only session-tab frame as a change

mobileSessionTabEqual's terminal branch never compared launchDraft, and
the route keeps `prev` when tabs compare equal — so a publish whose only
delta is the draft appearing or retracting was discarded and never
reached the composer. Live QA passed only because agentStatus happened to
change in the same frame.

MobileSessionTab's terminal variant did not declare the field either
(the controller read it through the structurally wider
MobileNativeChatTab), which is why TypeScript never flagged it.

* fix(mobile): judge a launch prefill only from its own settled transcript

Two ways the drafts hook was reading a transcript that was not the active
chat's:

- transcriptLoading came from `status`, a plain useState written by a
  passive effect declared before the drafts hook. On the commit where the
  tab identity changes it still holds the previous tab's value, so the
  guard was off on exactly the render that seeds: first entry saw
  status 'idle' with an empty list and seeded an already-submitted link,
  and a tab switch declined the new tab's prefill from the old tab's
  turns. The session hook now tracks the identity its messages describe
  and reports transcriptLoading until they agree; the retire effect gates
  on it too.
- Leaving chat view nulled launchDraft while draftKey stayed the same,
  which the hook could not tell from a host retraction — it declined the
  prefill permanently, so peeking at the terminal dropped the context.
  The controller now passes the raw field plus an explicit chatActive
  flag, and both effects hold their state when the tab is not on chat.

The controller wiring was previously unasserted: replacing both props
with constants left all 795 mobile session tests green.

* fix(native-chat): keep the launch-draft baseline across a transcript reload

baselineKey went null whenever the transcript was loading, and the null
branch DISCARDED an already-valid baseline taken from a settled read. It
was then re-taken from the fuller list, swallowing the very user turn
that resolves the draft — so a stale prefill gets re-adopted as a
duplicate turn. Key the baseline on draft identity alone and gate only
the capture.

session.status is also not a truthful read-in-flight signal: a live
'working' hook outranks 'loading', so the guard could be off over an
in-flight empty list. Expose the read phase itself and gate on that.

* test: cover the launch-draft reducers and the sync-key skip gate

Every consumer test injects the three launch-draft reducers as bare
vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left
2609 tests green — while in the app the composer would resurrect the
prefill after every manual clear.

canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either:
when it skips, the sync key is never even built, so the existing
getRuntimeMobileSessionSyncKey case cannot catch its removal.

* fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref

react-compiler rejects reading or writing a ref during render. Adjust the held
baseline with the sanctioned render-time setState instead, keeping the local
copy so the render that first sees a settled transcript resolves against it.

* fix(mobile): carry the transcript identity in the session read state

react-doctor flags the separate loadedIdentity state as an extra render for a
derivable value. Hold status alongside the identity it describes in one state
written by the subscription effect, so transcriptLoading derives from it.

* test(native-chat): assert the readPhase contract without the hook-status race

The test asserted status === 'working', which depends on liveStatusOverride
winning over ambient transcript state — green locally, red under CI load. The
contract is that readPhase stays 'loading' once live content unmasks status,
so assert exactly that; it still fails if readPhase derives from status.

* fix(mobile): derive pre-read chat status instead of writing it from the effect

react-doctor's no-derived-state-effect flags idle/waiting-session/loading being
set in the subscription effect: all three are pure functions of the props. Derive
them during render and keep state only for the genuinely async outcome, tagged
with the identity it describes.

The tag now gates `messages` too, so a just-switched tab never sees the previous
tab's transcript at all rather than seeing it behind a loading flag.

* fix(mobile): drop a settled chat read once its subscription is torn down

The settled outcome was only ever replaced by a newly arriving frame, so any
effect re-run that landed back on an already-settled identity resurfaced it over
a list the same effect had just cleared: 'ready' with no messages and
transcriptLoading false. Toggling out of chat view and back hit this every time
(the agent goes null, then returns), flashing the "start a chat" empty state over
a real conversation and opening the launch-draft seed's decline check on an empty
transcript. A reconnect did the same via the client dep.

Identity and client are the effect's only inputs, so tagging the read with both
and dropping it during render when either moves covers every re-run.
2026-07-28 13:15:31 -07:00
JinjingandOrcaWin a40183389b feat: bound direct SSH reconnect fan-out and recovery (#11003)
* docs: design for direct SSH reconnect fan-out

Capture the implementation-ready plan for host-qualified, epoch-fenced
SSH reconnect recovery after two rounds of multi-model LLM counsel review.

* docs: reconcile SSH reconnect fan-out design

* docs: close reconnect design consistency gaps

* feat: implement bounded direct SSH reconnect recovery

* fix: bound direct SSH retry settlement

* fix: harden direct SSH reconnect authority

* fix: preserve split SSH retry ownership

* fix: preserve SSH split continuation authority

* docs: record final SSH reconnect validation

* fix: preserve SSH authority through retained and detached state

* fix: retain SSH authority across delayed split mounts

* fix: close SSH authority recovery gaps

* fix: fence stale SSH transport replacement

* fix: serialize SSH target teardown

* fix: settle SSH teardown failures before reconnect

* fix: retire failed SSH reset sessions

* test: reconcile current main E2E contracts

* fix: close direct SSH reconnect review gaps

* fix: fence stale SSH reconnect side effects

* fix: close final SSH reconnect lifecycle gaps

* test: stabilize current-main reliability gates

* test: prove plugin navigation containment

* test: make plugin navigation oracle authoritative

* test: make plugin navigation oracle deterministic

* ci: allow sharded e2e suite to finish

* test: wait for runtime pane publication

* test: classify pane readiness by error code

* test: select close persistence terminal by tab identity

* docs: mark reconnect implementation validated

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 12:33:17 -07:00