Commit Graph
692 Commits
Author SHA1 Message Date
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
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
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
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
NeilandOrca 5f642841fd fix(worktrees): stop terminals after external deletion (#11237)
* fix(worktrees): stop terminals after external deletion

* fix(worktrees): request teardown per caller and revalidate uncached

Two defects let the original fix silently strand PTYs:

- teardown rode the scan's coalescing promise, so any caller that joined an
  in-flight scan purged its renderer state without ever asking for a sweep;
  it now runs per caller against its own known-id snapshot, deduped on the
  request it actually produces so fan-out still shares one host sweep.
- the runtime's authoritative recheck was served from the 30s worktree-scan
  cache, which can still list a directory git already dropped. The renderer
  purges either way, so a stale miss leaked those processes permanently.

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

* perf(worktrees): enumerate the host once per teardown sweep

An agent cleaning up N workspaces made killAllProcessesForWorktree issue one
full provider enumeration per missing worktree: O(N) relay round-trips carrying
O(N^2) rows. At 30 worktrees over an 80ms-RTT SSH link that is 30 scans and
~1.3s of stalled teardown; it scales linearly from there.

Share one point-in-time process list across the sweep — every worktree in it is
already known-missing, so a single snapshot answers all of them. A failed scan
is never shared: it falls back to a per-caller scan so one transient relay error
cannot suppress the sweep for the whole batch. Pinned requirePhysicalStop:false
since that path re-lists after shutdown and must not read a pre-shutdown snapshot.

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

* test(worktrees): pin the disconnected-SSH no-teardown invariant

main's new directSshAuthority gate bails before any refresh when an SSH target
is not connected. That is exactly the #10562 safety rule — "host unreachable"
must never be read as "worktree deleted" — so pin it: a disconnected target
issues no teardown RPC and keeps its renderer state.

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

* fix(worktrees): keep selector grammar intact when scoping by connection

resolveRepoSelectorForConnection matched the selector as a bare repo id, so an
explicit connection identity silently changed the grammar: `path:` and `name:`
selectors resolved to repo_not_found on that path alone, losing the whole sweep.
A connection identity should only *narrow* the candidate set.

Extract the selector matching both paths now share, and stop re-resolving an
already-resolved repo: teardown rescanned via `id:<repo.id>`, which throws
selector_ambiguous when an id is duplicated across hosts even though the
caller's own selector was unambiguous.

Reported as a P2 by Greptile (as redundant work); it is load-bearing.

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

* fix(worktrees): keep the shared snapshot out of provider internals

The snapshot proxy passed itself as the Reflect.get receiver, so prototype
methods invoked through it ran with `this` bound to the proxy. A provider whose
own shutdown() re-read state via `this.listProcesses()` would then silently get
this sweep's cached snapshot instead of the live host — batching leaking past
the calls it was built for.

Bind non-listProcesses members to the target so only the sweep's own calls share
the snapshot. No shipped provider does this today; the point is that adding one
must not quietly change teardown semantics.

Raised by Greptile as an undocumented implicit constraint; closed structurally
rather than by comment.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-30 02:05:13 -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
NeilandOrca 191fdf2ae6 fix(runtime): skip unreadable Windows drives (#11421)
Co-authored-by: Orca <help@stably.ai>
2026-07-30 00:23:24 -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
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
jmdallandOrcaWin 80c42d38c7 fix(runtime): avoid immediate WebSocket heartbeat sweep (#11300)
* fix(runtime): avoid immediate WebSocket heartbeat sweep

Defer the first heartbeat sweep until the interval tick.

The immediate sweep can close a newly accepted WebSocket before the E2EE handshake completes on Linux ARM64.

* test(runtime): update heartbeat expectations for deferred sweep

* docs(runtime): update heartbeat initialization comment

Clarified comment regarding socket pinging during heartbeat.

* fix(runtime): arm heartbeat after socket listeners

* test(runtime): pin shared heartbeat cadence

* chore(runtime): preserve reliability gate formatting

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-29 19:31:29 -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
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 cbe8635f46 fix(worktrees): prevent deletion from blocking Orca (#11233)
* fix(worktrees): prevent deletion from blocking Orca

* test(worktrees): loosen async history-delete event-loop bound for CI

The main-thread safety check failed on a loaded runner when a single
timer gap hit ~48ms under the prior 30ms threshold. Keep the bound well
below a recursive sync-rm stall without treating CI jitter as a block.

* test(worktrees): measure history-delete critical path, not timer gaps

setInterval gaps during async rm of thousands of files still flake under
CI scheduling. deleteWorktreeHistoryDir is sync and must only rename, so
assert that critical-path wall time stays well below a recursive walk.

* fix(worktrees): prevent deletion from blocking Orca

Add timeout-based draining of watcher closes so SSH round-trip delays don't
indefinitely block the worktree removal path. Also: order durable temp-file
sweeps ahead of writes to reclaim orphans before accumulation, skip own-process
temps to avoid deleting live writes, swallow persistence errors so disk failures
don't cascade to query callers, and measure history-deletion progress by loop
turns rather than timer gaps to detect blocking on CI runners.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion can now proceed even if filesystem watchers or history cleanup operations hang, preventing Orca from freezing. Changes:

- Fence install slots with tokens instead of counters so removals can abandon wedged installs without corrupting later removals
- Timeout-bound watcher unsubscribe operations with a shared drain budget
- Move JSON serialization of large usage caches from queue-time to write-time to avoid blocking main thread
- Async tombstone + schedule history tree deletion instead of blocking recursive rmSync during GC, preventing main-thread stalls ~10s after startup

* Extract usage cache writer into reusable durable snapshot class

Consolidates serialized durable-write and generation-veto logic from
three usage stores into UsageCacheSnapshotWriter. Eliminates duplication,
centralizes multi-MB JSON serialization on the main thread via write-queue
serialization, and vetoes superseded snapshots to avoid wasted rewrites.

* fix(worktrees): prevent deletion from blocking Orca

Worktree deletion used to recursively delete large session trees (hundreds
of MB) on the critical path, stalling the event loop. Instead, rename trees
into a `.pending-delete` tombstone queue and reclaim them asynchronously
off the removal's critical path.

Extracted host tree removal into a reusable helper (`removeHostTree`) that
centralizes Windows retry logic. Added usage-cache flush on quit to prevent
data loss when scans complete right before shutdown. Improved watcher
removal deadline management with reserved tail slices for the final
unsubscribe, and added retry logic for tombstone removals that fail once
under transient Windows locking.

* fix(history): retry failed session tree removals

Tombstoned session trees whose removal fails transiently (e.g., EBUSY
under Windows AV) are now re-queued in-process with bounded exponential
backoff instead of sitting until the next HistoryManager construction.
Prevents a single stuck tree from blocking the entire Orca process.
2026-07-29 18:21:26 -07:00
Neil 78b8a37aed fix(cli): keep automated worktree creation in background (#11445) 2026-07-29 17:45:57 -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
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 fa449bc0ef fix(worktree-palette): stop blanked display names from crashing Cmd+J (#11323)
* fix(worktree-palette): stop blanked display names from crashing Cmd+J

Blanking the "Display Name" field made buildWorktreeMetaUpdates emit
`displayName: undefined` as a present key. The store's `{ ...worktree,
...updates }` spread then erased the live name, so the next palette
keystroke threw "Cannot read properties of undefined (reading
'toLowerCase')" in searchWorktrees (crash a1f81ea1, build 1.4.159).

Fixed at three layers so no single guard is load-bearing:
- Producer: persist the blanking intent as '' instead of undefined, and
  let WorktreeSet accept '' so remote/SSH hosts stop dropping the clear.
- Store: applyWorktreeUpdates and applyDetectedWorktreeUpdates drop
  present-but-undefined keys for fields Worktree declares required.
- Readers: resolveWorktreeDisplayName/resolveWorktreeBranchLabel mirror
  the main-side mergeWorktree fallback (custom -> branch -> folder) for
  all four Cmd+J searches, the checks/review index, and the render site.

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

* test(worktree): assert omitted display name shape

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-29 00:56:26 -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
JinHyeok JeongandOrcaWin 6107789c97 Fix WebSocket fallback for reserved Windows ports (#7185)
* Fix WebSocket fallback for reserved ports

* fix(runtime): narrow reserved-port fallback

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 20:28:39 -07:00
Jinwoo HongandOrcaWin 0660ad9d6e fix(orchestration): reject legacy mail acknowledgment (#11227)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 18:27:52 -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
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 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
Jinjing 1bd80931bd fix(diff): stop file-tree navigation remounting combined diffs; make tree resizable (#11088) 2026-07-28 10:53:59 -07:00
OrcaWinandOrcaWin dca0db38c4 fix(orchestration): repair version-skewed run schemas (#11150)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 02:21:27 -07:00
Jinwoo HongandOrcaWin 0d6f9195d8 fix(orchestration): reveal worker terminals reliably (#11142)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 01:59:49 -07:00
OrcaWinandOrcaWin 77d4c64f7a Improve orchestration migration safety for live legacy workers (#11107)
* fix(orchestration): clarify legacy migration safety

* fix(cli): sanitize legacy formatted messages

* test(runtime): allow near-cap fuzz under shard load

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-28 00:36:25 -07:00
Neil c25d85cc4c perf(terminal): eliminate adverse control and frame-gate cases (#11045)
* perf(terminal): eliminate dense control and frame gate regressions

* test(terminal): keep gate labels in valid expect shape

* test(terminal): expose the surviving sub-threshold control-density case

The only adverse strip fixture sat at 50% control density, which is exactly
where the fallback fires and wins. A shape at 31 controls per 64-unit block
evades the trigger and still loses to the per-character legacy (0.67x), so the
benchmark structurally could not show it.

Add that fixture, pin both density literals in the staleness guard so a retune
fails loudly instead of silently measuring a boundary that moved, and export
the probe constant the equivalence test was hardcoding.
2026-07-27 23:28:05 -07:00
Neil badf91101b fix(quality): enforce performance-safe lint baseline (#11074)
* fix(quality): clear safe existing lint findings

* fix(quality): keep lint cleanup allocation-free

* fix(quality): enforce performance-safe baseline

* test(terminal): drain deferred confirmation cleanup
2026-07-27 20:54:02 -07:00
Wooseong KimandBrennan Benson f10b6de2c7 fix(relay): tolerate wall-clock skew in host-proof validation (#10474)
* fix(relay): tolerate wall-clock skew in host-proof validation

A few seconds of local clock lag made challenge issuedAt appear in the
future, so host-proof rejected every handshake and Mobile Relay looped
connecting forever. Allow ±30s skew while keeping the 10s challenge window (#10401).

* fix(relay): preserve host-proof challenge bounds

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
2026-07-27 19:49:18 -07:00
Brennan Benson dc45f79465 fix(runtime): drain the session-tabs coalescer when a listener subscribes (#11022)
multi-client-navigation-isolation.integration.test.ts fails intermittently on
clean main — measured at 4-8/24 with a flush-window sweep — and has been taxing
unrelated PRs across the repo.

Mechanism, confirmed by instrumented trace (schedule t=23412, listener REGISTER
23460/23461, coalescer fire 23462): a ~4ms race between the 50ms session-tabs
notify coalescer and listener registration in onMobileSessionTabsChanged. When
the pending timer fires after the listener registers but before
session.tabs.activate is handled, it emits a stale
{type:'updated', activeTabId:'host-tab'} that the test consumes.

flushAll() at the top of onMobileSessionTabsChanged, before the listener joins the
set — mirroring the flushAll() its own unsubscribe closure already performs.

DRAINING rather than cancelling is load-bearing: cancel/dispose has no emit, so it
would silence the stale frame but DELETE the pending update for subscribers
already registered, leaving them stale until an unrelated next schedule(). That
would trade a flaky test for a real lost-update bug. The newcomer cannot miss an
update by having it drained — listMobileSessionTabs and the coalesced emit read
the same mobileSessionTabsByWorktree map, and snapshot-read to listener-add is
macrotask-atomic.

Sweep: 4-5/24 before, 0/24 after. The regression test uses fake timers and is
two-sided — it fails if flushAll is removed (stale frame delivered) and fails if
flush is swapped for dispose (nothing drained), on different assertions, so it
pins the specific choice rather than merely the presence of a change.

Reviewed independently and returned clean with both mutations re-run by the
reviewer. A latent flushAll re-entrancy double-emit exists in principle — fire()
does not re-check pending.has the way flush() does — but is unreachable: both
production callers' listeners only invoke the dispatcher reply, a shape that has
shipped since #8141 on the symmetric unsubscribe flush.

Not verified: live paired-device mobile/relay behaviour (no device available); the
mobile conclusion rests on a code trace.
2026-07-27 18:07:57 -07:00
Brennan Benson c75c04eaae fix(runtime): reclaim orca-runtime.json when it stops describing this runtime (#10840)
* fix(runtime): reclaim orca-runtime.json when it stops describing this runtime

On macOS the Chromium single-instance lock is silently defeated whenever
`SingletonSocket`/`SingletonCookie` go missing from the profile — and the
socket they point at lives under `$TMPDIR` (`/var/folders/.../T`), which
macOS purges after 3 days (`com.apple.bsd.dirhelper`,
CLEAN_FILES_OLDER_THAN_DAYS=3). A launch that slips past the lock runs a
full startup, republishes `orca-runtime.json` with its own pid, and leaves
the CLI on a dead pid once it exits: `orca status` reports
`stale_bootstrap` and every terminal command fails `runtime_unavailable`
while the original app keeps serving.

The owner now watches its own discovery record and republishes once no live
runtime is described. Reclaiming only a dead pid is deliberate: two live
runtimes sharing a profile would otherwise fight over the file.

Reproduced on macOS with two real Orca main processes on one profile: the
second instance took the lock and clobbered the record, and killing it left
`stale_bootstrap` against the still-healthy first instance. With this
change the owner reclaimed the record in ~2s and the CLI returned to
`ready`.

Refs #7848

* test(runtime): assert stop() clears the metadata ownership timer

The republish guard alone kept the shutdown test green, so the watch teardown was unasserted. Also drop the doc claim of startup/activation callers that do not exist.

* test(runtime): stand in a real live pid for the sibling-runtime case

Windows never assigns pid 1, so the hardcoded sibling read as dead there and
the watch would reclaim the record. Own a synthetic pid instead and let
process.pid play the live sibling.
2026-07-27 17:32:55 -07:00
NeilandOrca d547e278f9 fix(mobile): deliver the notifications a reconnect missed, and never persist a watermark past them (#10816)
* fix(mobile): keep the reconnect watermark alive across the app's own teardown

The catch-up added in #8690 could never run. app/index.tsx unsubscribes the
notification stream on every non-'connected' state and builds a fresh
subscription on reconnect, so the closure holding the ready-counter, the
delivered watermark and the seen-set is destroyed exactly when a reconnect
needs them. Every reconnect looked like a cold open, `reconnectReadyCount`
was always 1, and notifications dispatched while the socket was down were
never fetched.

Move that state to a per-host module-scope session so it survives the
teardown.

Refs #8591

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

* fix(mobile): tag the notification watermark with a counter epoch so a desktop restart can't kill catch-up

The desktop's notification `seq` is a per-process in-memory counter that starts
at 0 on every launch. The mobile client's watermark is persisted in AsyncStorage
and monotonic. After a desktop restart the two index different counters, so a
client holding seq 57 meets a fresh counter at 2, `57 >= 2` cuts everything, and
reconnect catch-up dies silently until the new process out-dispatches the old
watermark — 57 notifications later. Users see nothing and get no error (#8591).

Stamp every dispatched notification with an epoch identifying the counter
lifetime, ride it on the `ready` frame and the getMissedSince response, and
persist it beside the watermark. A watermark whose epoch doesn't match the live
counter is void: the client resets to 0 and the desktop returns its retained
buffer instead of nothing.

The epoch param is optional on the wire in both directions, so a client or
daemon that predates it degrades to today's seq-only cut rather than erroring.

Also extracts the OS-permission helpers to notification-permissions.ts (re-
exported, so no importer changes) to keep mobile-notifications.ts under its
max-lines budget.

Mutation-tested: 3 mutations applied to the epoch logic, 3 killed — including
the storage-seed race guard, whose first mutant survived until the deferred-read
test was added.

* fix(mobile): make the notification watermark atomic and counter-scoped

Round-1 review found four ways the epoch fix could still lose notifications.
All four are addressed here.

1. Seen-set survived an epoch change. Seen-keys are seq-derived, and terminal
   bells carry no notificationId (they key on `seq:N` alone). After a restart
   the fresh counter re-issues low seqs, so a replayed post-restart bell was
   dropped as a duplicate of a bell from the previous counter. The dedup window
   belongs to one counter lifetime, so it is cleared on epoch change.

2. Legacy watermarks were trusted. Pre-upgrade installs stored a bare seq with
   no epoch. Adopting the first observed epoch as "nothing changed" left that
   unprovenanced seq cutting a counter it was never measured against — #8591
   through the upgrade path. An epoch-less seq no longer survives adoption.

3. seq and epoch were separate storage keys. A process death between the two
   writes left epoch-B beside seq-57-from-A: a pair that looks internally valid
   on the next launch and is therefore trusted. They are now one JSON value,
   which cannot tear, with a read-only migration from the legacy key.

4. Sessions were never retired. They live at module scope so they survive the
   subscription teardown a reconnect performs, so host removal is the only
   thing that can drop them. Removal now retires the session and its watermark.

Mutation-tested: 3 mutations, 3 killed. The first version of the bell test
passed with the fix removed — it exercised the live path, which only adds to
the seen-set; only the replay path consults it. Rewritten against the replay
path, it fails with `expected 1 to be 2`: the literal lost notification.

Mobile notifications + transport: 355 passed. Desktop replay: 11/11.

* fix(mobile): catch up on the first connection after a cold open

Catch-up hung off 'has this process connected before', which is false on the
first ready of a fresh launch — exactly the post-upgrade / post-eviction case
that loses everything between the stored watermark and the next live seq. Wait
for the persisted read, then catch up whenever this device has delivered for
the host before; a first-ever pairing still gets no replay.

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

* fix(mobile): serialize live delivery behind the watermark seed, and key catch-up on the record

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

* test(mobile): pin the two catch-up mechanisms mutation testing found unguarded

Mutating each mechanism of the #8591 fix in turn showed two survived with the
suite still green: the seed's epoch-provenance check, and the host session
outliving the subscription teardown. Both are load-bearing, so pin them.

- seen-set survives teardown: the desktop's retained buffer replays a
  notification already delivered live, and only the session-scoped seen-set
  stops a duplicate banner.
- a seed resolving after a live epoch was adopted must not reinstate the dead
  watermark. Not reachable through subscribeToDesktopNotifications today
  ('ready' awaits the seed first), so it asserts on the exported pair and says
  so.

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

* fix(mobile): serialize notification delivery per host so the watermark can't outrun what was shown

Addresses two MAJOR findings from review of this branch.

MAJOR #1 — the watermark could be persisted past a notification the user never
saw. `deliverLive` advanced `lastDeliveredSeq` before awaiting the local show,
and replay + live delivery ran concurrently, so a live seq 11 handled while
catch-up was still showing seq 6 persisted 11. A process death before 7..10 were
shown lost them permanently: the next launch asks the desktop for seq > 11.

This predates the branch — `origin/main` advances the watermark at the same point
— so it is a residual this fix closes, not a regression the branch introduced. It
is fixed here because the branch is what makes the watermark load-bearing.

Three changes:
  - the advance moves AFTER the show/dismiss await, so the watermark means
    "everything up to here reached the user" rather than "was dispatched"
  - a per-host `deliveryTail` promise chain (`enqueueHostDelivery`) serializes
    deliveries, so a monotonic advance is also an in-order one
  - the catch-up batch is ONE queue entry, not one per event. Awaiting per event
    returns to the event loop between replays and let a live event slot in
    between seq 6 and 7 — which is exactly the interleave being fixed. The RPC
    stays outside the queue: `sendRequest` waits up to 30s and holding the chain
    for that would stall live delivery on a slow link.

MAJOR #2 — every delivery awaits the persisted read, so an AsyncStorage read that
never settled disabled the host's notifications for the whole app lifetime, with
no error and nothing to see. The seed is now bounded at 3s; a late seed still
applies when it lands. Proceeding unseeded is strictly better: the watermark
stays 0, so catch-up over-fetches and the seen-set de-duplicates.

Serializing removed an overlap the duplicate-suppression relied on:
`showLocalNotification` deduped two same-id events by observing the first still
pending when the second arrived. With deliveries serialized the first completes
first, so the second saw no pending state and scheduled a second banner for the
same notification. The claim moves to enqueue time, where the overlap is still
observable. Dismisses are deliberately not claimed — a dismiss for a shown id is
what retires it.

Evidence — each mechanism disabled individually against the unchanged suite:
  - batch-as-one-entry -> reverted to per-item enqueue: ordering test fails
  - watermark advance -> moved back before the await: ordering test fails
  - seed timeout -> removed: wedged-read test fails
  - live-path claim -> removed: concurrent-dedup test fails
  - replay-path claim -> removed: cross-path dedup test fails
Each kills exactly one test, so no mechanism is unguarded and none is redundant.

`mobile-notifications.test.ts`'s local `flushAsync` drained 10 microtask ticks.
Deliveries are now several awaits deeper, so a fixed tick count under-drains; it
yields to the macrotask queue instead. Verified with real timers that the
behavior it asserts is unchanged — only the drain depth was wrong.

Full mobile suite: 344 files, 2499 passed, 2 skipped. tsc clean, oxlint clean.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:18:05 -07:00
NeilandOrca 2dac0741b4 fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew (#10817)
* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs #9993

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

* fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse

The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.

* fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact

The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — #9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).

* fix(daemon): refuse 2031 authority from a daemon that cannot retract it

Round-2 review found a wire-compatibility hole in the original #9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- #9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.

* fix(daemon): gate background delegation, not just the fact stream

A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

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

* fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background

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

* fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.

* fix(terminal): preserve mode 2031 reply decisions

* fix(build): record daemon protocol v29 compatibility

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:47 -07:00
NeilandOrca 077561f89a perf(terminal): measure stream byte length natively above a code-unit floor (#10916)
* perf(terminal): measure stream byte length natively above a code-unit floor

The terminal RPC path counted UTF-8 bytes with a hand-rolled per-code-point scan
that Buffer.byteLength does natively an order of magnitude faster.

Routed through a small module rather than swapping the shared clipboard helper,
which has ~50 renderer call sites and a partial-count contract on the over-limit
path that must not change.

4.4x on an 8KiB batcher push, 4.2x on a 2MiB snapshot scan, 4.0x on the 48KiB
chunk gate, and 1.26-1.33x on the adversarial early-trip shapes where the legacy
scan bails after a third of the string.

The floor is load-bearing, not defensive. Buffer.byteLength has a fixed ~14ns
call cost against a scan iteration of ~1.5ns, so below the measured 8-12 code
unit crossover the native call is a REGRESSION -- 4.2x slower at one code unit,
which is keystroke echo, the most latency-sensitive PTY shape there is. Short
inputs keep the scan verbatim; 16 leaves margin over the crossover so the worst
sub-floor shape stays at parity.

measureTerminalStreamByteLength takes the native count only when
`length * 3 <= stopAfterBytes` proves the limit cannot trip, so the callers'
truncated running total is never replaced by a full count.

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

* test(terminal): benchmark production byte-length exports

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:15:54 -07:00
NeilandOrca 6943638053 perf(terminal): scan output frames by code unit, not per code point (#10915)
* perf(terminal): scan output frames by code unit, not per code point

iterateTerminalOutputFrameChunks walked `for (const part of data)`, materializing
a 1-2 character string per code point and calling terminalStreamByteLength on
each, while accumulating the frame text with `chunk += part`.

The accumulator was never needed: `chunk` only ever reconstructs the contiguous
substring data[chunkStart..end), and `startSeq + chunkStartOffset + chunk.length`
collapses algebraically to `startSeq + end`. Track two integer offsets and emit
data.slice(chunkStart, end) instead, computing UTF-8 width inline from charCodeAt.

Also short-circuits the cap gate on UTF-16 length before measuring UTF-8 bytes,
which is sound because UTF-8 length is never below UTF-16 length.

2.4-6.1x across payload shapes, stable across reruns. This runs per terminal
output batch and per snapshot chunk.

Extracted to its own module along a real seam (the chunk-emission concern plus
its two types and cap gate); methods/terminal.ts shrinks by 85 lines. No
max-lines suppression added and the baseline is untouched.

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

* fix(terminal): preserve chunk sequence rounding

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:10:45 -07:00
NeilandOrca 79ec57d045 perf(relay): keep the PTY replay window as chunks, not a re-sliced string (#10900)
* perf(relay): keep the PTY replay window as chunks, not a re-sliced string

appendReplayBuffer did `buffered += data` then `buffered.slice(-REPLAY_BUFFER_MAX)`
once over the 100KB cap. It runs per raw node-pty emission -- before batching --
so once a PTY saturates the window (which a long-lived shell does almost
immediately) every subsequent chunk copied the whole 100KB.

Reuse RecentPtyOutputBuffer, which already solved this shape in the main process:
keep chunks, drop from the head, defer the join to read(). The relay's three
readers are attach, adopt, and revive only.

66-205x on the append path, per PTY, on the user's SSH host.

RecentPtyOutputBuffer's limit is now configurable, because the relay retains
100KB where the main process retains 64KB. One arithmetic branch still used the
hardcoded constant after that change and silently under-retained (100,800 of
102,400 code units); the equivalence tests caught it before it shipped, and the
suite now pins the configured limit directly.

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

* test(relay): exercise a real surrogate split; drop eval from the benchmark

Review feedback, both valid:

- The surrogate test never split a pair. The cap is even and a pair is two code
  units, so an emoji run alone always cuts on a pair boundary. A trailing single
  unit shifts the cut mid-pair, leaving a dangling low surrogate (0xDE00) --
  asserted directly now, with the boundary-aligned case kept as its own test.
- Parse REPLAY_BUFFER_MAX as a product instead of eval(). The regex already
  admits only digits, spaces and `*`, and eval tripped Biome's noGlobalEval
  regardless of the eslint suppression.

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

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:40:59 -07:00
giraphant cef52c68be fix(runtime): stop rejecting ui.set when Linear or Jira resume fields are present (#10715)
Reviewed with an independent reproduction. Extended the fix to the UiUpdate parity gaps one level up, and added a typecheck-level assertion so future field drift fails the build instead of silently rejecting paired-client payloads.
2026-07-27 16:26:51 -07:00
Brennan Benson 3baffb49ff fix(runtime): refuse SSH hosts in project setup instead of acting locally (#10799)
* fix(runtime): refuse SSH hosts in project setup instead of acting locally

projectHostSetup.clone and .setupExistingFolder threaded executionHostId all
the way down but never used it for routing: cloneRepo runs a local mkdir plus
a local gitSpawn, and addRepo probes the path with existsSync/statSync. An
`ssh:` host therefore cloned and validated on the *local* machine and then
registered the result as living on the SSH host.

It only failed loudly here because the remote path did not exist locally. With
a plausible destination the clone succeeds and writes a setup record pointing
at the wrong machine.

Nothing legitimate sends `ssh:` to these RPCs: the renderer maps every ssh
host (including ephemeral-VM `ssh:runtime-ssh-*`) to the desktop IPC path,
which dispatches to addRemoteRepoFromPath/cloneRemoteRepo, and the IPC handler
symmetrically rejects `runtime:`. Only the CLI can reach here with `ssh:`.

Fail closed until the RPC learns to route through the SSH providers.

* test(runtime): make the SSH guard test observe the corruption it names

The test asserted `gitSpawn` was never called and no repo was registered, but
neither assertion could fail. `/home/brennan` is unwritable on macOS, so the
pre-guard clone died at `mkdir` before reaching `gitSpawn`, and
`/home/brennan/orca` failed `isGitRepo` before reaching `addRepo` — the exact
side effects under test were unreachable either way. `rejects.toThrow` also
aborted the test before those lines ran.

Use a real temp destination and a real temp git repo, await both calls via
`.catch`, and assert the side effects before the wording. With the guard
disabled the test now fails on `gitSpawn` being called once with a real
`git clone`, and on a repo registered stamped `executionHostId: 'ssh:openclaw'`
— the silent local-clone-recorded-as-remote defect itself. `gitSpawn` is
stubbed so a regression records the call instead of hitting the network.

Also document the SSH restriction on `project setup-existing-folder`, which the
guard now rejects. `setup-clone` already carried that note; its sibling did not.
2026-07-27 16:21:54 -07:00
cdd5ceb72b fix(jira): render issue description/comment images with lightbox (#8938)
* fix(jira): render issue images and open them in a lightbox

Jira ADF media nodes were dropped when converting descriptions/comments
to Markdown, so screenshots never appeared in the Tasks drawer.

Download image attachments with authenticated Jira API access, embed them
as data URLs on issue/comment detail loads, and add a viewport-centered
lightbox. Closing with X/Esc only dismisses the preview, not the issue sheet.

* fix(jira): open comment images in the same lightbox as description

Jira issue comments still used compact markdown, so screenshots rendered
but could not expand. Use the document renderer for comment bodies, add a
regression test for the expand control, and sync MarkdownImageLightbox
locale keys.

* fix(jira): harden inline image handling

* fix(jira): harden inline image discovery, escaping, and downloads

Address PR review findings: correct media-attachment pairing, Server/DC
attachment lookup base path, markdown-safe external URLs, wider HTML
discovery with gated alt fallback, concurrent downloads outside the API
semaphore, and a main-process attachment data-URL cache with lower caps.

* fix(jira): Option A multi-same-name attachments and post-map media warns

Fix discovery so repeated alts (image.png) get distinct attachment ids,
flush resolution warns after ADF mapping using attachment-only stats,
clear attachment cache on clearToken with epoch-guarded singleflight,
and add Server comment path plus release-before-binary regression tests.

* fix(jira): simplify comment media request skip condition

Only needingCount determines whether to skip the attachment metadata
request — htmlIds alone cannot produce a download without needing
media. Add type annotation for mediaAttrs for clarity.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-07-27 15:34:20 -07:00
OrcaWin 24706ccff0 fix(terminals): negotiate explicit close intent for paired runtimes (#10129) 2026-07-27 15:22:55 -07:00
OrcaWin cd05f2ff93 Implement robust orchestration primitives and connected-server workers (#9925) 2026-07-27 12:31:37 -07:00
Jinwoo HongandOrcaWin c53a12e11d fix(relay): back off overloaded assignments (#10894)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-27 02:17:24 -07:00