diff --git a/.github/workflows/cloud-deploy-relay-production-director.yml b/.github/workflows/cloud-deploy-relay-production-director.yml
index 97abe2d227b..4489d67b845 100644
--- a/.github/workflows/cloud-deploy-relay-production-director.yml
+++ b/.github/workflows/cloud-deploy-relay-production-director.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
image-digest:
- description: "Immutable relay image digest (sha256: plus 64 lowercase hex characters)"
+ description: 'Immutable relay image digest (sha256: plus 64 lowercase hex characters)'
required: true
type: string
regional-placement-mode:
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 72bcb4b4d7f..899914339c7 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -13325,9 +13325,7 @@
},
{
"file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
- "assertions": [
- "a refused pointer write drains a delivery parked behind its watermark"
- ]
+ "assertions": ["a refused pointer write drains a delivery parked behind its watermark"]
},
{
"file": "src/main/providers/settled-pty-writer-census.test.ts",
@@ -18549,27 +18547,13 @@
"protection": "partial",
"owner": "browser-runtime",
"layer": "electron-packaged",
- "surfaces": [
- "paired browser placement"
- ],
- "platforms": [
- "linux",
- "macos",
- "windows"
- ],
- "providers": [
- "paired-runtime"
- ],
- "coveredPlatforms": [
- "linux"
- ],
- "coveredProviders": [
- "paired-runtime"
- ],
+ "surfaces": ["paired browser placement"],
+ "platforms": ["linux", "macos", "windows"],
+ "providers": ["paired-runtime"],
+ "coveredPlatforms": ["linux"],
+ "coveredProviders": ["paired-runtime"],
"coverageNotes": "Published Linux 1.4.188 desktop against current source in both directions; scheduled weekly and manually runnable. No required PR check.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/actions/runs/34069063016"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/actions/runs/34069063016"],
"invariant": "A paired client and host without client-hosted browser capabilities retain server-hosted browser placement across supported version skew.",
"oracle": "Require both existing named browser placement scenarios to pass three times with one attempt, zero skips, zero failures, and no report errors.",
"commands": [
@@ -18593,9 +18577,7 @@
},
{
"file": "config/scripts/verify-packaged-browser-participation.test.mjs",
- "assertions": [
- "reject missing, substituted, skipped and retried scenarios"
- ]
+ "assertions": ["reject missing, substituted, skipped and retried scenarios"]
},
{
"file": "config/scripts/packaged-browser-lane-contract.test.mjs",
@@ -18650,26 +18632,13 @@
"protection": "partial",
"owner": "terminal-input",
"layer": "electron-native-ime-e2e",
- "surfaces": [
- "native Hangul composition",
- "Wayland terminal input"
- ],
- "platforms": [
- "linux"
- ],
- "providers": [
- "local"
- ],
- "coveredPlatforms": [
- "linux"
- ],
- "coveredProviders": [
- "local"
- ],
+ "surfaces": ["native Hangul composition", "Wayland terminal input"],
+ "platforms": ["linux"],
+ "providers": ["local"],
+ "coveredPlatforms": ["linux"],
+ "coveredProviders": ["local"],
"coverageNotes": "Ubuntu 22.04 nested GNOME and IBus Hangul drive three complete native executions in GitHub Actions. GNOME owns IBus; daemon and CLI share its default config discovery path.",
- "motivatingLinks": [
- "https://github.com/stablyai/orca/pull/19174"
- ],
+ "motivatingLinks": ["https://github.com/stablyai/orca/pull/19174"],
"invariant": "Typing d k 1 Return through native IBus Hangul delivers exactly 아1 followed by newline without missing, duplicate, or reordered characters.",
"oracle": "Three executions each assert three exact UTF-8 PTY lines. Verify the exact Playwright title, zero skips/retries, each individual native composition receipt, and the nested launch Wayland flag.",
"commands": [
@@ -18685,15 +18654,11 @@
"assertionRefs": [
{
"file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts",
- "assertions": [
- "a digit typed right after a Hangul syllable reaches the pty"
- ]
+ "assertions": ["a digit typed right after a Hangul syllable reaches the pty"]
},
{
"file": "config/scripts/terminal-ime-e2e-workflow.test.mjs",
- "assertions": [
- "runs native Wayland independently with CJK fonts and retained evidence"
- ]
+ "assertions": ["runs native Wayland independently with CJK fonts and retained evidence"]
}
],
"evidenceRuns": [
diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md
index 24854890fc7..1ba19df87ea 100644
--- a/docs/reference/windows-edr-posture.md
+++ b/docs/reference/windows-edr-posture.md
@@ -31,12 +31,12 @@ administrators can do about it.
Four independent evidence clusters, from six incidents:
-| Cluster | Incidents | Evidence |
-| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
-| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
+| Cluster | Incidents | Evidence |
+| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
+| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
-| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
-| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
+| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
+| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
**Execution + Collection**, and a description reading _"Screenshots were taken
@@ -143,11 +143,11 @@ PEB fallback to reinstate it — a hooked `ntdll` answering
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
-Because the property is the *absence* of an import, it is checkable on the
+Because the property is the _absence_ of an import, it is checkable on the
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
-bearing because the published tarball ships a *loadable* prebuilt built from
+bearing because the published tarball ships a _loadable_ prebuilt built from
unpatched source, so "it required cleanly" is not evidence.
What to declare to administrators is now one
@@ -180,7 +180,7 @@ Three sites are named in the incident analysis:
`src/shared/setup-agent-sequencing.ts`,
`src/shared/windows-cmd-runner-delayed-launch.ts` and
`src/shared/windows-interactive-login-spawn.ts` each dropped
-`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script *files*,
+`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script _files_,
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
pattern to copy rather than restoring the switch — the switch loses to a GPO
@@ -192,7 +192,7 @@ What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
`src/main/agent-hooks/runtime-home-hook-command.ts`,
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
-— that last one only as a *fallback* since #18875, see below),
+— that last one only as a _fallback_ since #18875, see below),
`src/main/runtime/windows-default-route-interfaces.ts`,
`src/main/runtime/orchestration/setup-completion-signal.ts`,
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
@@ -224,7 +224,7 @@ denies the analyser the payload it would otherwise clear.
The hook launcher is prior art worth knowing about. #16003 measured, on a
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
-payload — `exit 0` was denied too. The fix was to stop *spelling* the flags:
+payload — `exit 0` was denied too. The fix was to stop _spelling_ the flags:
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
#16576, the execution policy bypass moved in-payload as a process-scope
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
@@ -247,7 +247,7 @@ a quoted token, each `%` is broken with `"^%"`.
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
-"e^%F^%g", "h"]` — the `&` truncated the argument *and* ran the remainder as a
+"e^%F^%g", "h"]` — the `&` truncated the argument _and_ ran the remainder as a
command.
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
@@ -259,7 +259,7 @@ obfuscated-command-line detector is tuned on.
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
-terminal multiplexer for coding agents *is*. `reg.exe` appears from
+terminal multiplexer for coding agents _is_. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
@@ -267,7 +267,7 @@ terminal multiplexer for coding agents *is*. `reg.exe` appears from
Nothing here is avoidable in principle. What is controllable is depth and
breadth: every interpreter hop between Orca and the thing the user asked for adds
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
-*shorten the interpreter chain* rather than to hide a window.
+_shorten the interpreter chain_ rather than to hide a window.
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
@@ -295,7 +295,7 @@ That last clause is the standing assumption of this change, and it is worth
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
any host that is one of the first three. Claude Code itself is a Git Bash host on
-native Windows. What no one here has verified is which host a *compat consumer*
+native Windows. What no one here has verified is which host a _compat consumer_
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
for exactly that). If one of them spawns hook strings through Windows PowerShell
@@ -318,12 +318,12 @@ then captures the screen through `Graphics.CopyFromScreen`.
That is four separate high-signal behaviours stacked in one process:
-| Behaviour | How it is scored |
-| ----------------------------------------------- | ---------------------------------------------------- |
-| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
-| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
-| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
-| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
+| Behaviour | How it is scored |
+| --------------------------------------------- | ------------------------------------------------------------- |
+| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
+| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
+| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
+| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
The bottom two rows are the two the incident text named directly, and they are
also the two a persistent runtime host would remove: a long-lived helper compiles
@@ -381,16 +381,16 @@ changed. Check the code before relying on it.
The checklist. On Windows, do not reach for:
-| Don't | Instead |
-| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
-| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
-| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
-| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
-| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
-| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
-| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
-| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
+| Don't | Instead |
+| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
+| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
+| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
+| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
+| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
+| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
+| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
+| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
@@ -407,7 +407,7 @@ Two framing rules that outlast the table:
This is the single most important operational point, and it is the one most
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
-Defender Antivirus path exclusions suppress *scan* detections; they do not
+Defender Antivirus path exclusions suppress _scan_ detections; they do not
suppress EDR behavioural alerts the same way. Adding
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
incidents to stop will not work.
diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md
index 80cc7663f4c..fac8f7c58d1 100644
--- a/docs/reference/windows-process-enumeration.md
+++ b/docs/reference/windows-process-enumeration.md
@@ -64,10 +64,10 @@ identity scan opens nothing.
So the module exposes two snapshots, and the row types differ so a cheap caller
cannot read what its flag set did not pay for:
-| reader | row type | flags | per-process handles |
-| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- |
-| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
-| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
+| reader | row type | flags | per-process handles |
+| ------------------------------------------ | --------------------------- | ---------------------- | ------------------- |
+| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
+| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
`Memory` is requested by neither. Nothing reads a working set off this table —
`windows-process-resource-collector.ts` runs its own sweep because it needs
@@ -103,7 +103,7 @@ only under concurrency.
Nothing else in this module prevents that. Each snapshot cache single-flights
only within itself (`inFlight` is a closure per reader), and the wedge set
-latches only *after* a read misses its 3 s deadline, so through the healthy
+latches only _after_ a read misses its 3 s deadline, so through the healthy
~12 ms of a scan neither excludes the other. Overlap is the normal state rather
than an edge case: other panes keep polling detailed at 750 ms while a teardown
takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh
@@ -166,15 +166,15 @@ through `toIdentityRow`, so an identity row carries no command line on any host.
### Which callers need which
-| caller | reads | flag set |
-| --------------------------------------------- | ------------------ | -------- |
-| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
-| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
-| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
-| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
-| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
-| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
-| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
+| caller | reads | flag set |
+| ------------------------------------------ | --------------------------------- | -------- |
+| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
+| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
+| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
+| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
+| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
+| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
+| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and
`name`, which the identity set answers, but it calls the detailed reader. On a
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
## Why the package is patched
-`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
+`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries six changes.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -368,10 +368,10 @@ on any other OS keeps using the scan.
to Unix ms; a process that denies the handle is emitted with the field
absent, never zero, because callers must be able to tell "cannot identify"
from a timestamp.
-5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
+6. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
compiled binary understands, and `lib/index.js` re-exports it.
- Why a fifth hunk and not just the enum: unlike `node-pty`, this package
+ Why a separate hunk and not just the enum: unlike `node-pty`, this package
publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
diff --git a/docs/site/content/docs/install.mdx b/docs/site/content/docs/install.mdx
index 9341cb0fa0d..d08e43320ef 100644
--- a/docs/site/content/docs/install.mdx
+++ b/docs/site/content/docs/install.mdx
@@ -30,8 +30,7 @@ import { Callout } from '@/components/docs/prose'
[installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
- **Linux:**
- AppImage
+ **Linux:** AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
@@ -131,7 +130,7 @@ On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `or
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
-- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
+- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that _during_ startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx
index 37f86665d6e..f23e3d94a6a 100644
--- a/docs/site/content/docs/remote-servers.mdx
+++ b/docs/site/content/docs/remote-servers.mdx
@@ -129,19 +129,23 @@ Install Orca and its bundled CLI on the server, then run:
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
- while it is starting, so that shim can never be the command that starts the server. Read
- `orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
- [Install → Linux](/docs/install#linux).
+ while it is starting, so that shim can never be the command that starts the server. Read `orca
+ serve` as `orca-ide serve` throughout this page when the host is Linux. See [Install →
+ Linux](/docs/install#linux).
```bash
orca serve --pairing-address
+# Linux
+orca-ide serve --pairing-address
```
For example:
```bash
orca serve --pairing-address 100.64.1.20
+# Linux
+orca-ide serve --pairing-address 100.64.1.20
```
The command:
@@ -157,6 +161,8 @@ Add `--port 6768` when a firewall, tunnel, or service definition requires a fixe
```bash
orca serve --port 6768 --pairing-address 100.64.1.20
+# Linux
+orca-ide serve --port 6768 --pairing-address 100.64.1.20
```
Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup.
@@ -167,6 +173,8 @@ For the Orca mobile app, request a mobile-scoped QR code and link:
```bash
orca serve --pairing-address 100.64.1.20 --mobile-pairing
+# Linux
+orca-ide serve --pairing-address 100.64.1.20 --mobile-pairing
```
Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan the terminal QR code or paste the printed link.
diff --git a/mobile/src/cache/session-tab-strip-cache.test.ts b/mobile/src/cache/session-tab-strip-cache.test.ts
new file mode 100644
index 00000000000..f432801a647
--- /dev/null
+++ b/mobile/src/cache/session-tab-strip-cache.test.ts
@@ -0,0 +1,406 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const asyncStorage = vi.hoisted(() => ({
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn()
+}))
+
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
+
+import {
+ deleteCachedSessionTabStripForHost,
+ getSessionTabStripCacheKey,
+ loadCachedSessionTabStrip,
+ readCachedSessionTabStrip,
+ resetSessionTabStripCacheForTests,
+ saveCachedSessionTabStrip
+} from './session-tab-strip-cache'
+import type { MobileSessionTabStripPreview } from '../session/mobile-session-tab-strip-entries'
+
+const STORAGE_KEY = 'orca:session-tab-strip:v1'
+
+function preview(...ids: string[]): MobileSessionTabStripPreview {
+ return {
+ tabs: ids.map((id) => ({ id, type: 'terminal' as const, title: id, agentId: null })),
+ activeTabId: ids[0] ?? null
+ }
+}
+
+function lastWrittenFile(): { workspaces: { key: string }[] } {
+ const call = asyncStorage.setItem.mock.calls.at(-1)
+ return JSON.parse(String(call?.[1]))
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ asyncStorage.getItem.mockReset().mockResolvedValue(null)
+ asyncStorage.setItem.mockReset().mockResolvedValue(undefined)
+ resetSessionTabStripCacheForTests()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('getSessionTabStripCacheKey', () => {
+ it('digests the workspace id so no filesystem path reaches the key', () => {
+ const path = '/Users/someone/private-client/worktrees/acquisition'
+ const key = getSessionTabStripCacheKey('host-1', `repo::${path}`)
+
+ expect(key).not.toContain(path)
+ expect(key).not.toContain('someone')
+ expect(key).toMatch(/^\["host-1","[0-9a-f]{32}"\]$/)
+ })
+
+ it('joins the two ids unambiguously, whatever a worktree path contains', () => {
+ expect(getSessionTabStripCacheKey('host', 'a\nb')).not.toBe(
+ getSessionTabStripCacheKey('host\na', 'b')
+ )
+ expect(getSessionTabStripCacheKey('host-1', 'wt-1')).not.toBe(
+ getSessionTabStripCacheKey('host-1', 'wt-2')
+ )
+ })
+
+ it('needs both a host and a workspace', () => {
+ expect(getSessionTabStripCacheKey(undefined, 'wt-1')).toBeNull()
+ expect(getSessionTabStripCacheKey('host-1', undefined)).toBeNull()
+ })
+})
+
+describe('session tab strip cache', () => {
+ it('serves a save back synchronously and persists it once the write settles', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, preview('tab-1', 'tab-2'))
+
+ expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2'])
+ expect(asyncStorage.setItem).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(asyncStorage.setItem.mock.calls[0]?.[0]).toBe(STORAGE_KEY)
+ expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([key])
+ })
+
+ it('reads nothing synchronously before the stored file is loaded', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ asyncStorage.getItem.mockResolvedValue(
+ JSON.stringify({ workspaces: [{ key, preview: preview('tab-1') }] })
+ )
+
+ expect(readCachedSessionTabStrip(key)).toBeNull()
+ expect((await loadCachedSessionTabStrip(key))?.tabs.map((tab) => tab.id)).toEqual(['tab-1'])
+ expect(readCachedSessionTabStrip(key)?.tabs).toHaveLength(1)
+ })
+
+ it('returns null for a workspace with no stored strip', async () => {
+ expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-9'))).toBeNull()
+ expect(await loadCachedSessionTabStrip(null)).toBeNull()
+ })
+
+ it('survives unreadable storage', async () => {
+ asyncStorage.getItem.mockResolvedValue('{not json')
+
+ expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-1'))).toBeNull()
+ })
+
+ it('evicts the least recently written workspace past the cap', async () => {
+ for (let i = 0; i < 14; i++) {
+ saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
+ }
+ await vi.advanceTimersByTimeAsync(300)
+
+ const keys = lastWrittenFile().workspaces.map((w) => w.key)
+ expect(keys).toHaveLength(12)
+ expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
+ expect(keys.at(-1)).toBe(getSessionTabStripCacheKey('host-1', 'wt-13'))
+ })
+
+ it('re-writing a workspace makes it the newest, not the oldest', async () => {
+ for (let i = 0; i < 12; i++) {
+ saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
+ }
+ saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-0'), preview('tab-2'))
+ saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-99'), preview('tab-1'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ const keys = lastWrittenFile().workspaces.map((w) => w.key)
+ expect(keys).toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
+ expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-1'))
+ })
+
+ it('records a workspace the host has emptied, so a stale strip cannot outlive it', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, preview('tab-1'))
+ saveCachedSessionTabStrip(key, { tabs: [], activeTabId: null })
+
+ expect(readCachedSessionTabStrip(key)).toEqual({ tabs: [], activeTabId: null })
+ })
+
+ it('caps tabs per workspace and title length, and drops an unmatched active id', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, {
+ // A file tab, because the titles that survive redaction at all are the ones the cap has
+ // to bound.
+ tabs: Array.from({ length: 30 }, (_, i) => ({
+ id: `tab-${i}`,
+ type: 'file' as const,
+ title: 'x'.repeat(200),
+ agentId: null
+ })),
+ activeTabId: 'tab-29'
+ })
+
+ const stored = readCachedSessionTabStrip(key)
+ expect(stored?.tabs).toHaveLength(24)
+ expect(stored?.tabs[0]?.title).toHaveLength(64)
+ expect(stored?.activeTabId).toBeNull()
+ })
+
+ it('drops fields a future tab type might smuggle into storage', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, {
+ tabs: [
+ {
+ id: 'tab-1',
+ type: 'file',
+ title: 'notes.md',
+ agentId: null,
+ filePath: '/Users/someone/secret/notes.md'
+ } as never
+ ],
+ activeTabId: 'tab-1'
+ })
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(String(asyncStorage.setItem.mock.calls.at(-1)?.[1])).not.toContain('/Users/someone')
+ })
+
+ it('drops a stored entry naming a tab type this build cannot draw', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, {
+ tabs: [
+ { id: 'tab-1', type: 'from-a-newer-build', title: 'raw title', agentId: null } as never,
+ { id: 'tab-2', type: 'file', title: 'notes.md', agentId: null }
+ ],
+ activeTabId: 'tab-2'
+ })
+
+ expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-2'])
+ })
+
+ it('never writes a shell-controlled terminal title, however it arrives', async () => {
+ const secret = 'psql postgres://admin:hunter2@db.internal/prod'
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ saveCachedSessionTabStrip(key, {
+ tabs: [
+ { id: 'tab-1', type: 'terminal', title: secret, agentId: null },
+ { id: 'tab-2', type: 'terminal', title: secret, agentId: 'claude' },
+ { id: 'tab-3', type: 'terminal', title: secret, agentId: 'not-a-known-agent' },
+ { id: 'tab-4', type: 'browser', title: 'Acme Corp — Q3 layoffs memo', agentId: null }
+ ],
+ activeTabId: 'tab-1'
+ })
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.title)).toEqual([
+ 'Terminal',
+ 'Claude',
+ 'Terminal',
+ 'Browser'
+ ])
+ const written = String(asyncStorage.setItem.mock.calls.at(-1)?.[1])
+ expect(written).not.toContain('hunter2')
+ expect(written).not.toContain('postgres://')
+ expect(written).not.toContain('layoffs')
+ })
+
+ it('scrubs a stored title written by an older build on the way back out', async () => {
+ const key = getSessionTabStripCacheKey('host-1', 'wt-1')
+ asyncStorage.getItem.mockResolvedValue(
+ JSON.stringify({
+ workspaces: [
+ {
+ key,
+ preview: {
+ tabs: [{ id: 'tab-1', type: 'terminal', title: 'curl -H token', agentId: null }],
+ activeTabId: 'tab-1'
+ }
+ }
+ ]
+ })
+ )
+
+ expect((await loadCachedSessionTabStrip(key))?.tabs[0]?.title).toBe('Terminal')
+ })
+
+ it('forgets an unpaired host and cannot resurrect it from a later save', async () => {
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ saveCachedSessionTabStrip(hostB, preview('tab-b'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ await deleteCachedSessionTabStripForHost('host-a')
+
+ expect(readCachedSessionTabStrip(hostA)).toBeNull()
+ expect(readCachedSessionTabStrip(hostB)?.tabs).toHaveLength(1)
+ expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
+
+ saveCachedSessionTabStrip(hostB, preview('tab-b2'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
+ })
+
+ it('forgets a host whose rows are only on disk, never read this session', async () => {
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
+ asyncStorage.getItem.mockResolvedValue(
+ JSON.stringify({
+ workspaces: [
+ { key: hostA, preview: preview('tab-a') },
+ { key: hostB, preview: preview('tab-b') }
+ ]
+ })
+ )
+
+ await deleteCachedSessionTabStripForHost('host-a')
+
+ expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
+ })
+
+ it('drops a pending debounced write so it cannot restore the forgotten host', async () => {
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+
+ await deleteCachedSessionTabStripForHost('host-a')
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(lastWrittenFile().workspaces).toEqual([])
+ })
+ it('rejects a deletion whose write never landed, rather than reporting it as done', async () => {
+ // A resolved delete over a failed write leaves the forgotten host's tab titles in
+ // plaintext on disk while every caller believes they are gone.
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ await vi.advanceTimersByTimeAsync(300)
+ asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
+
+ await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
+ })
+
+ it('keeps a debounced save best effort, so one failed write cannot reject unowned', async () => {
+ asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
+ saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-a', 'wt-1'), preview('tab-a'))
+
+ // No throw and no unhandled rejection: the write is fire-and-forget by design.
+ await vi.advanceTimersByTimeAsync(300)
+ expect(asyncStorage.setItem).toHaveBeenCalledOnce()
+ })
+
+ it('refuses a save for the host it is in the middle of forgetting', async () => {
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ let releaseWrite!: () => void
+ asyncStorage.setItem.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ releaseWrite = () => resolve()
+ })
+ )
+ const deletion = deleteCachedSessionTabStripForHost('host-a')
+ // The purge has run and its write is on the wire; a snapshot queued for the
+ // workspace the user just unpaired now lands in that window.
+ await vi.advanceTimersByTimeAsync(0)
+ saveCachedSessionTabStrip(hostA, preview('tab-a2'))
+ releaseWrite()
+ await deletion
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(readCachedSessionTabStrip(hostA)).toBeNull()
+ expect(lastWrittenFile().workspaces).toEqual([])
+ })
+
+ it('cannot be talked back into a host whose deletion write failed', async () => {
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ await vi.advanceTimersByTimeAsync(300)
+ asyncStorage.setItem.mockRejectedValueOnce(new Error('storage full'))
+
+ await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
+ const writesSoFar = asyncStorage.setItem.mock.calls.length
+
+ saveCachedSessionTabStrip(hostA, preview('tab-a3'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ expect(readCachedSessionTabStrip(hostA)).toBeNull()
+ expect(asyncStorage.setItem).toHaveBeenCalledTimes(writesSoFar)
+ })
+ it('lets a debounced write that already snapshotted the removed host land first', async () => {
+ // The tombstone stops new saves, but a debounced write that fired a moment earlier
+ // built its blob from the map as it was and is still on the wire. Writing over it
+ // concurrently leaves which blob lands last up to storage.
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ saveCachedSessionTabStrip(hostB, preview('tab-b'))
+
+ let releaseDebounced!: () => void
+ asyncStorage.setItem.mockImplementationOnce(
+ async () =>
+ new Promise((resolve) => {
+ releaseDebounced = () => resolve()
+ })
+ )
+ await vi.advanceTimersByTimeAsync(300)
+
+ const deletion = deleteCachedSessionTabStripForHost('host-a')
+ await vi.advanceTimersByTimeAsync(0)
+ expect(asyncStorage.setItem).toHaveBeenCalledOnce()
+
+ releaseDebounced()
+ await deletion
+
+ expect(asyncStorage.setItem).toHaveBeenCalledTimes(2)
+ expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
+ })
+ it('cannot let an older overlapping write commit after the purge', async () => {
+ // Why: two debounced writes can sit on the bridge at once, and the second used to replace
+ // the in-flight handle. The purge then awaited only the newer one, so the older blob --
+ // snapshotted while the forgotten host was still in the map -- could commit last.
+ const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
+ const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
+ let stored = ''
+ const gates: Array<() => void> = []
+ asyncStorage.setItem.mockImplementation(
+ (_key: string, value: string) =>
+ new Promise((resolve) => {
+ gates.push(() => {
+ stored = value
+ resolve()
+ })
+ })
+ )
+
+ saveCachedSessionTabStrip(hostA, preview('tab-a'))
+ await vi.advanceTimersByTimeAsync(300)
+ saveCachedSessionTabStrip(hostB, preview('tab-b'))
+ await vi.advanceTimersByTimeAsync(300)
+
+ const deletion = deleteCachedSessionTabStripForHost('host-a')
+ // Newest released first: only writes that queue behind one another survive this.
+ for (let step = 0; step < 6 && gates.length > 0; step += 1) {
+ gates.pop()?.()
+ await vi.advanceTimersByTimeAsync(0)
+ }
+ await deletion
+
+ const keys = (JSON.parse(stored) as { workspaces: { key: string }[] }).workspaces.map(
+ (workspace) => workspace.key
+ )
+ expect(keys).toEqual([hostB])
+ })
+})
diff --git a/mobile/src/cache/session-tab-strip-cache.ts b/mobile/src/cache/session-tab-strip-cache.ts
new file mode 100644
index 00000000000..d5fef98c75c
--- /dev/null
+++ b/mobile/src/cache/session-tab-strip-cache.ts
@@ -0,0 +1,260 @@
+// Why: reconnecting to a workspace the phone opened a minute ago tears the session screen back
+// to an empty strip and a spinner, even though the tab list it is about to be handed is the one
+// it just displayed. Persist the shape of the strip per workspace so a reconnect paints the
+// known tabs immediately and swaps in live rows under the same keys.
+//
+// This file is the authority on what reaches plaintext storage, not its callers: every entry is
+// rebuilt field by field on the way in, and shell-controlled titles are replaced with fixed
+// labels here rather than trusted to have been scrubbed upstream.
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { sha256 } from '@noble/hashes/sha256'
+import {
+ getPersistableTabStripTitle,
+ isDrawableTabStripType,
+ type MobileSessionTabStripEntry,
+ type MobileSessionTabStripPreview
+} from '../session/mobile-session-tab-strip-entries'
+
+const STORAGE_KEY = 'orca:session-tab-strip:v1'
+// A phone realistically revisits a handful of workspaces; the caps bound both the stored blob
+// and the cost of a single write.
+const MAX_WORKSPACES = 12
+const MAX_TABS_PER_WORKSPACE = 24
+const MAX_TITLE_LENGTH = 64
+const WRITE_DEBOUNCE_MS = 250
+// 128 bits of a digest: far past collision range for a dozen workspaces, and short enough that
+// the stored blob stays small.
+const WORKSPACE_DIGEST_LENGTH = 32
+
+type StoredWorkspace = { key: string; preview: MobileSessionTabStripPreview }
+type StoredFile = { workspaces: StoredWorkspace[] }
+
+// Insertion-ordered, so the first key is the least recently written one to evict.
+let memoryCache: Map | null = null
+let loadPromise: Promise