From 7465fda7856859ec15c603e8c1ccc22b73719491 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 14 May 2026 23:01:15 +0000
Subject: [PATCH 01/82] release: v1.4.2-rc.2
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 7e1366af82b..262e804eaa3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.2-rc.1",
+ "version": "1.4.2-rc.2",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From dcf5bf0dcee21f7e39f9e4b30632e71eb5cc2c43 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 14 May 2026 16:05:53 -0700
Subject: [PATCH 02/82] Clarify conflict details refresh state (#1875)
Co-authored-by: Orca
---
.../src/components/GitHubItemDialog.tsx | 2 +-
.../components/right-sidebar/ChecksPanel.tsx | 22 ++++++--
.../right-sidebar/SourceControl.tsx | 2 +-
.../checks-panel-content.test.tsx | 54 +++++++++++++++++++
...s-helpers.tsx => checks-panel-content.tsx} | 14 ++++-
5 files changed, 87 insertions(+), 7 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
rename src/renderer/src/components/right-sidebar/{checks-helpers.tsx => checks-panel-content.tsx} (98%)
diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx
index 6f449118a9f..b8cd117d62e 100644
--- a/src/renderer/src/components/GitHubItemDialog.tsx
+++ b/src/renderer/src/components/GitHubItemDialog.tsx
@@ -50,7 +50,7 @@ import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { detectLanguage } from '@/lib/language-detect'
import { cn } from '@/lib/utils'
import { buildDiffTree, type DiffTreeNode } from '@/components/pr-diff-tree'
-import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-helpers'
+import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel-content'
import {
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
index 1e644af9a83..b401307d286 100644
--- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx
@@ -14,7 +14,7 @@ import {
MergeConflictNotice,
ChecksList,
PRCommentsList
-} from './checks-helpers'
+} from './checks-panel-content'
import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh'
import type { PRInfo, PRCheckDetail, PRComment } from '../../../../shared/types'
@@ -43,6 +43,7 @@ export default function ChecksPanel(): React.JSX.Element {
const [commentsLoading, setCommentsLoading] = useState(false)
const [emptyRefreshing, setEmptyRefreshing] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
+ const [conflictDetailsRefreshing, setConflictDetailsRefreshing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const [titleSaving, setTitleSaving] = useState(false)
@@ -67,6 +68,7 @@ export default function ChecksPanel(): React.JSX.Element {
setTitleSaving(false)
setIsRefreshing(false)
setEmptyRefreshing(false)
+ setConflictDetailsRefreshing(false)
conflictSummaryRefreshKeyRef.current = null
}
@@ -108,6 +110,7 @@ export default function ChecksPanel(): React.JSX.Element {
useEffect(() => {
if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
conflictSummaryRefreshKeyRef.current = null
+ setConflictDetailsRefreshing(false)
return
}
@@ -121,7 +124,17 @@ export default function ChecksPanel(): React.JSX.Element {
// them so we don't keep rendering cached branch summaries or empty file
// lists from an older payload.
conflictSummaryRefreshKeyRef.current = refreshKey
- void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR })
+ setConflictDetailsRefreshing(true)
+ void fetchPRForBranch(repo.path, branch, { force: true, linkedPRNumber: linkedPR }).finally(
+ () => {
+ // Why: fetchPRForBranch updates the PR cache before resolving, which
+ // can rerun this effect. Only the current refresh key may clear the
+ // spinner so stale requests don't race newer worktrees/branches.
+ if (conflictSummaryRefreshKeyRef.current === refreshKey) {
+ setConflictDetailsRefreshing(false)
+ }
+ }
+ )
}, [repo, isFolder, branch, pr, linkedPR, fetchPRForBranch])
// Fetch checks via cached store method
@@ -580,7 +593,10 @@ export default function ChecksPanel(): React.JSX.Element {
-
+
{/* Why: when the PR has merge conflicts and no checks have been fetched,
showing "No checks configured" is misleading — checks may exist but
simply cannot run until conflicts are resolved. Hide the empty state. */}
diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx
index 0af146ae5dc..7047bf1e8d4 100644
--- a/src/renderer/src/components/right-sidebar/SourceControl.tsx
+++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx
@@ -92,7 +92,7 @@ import {
requestEditorSaveQuiesce
} from '@/components/editor/editor-autosave'
import { getConnectionId } from '@/lib/connection-context'
-import { PullRequestIcon } from './checks-helpers'
+import { PullRequestIcon } from './checks-panel-content'
import type {
DiffComment,
GitBranchChangeEntry,
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
new file mode 100644
index 00000000000..1d8017fbf7e
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import type { PRInfo } from '../../../../shared/types'
+import { MergeConflictNotice } from './checks-panel-content'
+
+function makePR(overrides: Partial = {}): PRInfo {
+ return {
+ number: 42,
+ title: 'Conflicting PR',
+ state: 'open',
+ url: 'https://github.com/acme/widgets/pull/42',
+ checksStatus: 'pending',
+ updatedAt: '2026-05-14T00:00:00Z',
+ mergeable: 'CONFLICTING',
+ ...overrides
+ }
+}
+
+function renderNotice(pr: PRInfo, isRefreshingConflictDetails = false): string {
+ return renderToStaticMarkup(
+ React.createElement(MergeConflictNotice, { pr, isRefreshingConflictDetails })
+ )
+}
+
+describe('MergeConflictNotice', () => {
+ it('does not claim conflict details are refreshing after the refresh has settled', () => {
+ const markup = renderNotice(makePR())
+
+ expect(markup).toContain('Conflict file details are unavailable')
+ expect(markup).not.toContain('Refreshing conflict details')
+ })
+
+ it('shows refreshing copy while conflict details are actively refreshing', () => {
+ const markup = renderNotice(makePR(), true)
+
+ expect(markup).toContain('Refreshing conflict details')
+ })
+
+ it('hides when the conflicting file list is available', () => {
+ const markup = renderNotice(
+ makePR({
+ conflictSummary: {
+ baseRef: 'main',
+ baseCommit: 'abc1234',
+ commitsBehind: 2,
+ files: ['src/conflict.ts']
+ }
+ })
+ )
+
+ expect(markup).toBe('')
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/checks-helpers.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
similarity index 98%
rename from src/renderer/src/components/right-sidebar/checks-helpers.tsx
rename to src/renderer/src/components/right-sidebar/checks-panel-content.tsx
index b907dd7dff7..8dce022608d 100644
--- a/src/renderer/src/components/right-sidebar/checks-helpers.tsx
+++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx
@@ -101,7 +101,13 @@ export function ConflictingFilesSection({ pr }: { pr: PRInfo }): React.JSX.Eleme
}
/** Fallback shown when GitHub reports merge conflicts but no file list is available yet. */
-export function MergeConflictNotice({ pr }: { pr: PRInfo }): React.JSX.Element | null {
+export function MergeConflictNotice({
+ pr,
+ isRefreshingConflictDetails
+}: {
+ pr: PRInfo
+ isRefreshingConflictDetails: boolean
+}): React.JSX.Element | null {
if (pr.mergeable !== 'CONFLICTING' || (pr.conflictSummary?.files.length ?? 0) > 0) {
return null
}
@@ -111,7 +117,11 @@ export function MergeConflictNotice({ pr }: { pr: PRInfo }): React.JSX.Element |
This branch has conflicts that must be resolved
- Refreshing conflict details…
+
+ {isRefreshingConflictDetails
+ ? 'Refreshing conflict details…'
+ : 'Conflict file details are unavailable'}
+
)
}
From 2cadcbc4e1ffbda3f04dd0c6fbc02cd816f55ac2 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 14 May 2026 16:06:23 -0700
Subject: [PATCH 03/82] Support agent status hooks over SSH (#1865)
Co-authored-by: Orca
---
config/tsconfig.relay.json | 6 +-
.../installer-utils-remote.test.ts | 269 ++++++++++++
.../agent-hooks/installer-utils-remote.ts | 385 ++++++++++++++++++
.../remote-hook-service-installers.test.ts | 224 ++++++++++
src/main/agent-hooks/server.ts | 22 +-
src/main/claude/hook-service.test.ts | 166 ++++++++
src/main/claude/hook-service.ts | 87 +++-
src/main/codex/config-toml-trust.ts | 18 +-
src/main/codex/hook-service.ts | 106 ++++-
src/main/cursor/hook-service.ts | 77 +++-
src/main/gemini/hook-service.ts | 69 +++-
src/main/ipc/pty.test.ts | 4 +
src/main/pty/overlay-mirror.ts | 12 +-
src/main/pty/shell-startup-env.test.ts | 19 +-
src/main/pty/shell-startup-env.ts | 90 ++--
src/main/ssh/ssh-relay-session.test.ts | 67 ++-
src/main/ssh/ssh-relay-session.ts | 80 +++-
src/relay/agent-hook-server.ts | 12 +-
src/relay/plugin-overlay-env.test.ts | 81 ++++
src/relay/plugin-overlay-env.ts | 35 ++
src/relay/plugin-overlay.test.ts | 186 +++++++++
src/relay/plugin-overlay.ts | 276 +++++++++++++
src/relay/plugin-source-limit.test.ts | 24 ++
src/relay/plugin-source-limit.ts | 12 +
src/relay/pty-handler.test.ts | 147 ++++++-
src/relay/pty-handler.ts | 109 +++--
src/relay/pty-shell-launch.test.ts | 58 +++
src/relay/pty-shell-launch.ts | 165 ++++++++
src/relay/relay.ts | 110 ++++-
src/shared/agent-hook-listener.ts | 4 +-
tests/e2e/ssh-localhost.spec.ts | 67 ++-
31 files changed, 2829 insertions(+), 158 deletions(-)
create mode 100644 src/main/agent-hooks/installer-utils-remote.test.ts
create mode 100644 src/main/agent-hooks/installer-utils-remote.ts
create mode 100644 src/main/agent-hooks/remote-hook-service-installers.test.ts
create mode 100644 src/main/claude/hook-service.test.ts
create mode 100644 src/relay/plugin-overlay-env.test.ts
create mode 100644 src/relay/plugin-overlay-env.ts
create mode 100644 src/relay/plugin-overlay.test.ts
create mode 100644 src/relay/plugin-overlay.ts
create mode 100644 src/relay/plugin-source-limit.test.ts
create mode 100644 src/relay/plugin-source-limit.ts
create mode 100644 src/relay/pty-shell-launch.test.ts
create mode 100644 src/relay/pty-shell-launch.ts
diff --git a/config/tsconfig.relay.json b/config/tsconfig.relay.json
index 247b9ac7476..242069f761c 100644
--- a/config/tsconfig.relay.json
+++ b/config/tsconfig.relay.json
@@ -1,6 +1,10 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
- "include": ["../src/relay/**/*"],
+ "include": [
+ "../src/relay/**/*",
+ "../src/main/pty/overlay-mirror.ts",
+ "../src/main/pty/shell-startup-env.ts"
+ ],
"exclude": ["../src/relay/integration.test.ts"],
"compilerOptions": {
"composite": true,
diff --git a/src/main/agent-hooks/installer-utils-remote.test.ts b/src/main/agent-hooks/installer-utils-remote.test.ts
new file mode 100644
index 00000000000..304ef4484ea
--- /dev/null
+++ b/src/main/agent-hooks/installer-utils-remote.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, it } from 'vitest'
+import type { SFTPWrapper } from 'ssh2'
+
+import {
+ readHooksJsonRemote,
+ writeHooksJsonRemote,
+ writeManagedScriptRemote,
+ writeTextFileRemoteAtomic
+} from './installer-utils-remote'
+
+type FakeFs = {
+ files: Map
+ dirs: Set
+ modes: Map
+ openSshRenameCount: number
+}
+
+function createFakeSftp(
+ opts: {
+ plainRenameOverwrites?: boolean
+ openSshRename?: boolean
+ failDotFileWrites?: boolean
+ } = {}
+): {
+ sftp: SFTPWrapper
+ fs: FakeFs
+} {
+ const plainRenameOverwrites = opts.plainRenameOverwrites ?? true
+ const fs: FakeFs = {
+ files: new Map(),
+ dirs: new Set(['/']),
+ modes: new Map(),
+ openSshRenameCount: 0
+ }
+ const noEntryError = (path: string): { code: number; message: string } => ({
+ code: 2,
+ message: `ENOENT ${path}`
+ })
+ const fakeStats = (mode: number): { mode: number } => ({ mode })
+
+ const sftp = {
+ readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
+ const v = fs.files.get(path)
+ if (v === undefined) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, v)
+ },
+ writeFile: (
+ path: string,
+ content: string,
+ options: string | { mode?: number },
+ cb: (err: unknown) => void
+ ): void => {
+ if (opts.failDotFileWrites && path.includes('/.')) {
+ cb({ code: 4, message: `write failed ${path}` })
+ return
+ }
+ fs.files.set(path, content)
+ if (typeof options !== 'string' && options.mode !== undefined) {
+ fs.modes.set(path, options.mode)
+ }
+ cb(null)
+ },
+ rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
+ const v = fs.files.get(src)
+ if (v === undefined) {
+ cb(noEntryError(src))
+ return
+ }
+ if (!plainRenameOverwrites && fs.files.has(dst)) {
+ cb({ code: 4, message: `SSH_FX_FAILURE destination exists ${dst}` })
+ return
+ }
+ fs.files.set(dst, v)
+ fs.files.delete(src)
+ const mode = fs.modes.get(src)
+ if (mode !== undefined) {
+ fs.modes.set(dst, mode)
+ fs.modes.delete(src)
+ }
+ cb(null)
+ },
+ unlink: (path: string, cb: (err: unknown) => void): void => {
+ if (!fs.files.has(path)) {
+ cb(noEntryError(path))
+ return
+ }
+ fs.files.delete(path)
+ fs.modes.delete(path)
+ cb(null)
+ },
+ chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
+ fs.modes.set(path, mode)
+ cb(null)
+ },
+ stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
+ if (!fs.files.has(path)) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
+ },
+ readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
+ if (fs.dirs.has(path)) {
+ cb(null, [])
+ return
+ }
+ cb(noEntryError(path))
+ },
+ mkdir: (path: string, cb: (err: unknown) => void): void => {
+ fs.dirs.add(path)
+ cb(null)
+ },
+ ...(opts.openSshRename
+ ? {
+ ext_openssh_rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
+ fs.openSshRenameCount += 1
+ const v = fs.files.get(src)
+ if (v === undefined) {
+ cb(noEntryError(src))
+ return
+ }
+ fs.files.set(dst, v)
+ fs.files.delete(src)
+ const mode = fs.modes.get(src)
+ if (mode !== undefined) {
+ fs.modes.set(dst, mode)
+ fs.modes.delete(src)
+ }
+ cb(null)
+ }
+ }
+ : {})
+ } as unknown as SFTPWrapper
+ return { sftp, fs }
+}
+
+describe('installer-utils-remote', () => {
+ it('returns {} when settings.json does not exist on the remote', async () => {
+ const { sftp } = createFakeSftp()
+ const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
+ expect(result).toEqual({})
+ })
+
+ it('returns null when settings.json is malformed JSON', async () => {
+ const { sftp, fs } = createFakeSftp()
+ fs.files.set('/home/u/.claude/settings.json', 'not json {{')
+ const result = await readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')
+ expect(result).toBeNull()
+ })
+
+ it('rethrows non-ENOENT read errors so callers can distinguish I/O failures from parse failures', async () => {
+ const sftp = {
+ readFile: (_path: string, _enc: string, cb: (err: unknown) => void): void => {
+ // Why: SSH_FX_PERMISSION_DENIED (3) is a real I/O failure that should
+ // not collapse into the same null result the parse-error path uses.
+ cb({ code: 3, message: 'permission denied' })
+ }
+ } as unknown as SFTPWrapper
+ await expect(readHooksJsonRemote(sftp, '/home/u/.claude/settings.json')).rejects.toMatchObject({
+ code: 3
+ })
+ })
+
+ it('atomically writes settings.json via tmp + rename', async () => {
+ const { sftp, fs } = createFakeSftp()
+ await writeHooksJsonRemote(sftp, '/home/u/.claude/settings.json', {
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'foo' }] }] }
+ })
+ expect(fs.files.has('/home/u/.claude/settings.json')).toBe(true)
+ expect(fs.dirs.has('/home/u/.claude')).toBe(true)
+ const contents = fs.files.get('/home/u/.claude/settings.json')!
+ const parsed = JSON.parse(contents)
+ expect(parsed.hooks.Stop[0].hooks[0].command).toBe('foo')
+ // Tmp must be cleaned up.
+ const tmp = Array.from(fs.files.keys()).find((k) => k.includes('.tmp'))
+ expect(tmp).toBeUndefined()
+ expect(fs.modes.get('/home/u/.claude/settings.json')).toBe(0o600)
+ })
+
+ it('preserves existing config file mode across atomic replacement', async () => {
+ const { sftp, fs } = createFakeSftp()
+ const path = '/home/u/.codex/config.toml'
+ fs.files.set(path, 'old')
+ fs.modes.set(path, 0o640)
+
+ await writeTextFileRemoteAtomic(sftp, path, 'new')
+
+ expect(fs.modes.get(path)).toBe(0o640)
+ })
+
+ it('uses OpenSSH overwrite rename when an atomic write updates an existing file', async () => {
+ const { sftp, fs } = createFakeSftp({
+ plainRenameOverwrites: false,
+ openSshRename: true
+ })
+ const path = '/home/u/.claude/settings.json'
+ fs.files.set(path, JSON.stringify({ hooks: {} }))
+
+ await writeHooksJsonRemote(sftp, path, {
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'new' }] }] }
+ })
+
+ expect(fs.openSshRenameCount).toBe(1)
+ expect(JSON.parse(fs.files.get(path)!).hooks.Stop[0].hooks[0].command).toBe('new')
+ })
+
+ it('leaves existing files intact when overwrite rename is unavailable', async () => {
+ const { sftp, fs } = createFakeSftp({ plainRenameOverwrites: false })
+ const path = '/home/u/.claude/settings.json'
+ fs.files.set(path, JSON.stringify({ hooks: { Stop: [] } }))
+
+ await expect(
+ writeHooksJsonRemote(sftp, path, {
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'fallback' }] }] }
+ })
+ ).rejects.toMatchObject({ code: 4 })
+
+ expect(JSON.parse(fs.files.get(path)!).hooks.Stop).toEqual([])
+ expect(Array.from(fs.files.keys()).some((key) => key.includes('.tmp'))).toBe(false)
+ })
+
+ it('writes the managed script and chmods 0o755', async () => {
+ const { sftp, fs } = createFakeSftp()
+ await writeManagedScriptRemote(sftp, '/home/u/.orca/agent-hooks/claude-hook.sh', '#!/bin/sh\n')
+ expect(fs.files.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe('#!/bin/sh\n')
+ expect(fs.modes.get('/home/u/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
+ })
+
+ it('replaces an existing managed script atomically via temp file rename', async () => {
+ const { sftp, fs } = createFakeSftp({
+ plainRenameOverwrites: false,
+ openSshRename: true
+ })
+ const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
+ fs.files.set(path, 'old script')
+
+ await writeManagedScriptRemote(sftp, path, 'new script')
+
+ expect(fs.files.get(path)).toBe('new script')
+ expect(fs.modes.get(path)).toBe(0o755)
+ expect(Array.from(fs.files.keys()).some((key) => key.includes('.orca-backup-'))).toBe(false)
+ })
+
+ it('leaves the existing managed script intact when temp write fails', async () => {
+ const { sftp, fs } = createFakeSftp({ failDotFileWrites: true })
+ const path = '/home/u/.orca/agent-hooks/claude-hook.sh'
+ fs.files.set(path, 'old script')
+
+ await expect(writeManagedScriptRemote(sftp, path, 'new script')).rejects.toMatchObject({
+ code: 4
+ })
+ expect(fs.files.get(path)).toBe('old script')
+ })
+
+ it('skips a no-op write when contents already match', async () => {
+ const { sftp, fs } = createFakeSftp()
+ const path = '/home/u/.claude/settings.json'
+ await writeHooksJsonRemote(sftp, path, { hooks: {} })
+ const beforeKey = fs.files.get(path)
+ // Re-writing the same payload should produce the same content; there is
+ // no rename/tmp cycle visible to a downstream observer beyond the
+ // identical file body.
+ await writeHooksJsonRemote(sftp, path, { hooks: {} })
+ expect(fs.files.get(path)).toBe(beforeKey)
+ })
+})
diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts
new file mode 100644
index 00000000000..63e10b45d5f
--- /dev/null
+++ b/src/main/agent-hooks/installer-utils-remote.ts
@@ -0,0 +1,385 @@
+// Why: SFTP-backed equivalents of `installer-utils.ts` for the remote-install
+// flow. Each function takes an `sftp` handle plus paths the agent CLI expects
+// on the remote (e.g. `~/.claude/settings.json`). Lives in `agent-hooks/`
+// because it shares the contract with the local installer (script body,
+// hook-event shape, atomic-rename semantics) and any drift between them is
+// exactly the bug we want to avoid.
+//
+// We deliberately keep the JSON merge logic in the existing
+// `installer-utils.ts` and only swap fs primitives — the JSON shape and
+// managed-command matching must stay identical to the local install.
+//
+// See docs/design/agent-status-over-ssh.md §8 (commit #8).
+
+import { randomUUID } from 'crypto'
+import type { SFTPWrapper, FileEntryWithStats } from 'ssh2'
+
+import { isPlainObject, type HooksConfig } from './installer-utils'
+
+const DEFAULT_REMOTE_CONFIG_MODE = 0o600
+
+/** Read+JSON-parse a remote file. Returns `null` on parse failure (caller
+ * surfaces "could not parse" status to the UI), `{}` on missing file
+ * (matches local behavior — first-install case). Rethrows on other I/O
+ * failures (permission denied, EIO, channel closed) so the caller can
+ * distinguish transient SFTP errors from a malformed-JSON case rather
+ * than collapsing both into a misleading "could not parse" diagnostic. */
+export async function readHooksJsonRemote(
+ sftp: SFTPWrapper,
+ remotePath: string
+): Promise {
+ let body: string
+ try {
+ body = await readFile(sftp, remotePath)
+ } catch (err) {
+ if (isNoEntryError(err)) {
+ return {}
+ }
+ throw err
+ }
+ try {
+ const parsed = JSON.parse(body)
+ return isPlainObject(parsed) ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+/** Atomically write a JSON config to the remote — write to a tmp path then
+ * rename, mirroring the local writeHooksJson contract. The .bak rotation is
+ * intentionally NOT carried over: the remote file is the user's, and a
+ * per-target backup convention belongs alongside the remote installer UI
+ * (out of scope for this commit). */
+export async function writeHooksJsonRemote(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ config: HooksConfig
+): Promise {
+ const dir = dirnamePosix(remotePath)
+ await mkdirpRemote(sftp, dir)
+ const serialized = `${JSON.stringify(config, null, 2)}\n`
+ // Why: skip the write when on-disk content is identical so repeated
+ // install() calls do not bump the file's mtime / inode unnecessarily.
+ try {
+ const existing = await readFile(sftp, remotePath)
+ if (existing === serialized) {
+ return
+ }
+ } catch {
+ // ENOENT or read error — fall through to the write below.
+ }
+ // Why: tmp + rename so a partial network drop mid-write does not leave a
+ // truncated settings.json that the agent CLI would refuse to load.
+ const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
+ try {
+ const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
+ await writeFile(sftp, tmp, serialized, mode)
+ await chmod(sftp, tmp, mode)
+ await rename(sftp, tmp, remotePath)
+ } finally {
+ // Best-effort cleanup if rename failed.
+ try {
+ await unlink(sftp, tmp)
+ } catch {
+ // already gone or never created
+ }
+ }
+}
+
+/** Write the managed hook script to the remote and chmod 0o755. POSIX-only —
+ * the relay deliberately does not support Windows-remote in v1 (see design
+ * doc §3 + §6). */
+export async function writeManagedScriptRemote(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ content: string
+): Promise {
+ const dir = dirnamePosix(remotePath)
+ await mkdirpRemote(sftp, dir)
+ try {
+ const existing = await readFile(sftp, remotePath)
+ if (existing === content) {
+ await chmod(sftp, remotePath, 0o755)
+ return
+ }
+ } catch {
+ // ENOENT or read error — fall through to the atomic write below.
+ }
+
+ // Why: existing configs may already invoke this script. Write/chmod a temp
+ // file first, then rename it into place so interrupted reinstalls do not
+ // leave the configured hook path truncated or non-executable.
+ const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
+ try {
+ await writeFile(sftp, tmp, content, 0o755)
+ await chmod(sftp, tmp, 0o755)
+ await rename(sftp, tmp, remotePath)
+ } finally {
+ try {
+ await unlink(sftp, tmp)
+ } catch {
+ // already gone or never created
+ }
+ }
+}
+
+export async function readTextFileRemote(
+ sftp: SFTPWrapper,
+ remotePath: string
+): Promise {
+ try {
+ return await readFile(sftp, remotePath)
+ } catch (err) {
+ if (isNoEntryError(err)) {
+ return null
+ }
+ throw err
+ }
+}
+
+export async function writeTextFileRemoteAtomic(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ content: string
+): Promise {
+ const dir = dirnamePosix(remotePath)
+ await mkdirpRemote(sftp, dir)
+ try {
+ const existing = await readFile(sftp, remotePath)
+ if (existing === content) {
+ return
+ }
+ } catch {
+ // ENOENT or read error — fall through to the atomic write below.
+ }
+
+ const tmp = `${dir}/.${Date.now()}-${randomUUID()}.tmp`
+ try {
+ const mode = await getRemoteFileModeOrDefault(sftp, remotePath, DEFAULT_REMOTE_CONFIG_MODE)
+ await writeFile(sftp, tmp, content, mode)
+ await chmod(sftp, tmp, mode)
+ await rename(sftp, tmp, remotePath)
+ } finally {
+ try {
+ await unlink(sftp, tmp)
+ } catch {
+ // already gone or never created
+ }
+ }
+}
+
+// ─── Private SFTP primitives ────────────────────────────────────────
+
+async function readFile(sftp: SFTPWrapper, remotePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.readFile(remotePath, 'utf8', (err, data) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve(typeof data === 'string' ? data : data.toString('utf8'))
+ })
+ })
+}
+
+async function writeFile(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ content: string,
+ mode?: number
+): Promise {
+ return new Promise((resolve, reject) => {
+ const options =
+ mode === undefined ? { encoding: 'utf8' as const } : { encoding: 'utf8' as const, mode }
+ sftp.writeFile(remotePath, content, options, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function statMode(sftp: SFTPWrapper, remotePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.stat(remotePath, (err, stats) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve(stats.mode & 0o7777)
+ })
+ })
+}
+
+async function getRemoteFileModeOrDefault(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ defaultMode: number
+): Promise {
+ try {
+ return await statMode(sftp, remotePath)
+ } catch (err) {
+ if (isNoEntryError(err)) {
+ return defaultMode
+ }
+ throw err
+ }
+}
+
+async function rename(sftp: SFTPWrapper, src: string, dst: string): Promise {
+ if (typeof sftp.ext_openssh_rename === 'function') {
+ try {
+ await renameOpenSsh(sftp, src, dst)
+ return
+ } catch (err) {
+ if (!isUnsupportedExtensionError(err)) {
+ throw err
+ }
+ }
+ }
+
+ // Why: servers without OpenSSH overwrite-rename cannot safely replace an
+ // existing live config path. Renaming dst aside would leave settings.json
+ // missing if the SFTP channel dies before src is moved into place, so fail
+ // closed and keep the existing file intact.
+ await renamePlain(sftp, src, dst)
+}
+
+async function renamePlain(sftp: SFTPWrapper, src: string, dst: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.rename(src, dst, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function renameOpenSsh(sftp: SFTPWrapper, src: string, dst: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.ext_openssh_rename(src, dst, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function unlink(sftp: SFTPWrapper, remotePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.unlink(remotePath, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function chmod(sftp: SFTPWrapper, remotePath: string, mode: number): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.chmod(remotePath, mode, (err) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function readdir(sftp: SFTPWrapper, remotePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.readdir(remotePath, (err, list) => {
+ if (err) {
+ reject(err)
+ return
+ }
+ resolve(list)
+ })
+ })
+}
+
+async function mkdir(sftp: SFTPWrapper, remotePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.mkdir(remotePath, (err) => {
+ if (err) {
+ // SSH_FX_FAILURE (4) often means "already exists" on OpenSSH; we
+ // probe with stat afterwards rather than parse the error code.
+ reject(err)
+ return
+ }
+ resolve()
+ })
+ })
+}
+
+async function mkdirpRemote(sftp: SFTPWrapper, remotePath: string): Promise {
+ if (remotePath === '/' || remotePath === '' || remotePath === '.') {
+ return
+ }
+ // Why: walk the path top-down rather than bottom-up so an existing parent
+ // chain doesn't cost a full readdir per segment. POSIX-only — Windows-
+ // remote is out of scope for v1.
+ const segments = remotePath.split('/').filter((s) => s.length > 0)
+ let current = remotePath.startsWith('/') ? '' : '.'
+ for (const seg of segments) {
+ current = current === '' ? `/${seg}` : current === '.' ? seg : `${current}/${seg}`
+ try {
+ await readdir(sftp, current)
+ } catch {
+ try {
+ await mkdir(sftp, current)
+ } catch (err) {
+ // Why: re-raise only when the dir really isn't there. SSH_FX_FAILURE
+ // on a concurrent mkdir from another client is harmless — readdir on
+ // the next iteration will succeed.
+ if (!isAlreadyExistsError(err)) {
+ throw err
+ }
+ }
+ }
+ }
+}
+
+function dirnamePosix(p: string): string {
+ const idx = p.lastIndexOf('/')
+ if (idx <= 0) {
+ return idx === 0 ? '/' : '.'
+ }
+ return p.slice(0, idx)
+}
+
+function isNoEntryError(err: unknown): boolean {
+ if (!err || typeof err !== 'object') {
+ return false
+ }
+ // ssh2 surfaces SFTP errors with `code === 2` (SSH_FX_NO_SUCH_FILE).
+ return (err as { code?: unknown }).code === 2
+}
+
+function isAlreadyExistsError(err: unknown): boolean {
+ if (!err || typeof err !== 'object') {
+ return false
+ }
+ // SSH_FX_FAILURE (4) is OpenSSH's catch-all for "exists" alongside other
+ // mkdir failures; we accept the ambiguity and let the next readdir prove
+ // success.
+ return (err as { code?: unknown }).code === 4
+}
+
+function isUnsupportedExtensionError(err: unknown): boolean {
+ if (!err || typeof err !== 'object') {
+ return false
+ }
+ const code = (err as { code?: unknown }).code
+ const message = (err as { message?: unknown }).message
+ return code === 8 || (typeof message === 'string' && /unsupported/i.test(message))
+}
diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts
new file mode 100644
index 00000000000..95db6b6ba95
--- /dev/null
+++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts
@@ -0,0 +1,224 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { SFTPWrapper } from 'ssh2'
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => '/tmp/orca-user-data'
+ }
+}))
+
+import { CodexHookService } from '../codex/hook-service'
+import { CursorHookService } from '../cursor/hook-service'
+import { GeminiHookService } from '../gemini/hook-service'
+import { ClaudeHookService } from '../claude/hook-service'
+
+type FakeFs = {
+ files: Map
+ dirs: Set
+ modes: Map
+ failRenameTo: Set
+}
+
+function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
+ const fs: FakeFs = {
+ files: new Map(),
+ dirs: new Set(['/']),
+ modes: new Map(),
+ failRenameTo: new Set()
+ }
+ const noEntryError = (path: string): { code: number; message: string } => ({
+ code: 2,
+ message: `ENOENT ${path}`
+ })
+ const fakeStats = (mode: number): { mode: number } => ({ mode })
+
+ const sftp = {
+ readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
+ const v = fs.files.get(path)
+ if (v === undefined) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, v)
+ },
+ writeFile: (
+ path: string,
+ content: string,
+ options: string | { mode?: number },
+ cb: (err: unknown) => void
+ ): void => {
+ fs.files.set(path, content)
+ if (typeof options !== 'string' && options.mode !== undefined) {
+ fs.modes.set(path, options.mode)
+ }
+ cb(null)
+ },
+ rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
+ if (fs.failRenameTo.has(dst)) {
+ cb({ code: 4, message: `rename failed ${dst}` })
+ return
+ }
+ const v = fs.files.get(src)
+ if (v === undefined) {
+ cb(noEntryError(src))
+ return
+ }
+ fs.files.set(dst, v)
+ fs.files.delete(src)
+ const mode = fs.modes.get(src)
+ if (mode !== undefined) {
+ fs.modes.set(dst, mode)
+ fs.modes.delete(src)
+ }
+ cb(null)
+ },
+ unlink: (path: string, cb: (err: unknown) => void): void => {
+ fs.files.delete(path)
+ fs.modes.delete(path)
+ cb(null)
+ },
+ chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
+ fs.modes.set(path, mode)
+ cb(null)
+ },
+ stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
+ if (!fs.files.has(path)) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
+ },
+ readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
+ if (fs.dirs.has(path)) {
+ cb(null, [])
+ return
+ }
+ cb(noEntryError(path))
+ },
+ mkdir: (path: string, cb: (err: unknown) => void): void => {
+ fs.dirs.add(path)
+ cb(null)
+ }
+ } as unknown as SFTPWrapper
+ return { sftp, fs }
+}
+
+describe('remote hook service installers', () => {
+ it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => {
+ const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
+ Object.defineProperty(process, 'platform', { value: 'win32' })
+ try {
+ const installers = [
+ {
+ path: '/home/dev/.orca/agent-hooks/claude-hook.sh',
+ install: (sftp: SFTPWrapper) => new ClaudeHookService().installRemote(sftp, '/home/dev')
+ },
+ {
+ path: '/home/dev/.orca/agent-hooks/codex-hook.sh',
+ install: (sftp: SFTPWrapper) => new CodexHookService().installRemote(sftp, '/home/dev')
+ },
+ {
+ path: '/home/dev/.orca/agent-hooks/gemini-hook.sh',
+ install: (sftp: SFTPWrapper) => new GeminiHookService().installRemote(sftp, '/home/dev')
+ },
+ {
+ path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
+ install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
+ }
+ ]
+
+ for (const { install, path } of installers) {
+ const { sftp, fs } = createFakeSftp()
+ const status = await install(sftp)
+ expect(status.state).toBe('installed')
+ const script = fs.files.get(path)
+ expect(script).toMatch(/^#!\/bin\/sh\n/)
+ expect(script).not.toContain('@echo off')
+ expect(script).not.toContain('powershell -NoProfile')
+ }
+ } finally {
+ if (originalPlatform) {
+ Object.defineProperty(process, 'platform', originalPlatform)
+ }
+ }
+ })
+
+ it('installs remote Codex hooks with matching trust entries', async () => {
+ const { sftp, fs } = createFakeSftp()
+
+ const status = await new CodexHookService().installRemote(sftp, '/home/dev/')
+
+ expect(status.state).toBe('installed')
+ expect(status.configPath).toBe('/home/dev/.codex/hooks.json')
+ const hooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as {
+ hooks: Record
+ }
+ for (const eventName of [
+ 'SessionStart',
+ 'UserPromptSubmit',
+ 'PreToolUse',
+ 'PermissionRequest',
+ 'PostToolUse',
+ 'Stop'
+ ]) {
+ const command = hooks.hooks[eventName]?.[0]?.hooks?.[0]?.command
+ expect(command).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh')
+ expect(command).toMatch(/^if \[ -x /)
+ }
+ expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
+ expect(fs.modes.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toBe(0o755)
+ const toml = fs.files.get('/home/dev/.codex/config.toml')
+ expect(toml).toContain('/home/dev/.codex/hooks.json:permission_request:0:0')
+ expect(toml).toContain('trusted_hash = "sha256:')
+ })
+
+ it('reports Codex trust-write failures without rolling back installed hooks', async () => {
+ const { sftp, fs } = createFakeSftp()
+ fs.failRenameTo.add('/home/dev/.codex/config.toml')
+
+ const status = await new CodexHookService().installRemote(sftp, '/home/dev')
+
+ expect(status.state).toBe('error')
+ expect(status.managedHooksPresent).toBe(true)
+ expect(status.detail).toContain('trust entries could not be written')
+ expect(fs.files.get('/home/dev/.codex/hooks.json')).toContain('codex-hook.sh')
+ expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
+ })
+
+ it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
+ const gemini = createFakeSftp()
+ const cursor = createFakeSftp()
+
+ await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
+ await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
+
+ const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
+ hooks: Record
+ }
+ for (const eventName of ['BeforeAgent', 'AfterAgent', 'AfterTool', 'PreToolUse']) {
+ const command = geminiConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
+ expect(command).toContain('/home/dev/.orca/agent-hooks/gemini-hook.sh')
+ expect(command).toMatch(/^if \[ -x /)
+ }
+
+ const cursorConfig = JSON.parse(cursor.fs.files.get('/home/dev/.cursor/hooks.json')!) as {
+ version: number
+ hooks: Record
+ }
+ expect(cursorConfig.version).toBe(1)
+ for (const eventName of [
+ 'beforeSubmitPrompt',
+ 'stop',
+ 'preToolUse',
+ 'postToolUse',
+ 'postToolUseFailure',
+ 'beforeShellExecution',
+ 'beforeMCPExecution',
+ 'afterAgentResponse'
+ ]) {
+ const definition = cursorConfig.hooks[eventName]?.[0]
+ expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
+ expect(definition?.hooks).toBeUndefined()
+ }
+ })
+})
diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts
index 241a0b2e8b1..cd715bcc598 100644
--- a/src/main/agent-hooks/server.ts
+++ b/src/main/agent-hooks/server.ts
@@ -709,6 +709,14 @@ export class AgentHookServer {
}
}
}
+
+ /** Test-only accessor for the per-instance listener state. The `_internals`
+ * shim needs to reach this without exposing `state` on the public surface
+ * to renderer/main callers. AGENTS.md disallows `as unknown as X` escapes,
+ * so we expose a narrow getter rather than casting the private field. */
+ _getStateForTests(): HookListenerState {
+ return this.state
+ }
}
export const agentHookServer = new AgentHookServer()
@@ -722,19 +730,9 @@ export const _internals = {
body: unknown,
expectedEnv: string
): AgentHookEventPayload | null =>
- normalizeHookPayload(_singletonState(), source, body, expectedEnv),
+ normalizeHookPayload(agentHookServer._getStateForTests(), source, body, expectedEnv),
parseFormEncodedBody,
resetCachesForTests: (): void => {
- clearAllListenerCaches(_singletonState())
+ clearAllListenerCaches(agentHookServer._getStateForTests())
}
}
-
-// Why: ergonomic accessor so the `_internals` shim can reach the singleton's
-// per-instance state without exposing `state` on the public class surface.
-function _singletonState(): HookListenerState {
- // The runtime field is private, but tests access this module exclusively
- // through `_internals`, which only fires after the module-level
- // `agentHookServer` is constructed. The cast keeps the compile-time
- // private invariant intact.
- return (agentHookServer as unknown as { state: HookListenerState }).state
-}
diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts
new file mode 100644
index 00000000000..00bfdc5cbe1
--- /dev/null
+++ b/src/main/claude/hook-service.test.ts
@@ -0,0 +1,166 @@
+// Why: locks in the remote-install contract so a refactor cannot silently
+// drift the produced settings.json shape, the wrapper-quoted command path,
+// or the script body that lands on the remote box. Local install behavior
+// is exercised through `installer-utils.test.ts` and the per-CLI status
+// audit; this file covers ONLY the SFTP-backed path added in commit #8.
+import { vi, describe, expect, it } from 'vitest'
+
+vi.mock('electron', () => ({
+ app: {
+ getPath: () => '/tmp/userData'
+ }
+}))
+
+import type { SFTPWrapper } from 'ssh2'
+import { ClaudeHookService } from './hook-service'
+
+type FakeFs = {
+ files: Map
+ dirs: Set
+ modes: Map
+}
+
+function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
+ const fs: FakeFs = {
+ files: new Map(),
+ dirs: new Set(['/']),
+ modes: new Map()
+ }
+ const noEntryError = (path: string): { code: number; message: string } => ({
+ code: 2,
+ message: `ENOENT ${path}`
+ })
+ const fakeStats = (mode: number): { mode: number } => ({ mode })
+ const sftp = {
+ readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => {
+ const v = fs.files.get(path)
+ if (v === undefined) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, v)
+ },
+ writeFile: (
+ path: string,
+ content: string,
+ options: string | { mode?: number },
+ cb: (err: unknown) => void
+ ): void => {
+ fs.files.set(path, content)
+ if (typeof options !== 'string' && options.mode !== undefined) {
+ fs.modes.set(path, options.mode)
+ }
+ cb(null)
+ },
+ rename: (src: string, dst: string, cb: (err: unknown) => void): void => {
+ const v = fs.files.get(src)
+ if (v === undefined) {
+ cb(noEntryError(src))
+ return
+ }
+ fs.files.set(dst, v)
+ fs.files.delete(src)
+ const mode = fs.modes.get(src)
+ if (mode !== undefined) {
+ fs.modes.set(dst, mode)
+ fs.modes.delete(src)
+ }
+ cb(null)
+ },
+ unlink: (path: string, cb: (err: unknown) => void): void => {
+ fs.files.delete(path)
+ fs.modes.delete(path)
+ cb(null)
+ },
+ chmod: (path: string, mode: number, cb: (err: unknown) => void): void => {
+ fs.modes.set(path, mode)
+ cb(null)
+ },
+ stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => {
+ if (!fs.files.has(path)) {
+ cb(noEntryError(path))
+ return
+ }
+ cb(null, fakeStats(fs.modes.get(path) ?? 0o100644))
+ },
+ readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => {
+ if (fs.dirs.has(path)) {
+ cb(null, [])
+ return
+ }
+ cb(noEntryError(path))
+ },
+ mkdir: (path: string, cb: (err: unknown) => void): void => {
+ fs.dirs.add(path)
+ cb(null)
+ }
+ } as unknown as SFTPWrapper
+ return { sftp, fs }
+}
+
+describe('ClaudeHookService.installRemote', () => {
+ it('writes settings.json + managed script under the remote $HOME', async () => {
+ const svc = new ClaudeHookService()
+ const { sftp, fs } = createFakeSftp()
+ const status = await svc.installRemote(sftp, '/home/dev')
+ expect(status.state).toBe('installed')
+ expect(status.configPath).toBe('/home/dev/.claude/settings.json')
+ const settings = fs.files.get('/home/dev/.claude/settings.json')
+ expect(settings).toBeTruthy()
+ const parsed = JSON.parse(settings!)
+ // Why: every load-bearing event must be present and point at the
+ // remote-shaped script path with the `if [ -x ... ]; then ... fi`
+ // wrapper applied. Drift in any of these is a real bug — Claude
+ // Code rejects unknown shapes silently and the agent-hooks pipeline
+ // goes dark.
+ for (const event of [
+ 'UserPromptSubmit',
+ 'Stop',
+ 'PreToolUse',
+ 'PostToolUse',
+ 'PostToolUseFailure',
+ 'PermissionRequest'
+ ]) {
+ expect(parsed.hooks[event]).toBeTruthy()
+ const cmd = parsed.hooks[event][0].hooks[0].command as string
+ expect(cmd).toContain('/home/dev/.orca/agent-hooks/claude-hook.sh')
+ expect(cmd).toMatch(/^if \[ -x /)
+ }
+ // Managed script body
+ expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh')
+ expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
+ })
+
+ it('reports parse error when remote settings.json is malformed', async () => {
+ const svc = new ClaudeHookService()
+ const { sftp, fs } = createFakeSftp()
+ fs.files.set('/home/dev/.claude/settings.json', 'not json')
+ const status = await svc.installRemote(sftp, '/home/dev')
+ expect(status.state).toBe('error')
+ expect(status.detail).toContain('Could not parse')
+ })
+
+ it('preserves user-authored hook entries on a fresh install', async () => {
+ const svc = new ClaudeHookService()
+ const { sftp, fs } = createFakeSftp()
+ fs.files.set(
+ '/home/dev/.claude/settings.json',
+ JSON.stringify({
+ hooks: {
+ Stop: [
+ {
+ hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }]
+ }
+ ]
+ }
+ })
+ )
+ await svc.installRemote(sftp, '/home/dev')
+ const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!)
+ // Original user-authored entry survives alongside the new managed entry.
+ const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[]
+ const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command))
+ expect(userCmds).toContain('/usr/local/bin/my-user-hook')
+ expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true)
+ })
+})
diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts
index f2caf488104..d1d31db4438 100644
--- a/src/main/claude/hook-service.ts
+++ b/src/main/claude/hook-service.ts
@@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
+import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
+import {
+ readHooksJsonRemote,
+ writeHooksJsonRemote,
+ writeManagedScriptRemote
+} from '../agent-hooks/installer-utils-remote'
const CLAUDE_EVENTS = [
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
@@ -61,8 +67,8 @@ function getManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
-function getManagedScript(): string {
- if (process.platform === 'win32') {
+function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+ if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@@ -215,6 +221,83 @@ export class ClaudeHookService {
return this.getStatus()
}
+ // Why: install Orca's managed Claude hooks on the remote box rather than
+ // the local Mac/Linux machine. Caller passes the user's SFTP handle from
+ // the SshConnection plus the resolved remote `$HOME` (used to compute
+ // ~/.claude/settings.json on the target). POSIX-only by design — see
+ // docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred).
+ async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise {
+ // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths
+ // (`~/.claude/settings.json`) and a `.sh` managed script body. The remote
+ // platform is gated by the relay's capability RPC at a higher layer; we
+ // cannot detect it from `process.platform` here (that's the local box).
+ const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json`
+ const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh`
+ // Why: SFTP reads/writes fail far more often than local fs (network drops,
+ // EACCES on remote dirs, disk full, channel closed). Wrap the entire
+ // install flow in try/catch so a transient I/O failure surfaces as a
+ // structured `state: 'error'` result for the UI, not an unstructured
+ // rejection the caller has to remember to handle. A `null` config
+ // specifically means "file present but unparseable" — keep that branch
+ // distinct so the user sees an actionable message.
+ try {
+ const config = await readHooksJsonRemote(sftp, remoteConfigPath)
+ if (!config) {
+ return {
+ agent: 'claude',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: 'Could not parse remote Claude settings.json'
+ }
+ }
+
+ // Why: the POSIX wrapper is identical regardless of where the script
+ // lands; only the path differs. Reuse the same wrapper helper.
+ const command = wrapPosixHookCommand(remoteScriptPath)
+ const nextHooks = { ...config.hooks }
+ const isManagedCommand = createManagedCommandMatcher('claude-hook.sh')
+
+ for (const event of CLAUDE_EVENTS) {
+ const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
+ const cleaned = removeManagedCommands(current, isManagedCommand)
+ const definition: HookDefinition = {
+ ...event.definition,
+ hooks: [{ type: 'command', command }]
+ }
+ nextHooks[event.eventName] = [...cleaned, definition]
+ }
+ config.hooks = nextHooks
+
+ // Why: write the script first, then the settings — settings.json
+ // referencing a missing script body would fire `command not found` on
+ // every tool call until the user re-runs install. Doing it in this
+ // order means a partial-failure mid-install at worst leaves the user
+ // with a working script no settings.json points at (a no-op), instead
+ // of broken settings.json.
+ // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
+ // running on Windows; never derive remote script syntax from local OS.
+ await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
+ await writeHooksJsonRemote(sftp, remoteConfigPath, config)
+
+ return {
+ agent: 'claude',
+ state: 'installed',
+ configPath: remoteConfigPath,
+ managedHooksPresent: true,
+ detail: null
+ }
+ } catch (err) {
+ return {
+ agent: 'claude',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: err instanceof Error ? err.message : String(err)
+ }
+ }
+ }
+
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts
index 1bd8594e8c9..179ce2572a0 100644
--- a/src/main/codex/config-toml-trust.ts
+++ b/src/main/codex/config-toml-trust.ts
@@ -190,16 +190,26 @@ export function upsertHookTrustEntries(
entries: readonly CodexTrustEntry[]
): void {
const existing = existsSync(configPath) ? readTomlFile(configPath) : ''
- let updated = existing
- for (const entry of entries) {
- updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
- }
+ const updated = upsertHookTrustEntriesInContent(existing, entries)
if (updated === existing) {
return
}
writeConfigAtomically(configPath, updated)
}
+export function upsertHookTrustEntriesInContent(
+ existingContent: string,
+ entries: readonly CodexTrustEntry[]
+): string {
+ const existing =
+ existingContent.charCodeAt(0) === 0xfeff ? existingContent.slice(1) : existingContent
+ let updated = existing
+ for (const entry of entries) {
+ updated = upsertTrustBlock(updated, computeTrustKey(entry), computeTrustedHash(entry))
+ }
+ return updated
+}
+
// Why: build the canonical block we own. The two field names mirror what
// Codex itself writes when the user approves via /hooks (HookStateToml
// fields). `enabled` is plumbed through so an existing user-set
diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts
index 6d54ff655c8..f7cada61a68 100644
--- a/src/main/codex/hook-service.ts
+++ b/src/main/codex/hook-service.ts
@@ -1,6 +1,7 @@
/* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */
import { homedir } from 'os'
import { join } from 'path'
+import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@@ -12,12 +13,20 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
+import {
+ readHooksJsonRemote,
+ readTextFileRemote,
+ writeHooksJsonRemote,
+ writeManagedScriptRemote,
+ writeTextFileRemoteAtomic
+} from '../agent-hooks/installer-utils-remote'
import {
computeTrustKey,
computeTrustedHash,
parseTrustKey,
readHookTrustEntries,
removeHookTrustEntries,
+ upsertHookTrustEntriesInContent,
upsertHookTrustEntries,
type CodexEventLabel,
type CodexHookTrustState,
@@ -71,8 +80,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
-function getManagedScript(): string {
- if (process.platform === 'win32') {
+function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+ if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@@ -329,6 +338,99 @@ export class CodexHookService {
return this.getStatus()
}
+ async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise {
+ const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.codex/hooks.json`
+ const remoteTomlPath = `${remoteHome.replace(/\/$/, '')}/.codex/config.toml`
+ const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/codex-hook.sh`
+ try {
+ const config = await readHooksJsonRemote(sftp, remoteConfigPath)
+ if (!config) {
+ return {
+ agent: 'codex',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: 'Could not parse remote Codex hooks.json'
+ }
+ }
+
+ const command = wrapPosixHookCommand(remoteScriptPath)
+ const nextHooks = { ...config.hooks }
+ const managedEvents = new Set(CODEX_EVENTS)
+ const isManagedCommand = createManagedCommandMatcher('codex-hook.sh')
+
+ for (const [eventName, definitions] of Object.entries(nextHooks)) {
+ if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
+ continue
+ }
+ const cleaned = removeManagedCommands(definitions, isManagedCommand)
+ if (cleaned.length === 0) {
+ delete nextHooks[eventName]
+ } else {
+ nextHooks[eventName] = cleaned
+ }
+ }
+
+ const trustEntries: CodexTrustEntry[] = []
+ for (const eventName of CODEX_EVENTS) {
+ const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
+ const cleaned = removeManagedCommands(current, isManagedCommand)
+ const definition: HookDefinition = {
+ hooks: [{ type: 'command', command }]
+ }
+ nextHooks[eventName] = [...cleaned, definition]
+ trustEntries.push({
+ sourcePath: remoteConfigPath,
+ eventLabel: CODEX_EVENT_LABEL[eventName],
+ groupIndex: cleaned.length,
+ handlerIndex: 0,
+ command
+ })
+ }
+
+ config.hooks = nextHooks
+ // Why: script/settings first, trust TOML last. A partial trust write
+ // leaves Codex asking for approval rather than executing a missing script.
+ // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
+ // running on Windows; never derive remote script syntax from local OS.
+ await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
+ await writeHooksJsonRemote(sftp, remoteConfigPath, config)
+ try {
+ const existingToml = (await readTextFileRemote(sftp, remoteTomlPath)) ?? ''
+ const updatedToml = upsertHookTrustEntriesInContent(existingToml, trustEntries)
+ if (updatedToml !== existingToml) {
+ await writeTextFileRemoteAtomic(sftp, remoteTomlPath, updatedToml)
+ }
+ } catch (error) {
+ return {
+ agent: 'codex',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: true,
+ detail: `Hooks installed but trust entries could not be written: ${
+ error instanceof Error ? error.message : String(error)
+ }. Run /hooks in Codex on the remote host to approve.`
+ }
+ }
+
+ return {
+ agent: 'codex',
+ state: 'installed',
+ configPath: remoteConfigPath,
+ managedHooksPresent: true,
+ detail: null
+ }
+ } catch (err) {
+ return {
+ agent: 'codex',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: err instanceof Error ? err.message : String(err)
+ }
+ }
+ }
+
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts
index 805a8d905db..ed7f7f3bd0a 100644
--- a/src/main/cursor/hook-service.ts
+++ b/src/main/cursor/hook-service.ts
@@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
+import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
+import {
+ readHooksJsonRemote,
+ writeHooksJsonRemote,
+ writeManagedScriptRemote
+} from '../agent-hooks/installer-utils-remote'
// Why: cursor-agent exposes a declarative hooks.json surface at
// ~/.cursor/hooks.json (https://cursor.com/docs/hooks) with camelCase event
@@ -55,8 +61,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
-function getManagedScript(): string {
- if (process.platform === 'win32') {
+function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+ if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@@ -235,6 +241,73 @@ export class CursorHookService {
return this.getStatus()
}
+ // Why: install Orca's managed Cursor hooks on the remote box. Mirrors
+ // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
+ // primitives, and emits Cursor's documented schema (top-level `command`
+ // on each definition + top-level `version: 1`) so cursor-agent on the
+ // remote actually invokes the script. See docs/design/agent-status-over-ssh.md
+ // §8.
+ async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise {
+ const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.cursor/hooks.json`
+ const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/cursor-hook.sh`
+ try {
+ const config = await readHooksJsonRemote(sftp, remoteConfigPath)
+ if (!config) {
+ return {
+ agent: 'cursor',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: 'Could not parse remote Cursor hooks.json'
+ }
+ }
+
+ const command = wrapPosixHookCommand(remoteScriptPath)
+ const nextHooks = { ...config.hooks }
+ const isManagedCommand = createManagedCommandMatcher('cursor-hook.sh')
+
+ for (const eventName of CURSOR_EVENTS) {
+ const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
+ // Why: same dual-shape sweep as the local install — repeated
+ // installs converge on a single managed entry.
+ const cleaned = removeManagedCommands(current, isManagedCommand).filter(
+ (definition) => !isManagedCommand(definition.command as string | undefined)
+ )
+ const definition: HookDefinition = { command }
+ nextHooks[eventName] = [...cleaned, definition]
+ }
+
+ const nextConfig: Record = { ...config, hooks: nextHooks }
+ if (nextConfig.version === undefined) {
+ nextConfig.version = 1
+ }
+
+ // Why: script-then-config order so a partial-failure mid-install at
+ // worst leaves a working script no settings.json points at — see
+ // ClaudeHookService.installRemote.
+ // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
+ // running on Windows; never derive remote script syntax from local OS.
+ await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
+ await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
+
+ return {
+ agent: 'cursor',
+ state: 'installed',
+ configPath: remoteConfigPath,
+ managedHooksPresent: true,
+ detail: null
+ }
+ } catch (err) {
+ return {
+ agent: 'cursor',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: err instanceof Error ? err.message : String(err)
+ }
+ }
+ }
+
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts
index 4cd0e401ab3..7b0d418ee71 100644
--- a/src/main/gemini/hook-service.ts
+++ b/src/main/gemini/hook-service.ts
@@ -1,5 +1,6 @@
import { homedir } from 'os'
import { join } from 'path'
+import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
@@ -11,6 +12,11 @@ import {
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
+import {
+ readHooksJsonRemote,
+ writeHooksJsonRemote,
+ writeManagedScriptRemote
+} from '../agent-hooks/installer-utils-remote'
// Why: Gemini CLI fires `BeforeAgent` when a turn starts and `AfterAgent` when
// it completes. `AfterTool` marks the resumption of model work after a tool
@@ -41,8 +47,8 @@ function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
-function getManagedScript(): string {
- if (process.platform === 'win32') {
+function getManagedScript(target: 'local' | 'posix' = 'local'): string {
+ if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
@@ -183,6 +189,65 @@ export class GeminiHookService {
return this.getStatus()
}
+ // Why: install Orca's managed Gemini hooks on the remote box. Mirrors
+ // ClaudeHookService.installRemote — POSIX-only, uses the same SFTP-backed
+ // primitives, and lays down the same script body the local install
+ // generates so a remote-side Gemini CLI behaves identically. See
+ // docs/design/agent-status-over-ssh.md §8.
+ async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise {
+ const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.gemini/settings.json`
+ const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/gemini-hook.sh`
+ try {
+ const config = await readHooksJsonRemote(sftp, remoteConfigPath)
+ if (!config) {
+ return {
+ agent: 'gemini',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: 'Could not parse remote Gemini settings.json'
+ }
+ }
+
+ const command = wrapPosixHookCommand(remoteScriptPath)
+ const nextHooks = { ...config.hooks }
+ const isManagedCommand = createManagedCommandMatcher('gemini-hook.sh')
+
+ for (const eventName of GEMINI_EVENTS) {
+ const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
+ const cleaned = removeManagedCommands(current, isManagedCommand)
+ const definition: HookDefinition = {
+ hooks: [{ type: 'command', command }]
+ }
+ nextHooks[eventName] = [...cleaned, definition]
+ }
+ config.hooks = nextHooks
+
+ // Why: write the script first so an interrupted install never leaves
+ // settings.json pointing at a missing script. See ClaudeHookService.
+ // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is
+ // running on Windows; never derive remote script syntax from local OS.
+ await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
+ await writeHooksJsonRemote(sftp, remoteConfigPath, config)
+
+ return {
+ agent: 'gemini',
+ state: 'installed',
+ configPath: remoteConfigPath,
+ managedHooksPresent: true,
+ detail: null
+ }
+ } catch (err) {
+ return {
+ agent: 'gemini',
+ state: 'error',
+ configPath: remoteConfigPath,
+ managedHooksPresent: false,
+ detail: err instanceof Error ? err.message : String(err)
+ }
+ }
+ }
+
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts
index 195685c90f2..1c5deb8d3cc 100644
--- a/src/main/ipc/pty.test.ts
+++ b/src/main/ipc/pty.test.ts
@@ -1277,6 +1277,9 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_TAB_ID).toBeUndefined()
expect(env.ORCA_WORKTREE_ID).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
+ // Why: the local hook server's userData-relative endpoint file path
+ // is meaningless on the remote box; assert it does not leak.
+ expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
@@ -1336,6 +1339,7 @@ describe('registerPtyHandlers', () => {
// relay is the source of truth for those.
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined()
+ expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
} finally {
if (prevFlag === undefined) {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts
index d529ac139b2..94e69071473 100644
--- a/src/main/pty/overlay-mirror.ts
+++ b/src/main/pty/overlay-mirror.ts
@@ -1,8 +1,8 @@
// Why: Pi (PI_CODING_AGENT_DIR) and OpenCode (OPENCODE_CONFIG_DIR) both inject
// Orca-owned files into per-PTY overlay directories that mirror a user-owned
-// source dir via symlinks/junctions. The safety guarantees here — never
+// source dir via symlinks/junctions. The safety guarantees here -- never
// descend into a symlink/junction during teardown, refuse to operate outside
-// the overlay root, lstat-not-stat to avoid following links — are the result
+// the overlay root, lstat-not-stat to avoid following links -- are the result
// of debugging issue #1083 (Windows directory junctions causing fs.rmSync to
// delete the user's real Pi state). Shared in one module so a new overlay
// consumer cannot accidentally diverge from the audited cleanup behavior.
@@ -42,7 +42,7 @@ export function mirrorEntry(sourcePath: string, targetPath: string): void {
// Exported for tests. A "descend candidate" is an entry whose children we
// should recurse into when tearing down the overlay. Anything that is a
// symlink (including a Windows directory junction) must NOT be a candidate
-// even if it also reports isDirectory() — following it would walk into the
+// even if it also reports isDirectory() -- following it would walk into the
// link target and delete user data, which is the bug in #1083.
export function isSafeDescendCandidate(stats: {
isSymbolicLink(): boolean
@@ -56,7 +56,7 @@ export function isSafeDescendCandidate(stats: {
// Why: the overlay tree contains symlinks/junctions that point back into the
// user's real state dir. fs.rmSync with { recursive: true } has repeatedly
-// regressed on Windows when walking NTFS junctions — it can follow them and
+// regressed on Windows when walking NTFS junctions -- it can follow them and
// delete the *target*, destroying the user's data. Never descend into a
// symlink/junction here: for any non-real-directory entry we unlink the link
// itself; only entries that are truly directories on disk are recursed into.
@@ -70,7 +70,7 @@ export function safeRemoveTree(path: string): void {
// On Windows, lstat on a directory junction can report BOTH
// isSymbolicLink() === true AND isDirectory() === true, so we MUST check
- // isSymbolicLink first — otherwise a junction enters the recursive branch
+ // isSymbolicLink first -- otherwise a junction enters the recursive branch
// and readdirSync enumerates the link's target, the exact bug in #1083.
if (!isSafeDescendCandidate(stat)) {
try {
@@ -98,7 +98,7 @@ export function safeRemoveTree(path: string): void {
try {
unlinkSync(child)
} catch {
- // best-effort, see above
+ // Best-effort, see above.
}
}
diff --git a/src/main/pty/shell-startup-env.test.ts b/src/main/pty/shell-startup-env.test.ts
index 91418fd0809..d41e6c7fdcb 100644
--- a/src/main/pty/shell-startup-env.test.ts
+++ b/src/main/pty/shell-startup-env.test.ts
@@ -34,13 +34,17 @@ describe('readShellStartupEnvVar', () => {
})
function mockStartupFiles(files: Record) {
+ const hasAbsoluteKeys = Object.keys(files).some((path) => path.startsWith('/'))
existsSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
- return file in files
+ return p in files || (!hasAbsoluteKeys && file in files)
})
readFileSyncMock.mockImplementation((p: string) => {
const file = p.split('/').pop() ?? ''
- if (file in files) {
+ if (p in files) {
+ return files[p]
+ }
+ if (!hasAbsoluteKeys && file in files) {
return files[file]
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
@@ -96,6 +100,17 @@ describe('readShellStartupEnvVar', () => {
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/newest/zlogin')
})
+ it('uses ZDOTDIR exported from .zshenv for later zsh startup files', () => {
+ mockStartupFiles({
+ '/home/alice/.zshenv': 'export ZDOTDIR="$HOME/.config/zsh"\n',
+ '/home/alice/.config/zsh/.zshrc': 'export OPENCODE_CONFIG_DIR="$HOME/company/opencode"\n'
+ })
+
+ expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice', '/bin/zsh')).toBe(
+ '/home/alice/company/opencode'
+ )
+ })
+
it('handles double-quoted values', () => {
mockStartupFiles({ '.zshrc': 'export OPENCODE_CONFIG_DIR="/quoted/path"\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBe('/quoted/path')
diff --git a/src/main/pty/shell-startup-env.ts b/src/main/pty/shell-startup-env.ts
index fea07dd0f7f..96ba85aae6d 100644
--- a/src/main/pty/shell-startup-env.ts
+++ b/src/main/pty/shell-startup-env.ts
@@ -4,25 +4,62 @@ import { basename, join } from 'path'
// Why: only files the user's actual shell would source. Mixing zsh and bash
// files breaks the "last assignment wins matches the live shell" guarantee —
// a stale .bash_profile on a zsh user would clobber the real .zshrc value.
-const ZSH_FILES = ['.zshenv', '.zprofile', '.zshrc', '.zlogin']
+const ZSH_ENV_FILE = '.zshenv'
+const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin']
// Why: Orca launches bash as a login shell (see local-pty-shell-ready.ts
// getBashShellReadyRcfileContent and daemon/shell-ready.ts) which sources
// .bash_profile / .bash_login / .profile but intentionally does NOT force
// .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees.
const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile']
-function shellStartupFiles(shell: string | undefined): readonly string[] {
+function parseExportedValue(content: string, name: string, home: string): string | undefined {
+ const assignment = new RegExp(`^export\\s+${name}=(.+)$`)
+ let lastMatch: string | undefined
+
+ for (const rawLine of content.split(/\r?\n/)) {
+ const line = rawLine.trim()
+ const match = assignment.exec(line)
+ if (!match?.[1]) {
+ continue
+ }
+ // Why: strip trailing unquoted `# comment` first so quoted values like
+ // `"$HOME/.opencode" # note` survive intact for unquoteShellValue.
+ const decommented = stripTrailingComment(match[1])
+ const { text, quoted } = unquoteShellValue(decommented)
+ // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would
+ // do for double-quoted and unquoted values; single-quoted is literal.
+ const expanded = quoted === "'" ? text : expandHome(text, home)
+ if (expanded.length > 0) {
+ lastMatch = expanded
+ }
+ }
+
+ return lastMatch
+}
+
+function readStartupFile(path: string): string | null {
+ if (!existsSync(path)) {
+ return null
+ }
+ try {
+ return readFileSync(path, 'utf8')
+ } catch {
+ return null
+ }
+}
+
+function shellStartupFilePaths(home: string, shell: string | undefined): readonly string[] {
if (!shell) {
// Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset.
- return ZSH_FILES
+ return zshStartupFilePaths(home)
}
const name = basename(shell).toLowerCase()
if (name === 'zsh') {
- return ZSH_FILES
+ return zshStartupFilePaths(home)
}
if (name === 'bash') {
- return BASH_LOGIN_FILES
+ return BASH_LOGIN_FILES.map((file) => join(home, file))
}
// Why: unsupported explicit shells (fish, nushell, custom wrappers) do not
// use Orca's zsh/bash shell-ready startup files, so scanning those files
@@ -30,6 +67,16 @@ function shellStartupFiles(shell: string | undefined): readonly string[] {
return []
}
+function zshStartupFilePaths(home: string): readonly string[] {
+ const zshEnvPath = join(home, ZSH_ENV_FILE)
+ const zshEnv = readStartupFile(zshEnvPath)
+ // Why: zsh sources ~/.zshenv first, then uses any ZDOTDIR exported there
+ // for .zprofile/.zshrc/.zlogin. Mirror that enough for static env discovery
+ // so users who keep zsh config in ~/.config/zsh do not lose overlay sources.
+ const zshDir = zshEnv ? (parseExportedValue(zshEnv, 'ZDOTDIR', home) ?? home) : home
+ return [zshEnvPath, ...ZSH_AFTER_ENV_FILES.map((file) => join(zshDir, file))]
+}
+
function unquoteShellValue(value: string): { text: string; quoted: '"' | "'" | null } {
const trimmed = value.trim()
if (trimmed.length >= 2) {
@@ -118,38 +165,17 @@ export function readShellStartupEnvVar(
return cache.get(cacheKey)
}
- const assignment = new RegExp(`^export\\s+${name}=(.+)$`)
let lastMatch: string | undefined
- for (const file of shellStartupFiles(shell)) {
- const path = join(home, file)
- if (!existsSync(path)) {
+ for (const path of shellStartupFilePaths(home, shell)) {
+ const content = readStartupFile(path)
+ if (content === null) {
continue
}
- let content
- try {
- content = readFileSync(path, 'utf8')
- } catch {
- continue
- }
-
- for (const rawLine of content.split(/\r?\n/)) {
- const line = rawLine.trim()
- const match = assignment.exec(line)
- if (!match?.[1]) {
- continue
- }
- // Why: strip trailing unquoted `# comment` first so quoted values like
- // `"$HOME/.opencode" # note` survive intact for unquoteShellValue.
- const decommented = stripTrailingComment(match[1])
- const { text, quoted } = unquoteShellValue(decommented)
- // Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would
- // do for double-quoted and unquoted values; single-quoted is literal.
- const expanded = quoted === "'" ? text : expandHome(text, home)
- if (expanded.length > 0) {
- lastMatch = expanded
- }
+ const match = parseExportedValue(content, name, home)
+ if (match !== undefined) {
+ lastMatch = match
}
}
diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts
index 48c248480e3..52660c51955 100644
--- a/src/main/ssh/ssh-relay-session.test.ts
+++ b/src/main/ssh/ssh-relay-session.test.ts
@@ -6,6 +6,11 @@ import { SshRelaySession } from './ssh-relay-session'
import type { SshConnection } from './ssh-connection'
import type { Store } from '../persistence'
import type { SshPortForwardManager } from './ssh-port-forward'
+import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay'
+
+const { muxRequestMock } = vi.hoisted(() => ({
+ muxRequestMock: vi.fn()
+}))
vi.mock('./ssh-relay-deploy', () => ({
deployAndLaunchRelay: vi.fn()
@@ -15,7 +20,7 @@ vi.mock('./ssh-channel-multiplexer', () => {
return {
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
- request = vi.fn().mockResolvedValue([])
+ request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
@@ -119,6 +124,9 @@ function mockDeploySuccess() {
describe('SshRelaySession', () => {
beforeEach(() => {
vi.clearAllMocks()
+ delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
+ muxRequestMock.mockReset()
+ muxRequestMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
})
@@ -143,6 +151,63 @@ describe('SshRelaySession', () => {
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
+ it('syncs relay-owned plugin assets before registering the SSH PTY provider', async () => {
+ process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
+ muxRequestMock.mockResolvedValue({ ok: true })
+ const sftp = { end: vi.fn() }
+ const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
+ const mockConn = {
+ sftp: vi.fn().mockResolvedValue(sftp)
+ } as unknown as SshConnection
+ const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
+
+ await session.establish(mockConn)
+
+ const installPluginsCallIndex = muxRequestMock.mock.calls.findIndex(
+ ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
+ )
+ expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0)
+ expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan(
+ vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
+ )
+ // Why: connecting to SSH may upload relay-owned plugin source, but must
+ // not mutate user-owned agent config files. Remote managed-hook install
+ // belongs behind an explicit per-host user action.
+ expect(mockConn.sftp).not.toHaveBeenCalled()
+ expect(sftp.end).not.toHaveBeenCalled()
+ })
+
+ it('does not register providers if dispose wins during initial plugin sync', async () => {
+ process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
+ let resolvePluginInstall!: () => void
+ muxRequestMock.mockImplementation(async (method: string) => {
+ if (method === AGENT_HOOK_INSTALL_PLUGINS_METHOD) {
+ return new Promise((resolve) => {
+ resolvePluginInstall = () => resolve({ ok: true })
+ })
+ }
+ return { ok: true }
+ })
+ const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
+ const mockConn = {} as SshConnection
+ const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
+
+ const establish = session.establish(mockConn)
+ await vi.waitFor(() =>
+ expect(muxRequestMock).toHaveBeenCalledWith(
+ AGENT_HOOK_INSTALL_PLUGINS_METHOD,
+ expect.anything()
+ )
+ )
+ session.dispose()
+ resolvePluginInstall()
+
+ await expect(establish).rejects.toThrow('Session disposed during establish')
+ expect(registerSshPtyProvider).not.toHaveBeenCalled()
+ expect(registerSshFilesystemProvider).not.toHaveBeenCalled()
+ expect(registerSshGitProvider).not.toHaveBeenCalled()
+ })
+
it('rejects establish when not idle', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts
index 61074ed65ea..d75807f86ce 100644
--- a/src/main/ssh/ssh-relay-session.ts
+++ b/src/main/ssh/ssh-relay-session.ts
@@ -18,10 +18,13 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import {
+ AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD,
isRemoteAgentHooksEnabled
} from '../../shared/agent-hook-relay'
+import { _internals as openCodeInternals } from '../opencode/hook-service'
+import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source'
import {
registerSshPtyProvider,
unregisterSshPtyProvider,
@@ -52,6 +55,11 @@ export class SshRelaySession {
private mux: SshChannelMultiplexer | null = null
private abortController: AbortController | null = null
private muxDisposeCleanup: (() => void) | null = null
+ // Why: store the notification-handler disposer so teardownProviders can
+ // release it on reconnect/shutdown. Symmetric with muxDisposeCleanup; while
+ // the old mux's handler array is GC'd along with the mux today, holding the
+ // disposer is cheap insurance against future code that retains the old mux.
+ private muxNotificationCleanup: (() => void) | null = null
// Why: when the relay exec channel closes but the SSH connection stays
// up, the onStateChange reconnect path never fires. This callback lets
// ssh.ts wire up relay-level reconnect from outside the session.
@@ -152,7 +160,13 @@ export class SshRelaySession {
// here fails fast so doConnect() can report the real error.
await mux.request('session.resolveHome', { path: '~' })
- await this.registerProviders(mux)
+ const registered = await this.registerProviders(mux, ownsAttempt)
+ if (!registered) {
+ if (!mux.isDisposed()) {
+ mux.dispose()
+ }
+ throw new Error('Session disposed during establish')
+ }
// Why: the mux's transport can close during registerProviders (e.g.
// the --connect bridge exits). registerRelayRoots swallows mux errors
@@ -405,6 +419,11 @@ export class SshRelaySession {
return false
}
+ await this.installPluginsOnRelay(mux)
+ if (shouldContinue && !shouldContinue()) {
+ return false
+ }
+
const ptyProvider = new SshPtyProvider(this.targetId, mux)
registerSshPtyProvider(this.targetId, ptyProvider)
@@ -419,6 +438,47 @@ export class SshRelaySession {
return true
}
+ // Why: ship the OpenCode plugin / Pi extension source bodies to the relay
+ // so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR
+ // / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent
+ // events (recent additions: cursor, pi); pinning them to the relay binary
+ // would force a relay redeploy on every Orca update. See
+ // docs/design/agent-status-over-ssh.md §4 + §8 (commit #7).
+ //
+ // Best-effort: a -32601 from an older relay (no handler installed) is
+ // swallowed; the user just doesn't get OpenCode/Pi status reporting until
+ // they upgrade. Hook-script-based agents use a separate explicit remote
+ // installer flow because that mutates user-owned agent config files.
+ private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise {
+ if (!isRemoteAgentHooksEnabled()) {
+ return
+ }
+ try {
+ await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, {
+ opencodePluginSource: openCodeInternals.getOpenCodePluginSource(),
+ piExtensionSource: getPiAgentStatusExtensionSource()
+ })
+ } catch (err) {
+ // Why: -32601 = older relay without the handler (treat as soft skip).
+ // CONNECTION_LOST / DISPOSED come from the multiplexer when it tears
+ // down mid-flight (routine on session shutdown / reconnect race) — not
+ // a real failure to surface; suppress to avoid log spam on every clean
+ // disconnect.
+ const code = (err as { code?: unknown })?.code
+ if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
+ return
+ }
+ if (mux.isDisposed()) {
+ return
+ }
+ console.warn(
+ `[ssh-relay-session] agent_hook.installPlugins failed for ${this.targetId}: ${
+ err instanceof Error ? err.message : String(err)
+ }`
+ )
+ }
+ }
+
// Why: route the relay's `agent.hook` JSON-RPC notification into Orca's
// shared `agentHookServer` via `ingestRemote`. The wire envelope carries
// `connectionId: null` (the relay does not know Orca's local handle); we
@@ -433,7 +493,13 @@ export class SshRelaySession {
if (!isRemoteAgentHooksEnabled()) {
return
}
- mux.onNotification((method, params) => {
+ // Why: capture the disposer so teardownProviders can release the
+ // notification handler symmetrically with muxDisposeCleanup. Even though
+ // the disposed mux's handler array is GC'd along with it today, retaining
+ // the disposer makes "registerProviders called twice on the same mux"
+ // safe by future-proofing against duplicate handler registration.
+ this.muxNotificationCleanup?.()
+ this.muxNotificationCleanup = mux.onNotification((method, params) => {
if (method !== AGENT_HOOK_NOTIFICATION_METHOD) {
return
}
@@ -470,10 +536,14 @@ export class SshRelaySession {
// *after* the handler is wired so the request-driven replay shape
// strictly trails our subscription on the dispatcher's single write
// callback. Best-effort: a relay that does not know the method
- // (e.g. older relay binary) returns -32601, which we swallow.
+ // (e.g. older relay binary) returns -32601; CONNECTION_LOST / DISPOSED
+ // arise from mux teardown mid-flight on routine reconnect/shutdown.
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => {
const code = (err as { code?: unknown })?.code
- if (code === -32601) {
+ if (code === -32601 || code === 'CONNECTION_LOST' || code === 'DISPOSED') {
+ return
+ }
+ if (mux.isDisposed()) {
return
}
// Why: a normal disconnect/teardown rejects the in-flight request with
@@ -493,6 +563,8 @@ export class SshRelaySession {
private teardownProviders(reason: 'shutdown' | 'connection_lost'): void {
this.muxDisposeCleanup?.()
this.muxDisposeCleanup = null
+ this.muxNotificationCleanup?.()
+ this.muxNotificationCleanup = null
if (this.mux && !this.mux.isDisposed()) {
this.mux.dispose(reason)
}
diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts
index 7865d018b54..f2fc8e5d267 100644
--- a/src/relay/agent-hook-server.ts
+++ b/src/relay/agent-hook-server.ts
@@ -106,6 +106,11 @@ export class RelayAgentHookServer {
await new Promise((resolve, reject) => {
const onStartupError = (err: Error): void => {
this.server?.off('listening', onListening)
+ // Why: null the server reference on bind failure so a subsequent
+ // start() can retry. Without this, a failed bind (e.g. EMFILE) leaves
+ // this.server populated and the early-return at the top of start()
+ // wedges the relay into a permanently broken state until stop() runs.
+ this.server = null
reject(err)
}
const onListening = (): void => {
@@ -234,9 +239,14 @@ export class RelayAgentHookServer {
}
res.writeHead(204)
res.end()
- } catch {
+ } catch (err) {
// Why: agent hooks must fail open — return success on parse / size /
// timeout errors so a buggy agent script never blocks the agent run.
+ // Log the swallowed error to stderr so future programmer bugs are not
+ // invisible (the 204 response would otherwise mask them entirely).
+ process.stderr.write(
+ `[relay-hook-server] hook request failed: ${err instanceof Error ? err.message : String(err)}\n`
+ )
res.writeHead(204)
res.end()
}
diff --git a/src/relay/plugin-overlay-env.test.ts b/src/relay/plugin-overlay-env.test.ts
new file mode 100644
index 00000000000..d23a767692b
--- /dev/null
+++ b/src/relay/plugin-overlay-env.test.ts
@@ -0,0 +1,81 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { __resetShellStartupEnvCache } from '../main/pty/shell-startup-env'
+import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
+
+describe('plugin overlay env source resolution', () => {
+ let homeDir: string
+
+ beforeEach(() => {
+ homeDir = mkdtempSync(join(tmpdir(), 'relay-plugin-overlay-env-'))
+ __resetShellStartupEnvCache()
+ })
+
+ afterEach(() => {
+ rmSync(homeDir, { recursive: true, force: true })
+ __resetShellStartupEnvCache()
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'uses zsh startup exports before inherited public overlay env',
+ () => {
+ mkdirSync(join(homeDir, 'company-opencode'), { recursive: true })
+ mkdirSync(join(homeDir, 'company-pi'), { recursive: true })
+ writeFileSync(
+ join(homeDir, '.zshrc'),
+ [
+ 'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"',
+ 'export PI_CODING_AGENT_DIR="$HOME/company-pi"'
+ ].join('\n')
+ )
+
+ const env = {
+ HOME: homeDir,
+ OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay',
+ PI_CODING_AGENT_DIR: '/tmp/inherited-pi-overlay'
+ }
+
+ expect(resolveOpenCodeSourceConfigDir(env, '/bin/zsh')).toBe(
+ join(homeDir, 'company-opencode')
+ )
+ expect(resolvePiSourceAgentDir(env, '/bin/zsh')).toBe(join(homeDir, 'company-pi'))
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'discovers overlay sources from a custom zsh ZDOTDIR',
+ () => {
+ const zshDir = join(homeDir, '.config', 'zsh')
+ mkdirSync(zshDir, { recursive: true })
+ writeFileSync(join(homeDir, '.zshenv'), 'export ZDOTDIR="$HOME/.config/zsh"\n')
+ writeFileSync(join(zshDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/opencode-src"\n')
+
+ expect(
+ resolveOpenCodeSourceConfigDir(
+ {
+ HOME: homeDir,
+ OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay'
+ },
+ '/bin/zsh'
+ )
+ ).toBe(join(homeDir, 'opencode-src'))
+ }
+ )
+
+ it('keeps explicit original-source env ahead of startup hints', () => {
+ writeFileSync(join(homeDir, '.zshrc'), 'export OPENCODE_CONFIG_DIR="$HOME/company-opencode"\n')
+
+ expect(
+ resolveOpenCodeSourceConfigDir(
+ {
+ HOME: homeDir,
+ ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/remote/original-opencode',
+ OPENCODE_CONFIG_DIR: '/tmp/inherited-opencode-overlay'
+ },
+ '/bin/zsh'
+ )
+ ).toBe('/remote/original-opencode')
+ })
+})
diff --git a/src/relay/plugin-overlay-env.ts b/src/relay/plugin-overlay-env.ts
new file mode 100644
index 00000000000..431a49430f1
--- /dev/null
+++ b/src/relay/plugin-overlay-env.ts
@@ -0,0 +1,35 @@
+import { readShellStartupEnvVar } from '../main/pty/shell-startup-env'
+
+function firstNonEmpty(...values: (string | undefined)[]): string | undefined {
+ return values.find((value) => typeof value === 'string' && value.length > 0)
+}
+
+function readStartupEnv(
+ name: string,
+ env: Record,
+ shell: string | undefined
+): string | undefined {
+ return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL)
+}
+
+export function resolveOpenCodeSourceConfigDir(
+ env: Record,
+ shell: string | undefined
+): string | undefined {
+ return firstNonEmpty(
+ env.ORCA_OPENCODE_SOURCE_CONFIG_DIR,
+ readStartupEnv('OPENCODE_CONFIG_DIR', env, shell),
+ env.OPENCODE_CONFIG_DIR
+ )
+}
+
+export function resolvePiSourceAgentDir(
+ env: Record,
+ shell: string | undefined
+): string | undefined {
+ return firstNonEmpty(
+ env.ORCA_PI_SOURCE_AGENT_DIR,
+ readStartupEnv('PI_CODING_AGENT_DIR', env, shell),
+ env.PI_CODING_AGENT_DIR
+ )
+}
diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts
new file mode 100644
index 00000000000..5d7a7f03c33
--- /dev/null
+++ b/src/relay/plugin-overlay.test.ts
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync
+} from 'fs'
+import { tmpdir } from 'os'
+import { basename, join } from 'path'
+import { PluginOverlayManager } from './plugin-overlay'
+
+describe('PluginOverlayManager', () => {
+ let homeDir: string
+ let manager: PluginOverlayManager
+
+ beforeEach(() => {
+ homeDir = mkdtempSync(join(tmpdir(), 'plugin-overlay-'))
+ manager = new PluginOverlayManager({ homeDir })
+ })
+
+ afterEach(() => {
+ rmSync(homeDir, { recursive: true, force: true })
+ })
+
+ it('reports no source until install runs', () => {
+ expect(manager.hasOpenCodeSource()).toBe(false)
+ expect(manager.hasPiSource()).toBe(false)
+ expect(manager.materializeOpenCode('tab-1:0')).toBeNull()
+ expect(manager.materializePi('tab-1:0')).toBeNull()
+ })
+
+ it('materializes OpenCode plugin into /plugins/', () => {
+ manager.setSources({ opencodePluginSource: 'export const X = 1' })
+ const dir = manager.materializeOpenCode('tab-1:0')
+ expect(dir).not.toBeNull()
+ const expected = join(dir!, 'plugins', 'orca-opencode-status.js')
+ expect(existsSync(expected)).toBe(true)
+ expect(readFileSync(expected, 'utf8')).toBe('export const X = 1')
+ })
+
+ it('mirrors a preexisting remote OpenCode config dir before adding Orca plugin', () => {
+ const userConfigDir = join(homeDir, 'company-opencode')
+ mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
+ writeFileSync(join(userConfigDir, 'opencode.json'), '{"provider":"custom"}')
+ writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin')
+ writeFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'user same-name')
+
+ manager.setSources({ opencodePluginSource: 'orca plugin' })
+ const dir = manager.materializeOpenCode('tab-opencode:0', userConfigDir)
+
+ expect(dir).not.toBeNull()
+ expect(readFileSync(join(dir!, 'opencode.json'), 'utf8')).toBe('{"provider":"custom"}')
+ expect(readFileSync(join(dir!, 'plugins', 'user-plugin.js'), 'utf8')).toBe('user plugin')
+ expect(readFileSync(join(dir!, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
+ 'orca plugin'
+ )
+ expect(readFileSync(join(userConfigDir, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe(
+ 'user same-name'
+ )
+ })
+
+ it('does not override a missing preexisting OpenCode config dir', () => {
+ manager.setSources({ opencodePluginSource: 'orca plugin' })
+
+ expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull()
+ })
+
+ it('materializes Pi extension into /extensions/', () => {
+ manager.setSources({ piExtensionSource: '// pi extension' })
+ const dir = manager.materializePi('tab-2:0')
+ expect(dir).not.toBeNull()
+ const file = join(dir!, 'extensions', 'orca-agent-status.ts')
+ expect(existsSync(file)).toBe(true)
+ })
+
+ it('mirrors the remote default Pi agent dir before adding Orca status extension', () => {
+ const piAgentDir = join(homeDir, '.pi', 'agent')
+ mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true })
+ mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true })
+ writeFileSync(join(piAgentDir, 'auth.json'), 'secret token')
+ writeFileSync(join(piAgentDir, 'skills', 'my-skill', 'SKILL.md'), 'critical user skill')
+ writeFileSync(join(piAgentDir, 'extensions', 'user-ext', 'ext.ts'), 'user extension')
+
+ manager.setSources({ piExtensionSource: '// pi extension' })
+ const dir = manager.materializePi('tab-pi:0')
+
+ expect(dir).not.toBeNull()
+ expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('secret token')
+ expect(readFileSync(join(dir!, 'skills', 'my-skill', 'SKILL.md'), 'utf8')).toBe(
+ 'critical user skill'
+ )
+ expect(readFileSync(join(dir!, 'extensions', 'user-ext', 'ext.ts'), 'utf8')).toBe(
+ 'user extension'
+ )
+ expect(readdirSync(join(dir!, 'extensions')).sort()).toEqual([
+ 'orca-agent-status.ts',
+ 'user-ext'
+ ])
+ })
+
+ it('mirrors a preexisting remote Pi agent dir instead of the default', () => {
+ const defaultAgentDir = join(homeDir, '.pi', 'agent')
+ const customAgentDir = join(homeDir, 'custom-pi-agent')
+ mkdirSync(defaultAgentDir, { recursive: true })
+ mkdirSync(join(customAgentDir, 'extensions'), { recursive: true })
+ writeFileSync(join(defaultAgentDir, 'auth.json'), 'default token')
+ writeFileSync(join(customAgentDir, 'auth.json'), 'custom token')
+ writeFileSync(join(customAgentDir, 'extensions', 'custom.ts'), 'custom extension')
+
+ manager.setSources({ piExtensionSource: '// pi extension' })
+ const dir = manager.materializePi('tab-custom-pi:0', customAgentDir)
+
+ expect(dir).not.toBeNull()
+ expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token')
+ expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension')
+ expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
+ '// pi extension'
+ )
+ })
+
+ it('does not override a missing preexisting Pi agent dir', () => {
+ manager.setSources({ piExtensionSource: '// pi extension' })
+
+ expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull()
+ })
+
+ it('clearOverlay removes both overlay roots for an id', () => {
+ manager.setSources({
+ opencodePluginSource: 'opencode',
+ piExtensionSource: 'pi'
+ })
+ const opencodeDir = manager.materializeOpenCode('tab-3:0')!
+ const piRoot = manager.materializePi('tab-3:0')!
+ expect(existsSync(opencodeDir)).toBe(true)
+ expect(existsSync(piRoot)).toBe(true)
+
+ manager.clearOverlay('tab-3:0')
+
+ expect(existsSync(opencodeDir)).toBe(false)
+ expect(existsSync(piRoot)).toBe(false)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'clearOverlay removes OpenCode overlay symlinks without deleting their targets',
+ () => {
+ const userConfigDir = join(homeDir, 'company-opencode')
+ const linkedTarget = join(homeDir, 'linked-plugin-target')
+ mkdirSync(join(userConfigDir, 'plugins'), { recursive: true })
+ mkdirSync(linkedTarget, { recursive: true })
+ writeFileSync(join(linkedTarget, 'keep.js'), 'do not delete')
+ symlinkSync(linkedTarget, join(userConfigDir, 'plugins', 'linked-plugin'), 'dir')
+
+ manager.setSources({ opencodePluginSource: 'orca plugin' })
+ const dir = manager.materializeOpenCode('tab-opencode-symlink:0', userConfigDir)!
+ expect(existsSync(join(dir, 'plugins', 'linked-plugin'))).toBe(true)
+
+ manager.clearOverlay('tab-opencode-symlink:0')
+
+ expect(existsSync(dir)).toBe(false)
+ expect(readFileSync(join(linkedTarget, 'keep.js'), 'utf8')).toBe('do not delete')
+ }
+ )
+
+ it('produces stable overlay dirs for a given id (idempotent re-materialization)', () => {
+ manager.setSources({ opencodePluginSource: 'first' })
+ const dirA = manager.materializeOpenCode('tab-stable:0')!
+ manager.setSources({ opencodePluginSource: 'second' })
+ const dirB = manager.materializeOpenCode('tab-stable:0')!
+ expect(dirA).toBe(dirB)
+ expect(readFileSync(join(dirA, 'plugins', 'orca-opencode-status.js'), 'utf8')).toBe('second')
+ })
+
+ it('hashes unsafe pane ids into portable overlay directory names', () => {
+ manager.setSources({ opencodePluginSource: 'plugin' })
+ const dir = manager.materializeOpenCode('tab/with\\unsafe:chars\n0')
+
+ expect(dir).not.toBeNull()
+ expect(basename(dir!)).toMatch(/^[a-f0-9]{32}$/)
+ expect(dir).not.toContain('tab/with')
+ expect(existsSync(join(dir!, 'plugins', 'orca-opencode-status.js'))).toBe(true)
+ })
+})
diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts
new file mode 100644
index 00000000000..905d4b5f3f1
--- /dev/null
+++ b/src/relay/plugin-overlay.ts
@@ -0,0 +1,276 @@
+// Why: relay-side equivalent of Orca's userData-backed plugin overlay system.
+// Orca's local OpenCodeHookService and PiTitlebarExtensionService each
+// materialize a per-PTY overlay and inject OPENCODE_CONFIG_DIR /
+// PI_CODING_AGENT_DIR pointing at it. Those paths describe the local
+// filesystem and would resolve to nothing on a remote box, so when a PTY runs
+// on the relay, the relay must do the same materialization on its own disk.
+//
+// Plugin source strings ship over the JSON-RPC channel at session-ready
+// (commit #7) — they are NOT bundled with the relay binary because the
+// relay is versioned independently from Orca and the plugin source changes
+// frequently as new agent events get added (see docs/design/agent-status-
+// over-ssh.md §4 "Why ship the plugin source over the wire").
+//
+// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService
+// directly: those modules import `electron` and ride on Orca's userData
+// path. The relay's electron-free constraint forces a thin parallel
+// implementation rooted at $HOME/.orca-relay/.
+
+import { createHash } from 'crypto'
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ realpathSync,
+ statSync,
+ unlinkSync,
+ writeFileSync
+} from 'fs'
+import { homedir } from 'os'
+import { basename, join } from 'path'
+import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror'
+
+const RELAY_HOOKS_DIR = '.orca-relay'
+const OPENCODE_OVERLAY_SUBDIR = 'opencode-overlays'
+const PI_OVERLAY_SUBDIR = 'pi-overlays'
+const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
+const PI_EXTENSION_FILE = 'orca-agent-status.ts'
+const PI_AGENT_DIR_NAME = '.pi'
+const PI_AGENT_SUBDIR = 'agent'
+
+function safeDirName(input: string): string {
+ // Why: paneKey embeds tabId:paneId where tabId may itself contain
+ // filesystem-unsafe characters in some Orca builds. Hash to a fixed-width
+ // hex name so any input produces a portable directory name.
+ return createHash('sha256').update(input).digest('hex').slice(0, 32)
+}
+
+function isUsableId(id: string): boolean {
+ return typeof id === 'string' && id.length > 0 && id.length <= 1024
+}
+
+export type PluginSources = {
+ /** Source body of `orca-opencode-status.js` to drop into /plugins/. */
+ opencodePluginSource?: string
+ /** Source body of `orca-agent-status.ts` to drop into /extensions/. */
+ piExtensionSource?: string
+}
+
+export class PluginOverlayManager {
+ private opencodePluginSource: string | null = null
+ private piExtensionSource: string | null = null
+ private homeDir: string
+ private opencodeRoot: string
+ private piRoot: string
+
+ constructor(opts?: { homeDir?: string }) {
+ const home = opts?.homeDir ?? homedir()
+ this.homeDir = home
+ this.opencodeRoot = join(home, RELAY_HOOKS_DIR, OPENCODE_OVERLAY_SUBDIR)
+ this.piRoot = join(home, RELAY_HOOKS_DIR, PI_OVERLAY_SUBDIR)
+ }
+
+ /** Replace the cached source bodies. Called from relay.ts when Orca sends
+ * `agent_hook.installPlugins`. The first install enables the augmenter
+ * output; subsequent installs (e.g. Orca version upgrade in flight) refresh
+ * the cached source so future spawns see the new strings.
+ * Note: existing per-PTY overlays already on disk keep the previous source
+ * until that PTY exits — a long-running PTY does NOT pick up the new
+ * source, matching the local-Orca behavior where the plugin file is
+ * written once at spawn time. */
+ setSources(sources: PluginSources): void {
+ if (typeof sources.opencodePluginSource === 'string') {
+ this.opencodePluginSource = sources.opencodePluginSource
+ }
+ if (typeof sources.piExtensionSource === 'string') {
+ this.piExtensionSource = sources.piExtensionSource
+ }
+ }
+
+ hasOpenCodeSource(): boolean {
+ return this.opencodePluginSource !== null
+ }
+
+ hasPiSource(): boolean {
+ return this.piExtensionSource !== null
+ }
+
+ private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void {
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
+ const sourcePath = join(sourceDir, entry.name)
+
+ if (entry.name === 'plugins') {
+ const isSymlink = entry.isSymbolicLink()
+ let isLinkPointingToDir = false
+ if (isSymlink) {
+ try {
+ isLinkPointingToDir = statSync(sourcePath).isDirectory()
+ } catch {
+ isLinkPointingToDir = false
+ }
+ }
+
+ if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
+ const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
+ const overlayPluginsDir = join(overlayDir, 'plugins')
+ mkdirSync(overlayPluginsDir, { recursive: true })
+ for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
+ if (pluginEntry.name === OPENCODE_PLUGIN_FILE) {
+ continue
+ }
+ mirrorEntry(
+ join(resolvedSource, pluginEntry.name),
+ join(overlayPluginsDir, pluginEntry.name)
+ )
+ }
+ continue
+ }
+ }
+
+ mirrorEntry(sourcePath, join(overlayDir, entry.name))
+ }
+ }
+
+ private writeOpenCodePlugin(overlayDir: string): void {
+ const pluginsDir = join(overlayDir, 'plugins')
+ mkdirSync(pluginsDir, { recursive: true })
+ const pluginPath = join(pluginsDir, OPENCODE_PLUGIN_FILE)
+ try {
+ unlinkSync(pluginPath)
+ } catch {
+ // Fresh overlay or no same-named stale symlink.
+ }
+ writeFileSync(pluginPath, this.opencodePluginSource!)
+ }
+
+ /** Materialize the OpenCode plugin overlay for `id` (typically the
+ * renderer-supplied paneKey or, fallback, the relay-internal pty-id) and
+ * return the directory path. Returns null when no source is cached or
+ * the overlay write fails — caller falls back to no plugin (the agent
+ * CLI runs without status reporting), which is the existing fail-open
+ * behavior on the local side. */
+ materializeOpenCode(id: string, existingConfigDir?: string): string | null {
+ if (!this.opencodePluginSource || !isUsableId(id)) {
+ return null
+ }
+ const dir = join(this.opencodeRoot, safeDirName(id))
+ try {
+ safeRemoveOverlay(dir, this.opencodeRoot)
+ mkdirSync(dir, { recursive: true })
+ if (existingConfigDir) {
+ if (!existsSync(existingConfigDir)) {
+ return null
+ }
+ // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's
+ // remote root into the overlay before adding Orca's plugin so status
+ // reporting does not hide their auth, models, keybinds, or plugins.
+ this.mirrorOpenCodeConfig(existingConfigDir, dir)
+ }
+ this.writeOpenCodePlugin(dir)
+ return dir
+ } catch (err) {
+ process.stderr.write(
+ `[plugin-overlay] failed to materialize OpenCode overlay: ${err instanceof Error ? err.message : String(err)}\n`
+ )
+ return null
+ }
+ }
+
+ private getDefaultPiAgentDir(): string {
+ return join(this.homeDir, PI_AGENT_DIR_NAME, PI_AGENT_SUBDIR)
+ }
+
+ private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string): void {
+ if (!existsSync(sourceAgentDir)) {
+ return
+ }
+
+ for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) {
+ const sourcePath = join(sourceAgentDir, entry.name)
+
+ if (entry.name === 'extensions') {
+ const isSymlink = entry.isSymbolicLink()
+ let isLinkPointingToDir = false
+ if (isSymlink) {
+ try {
+ isLinkPointingToDir = statSync(sourcePath).isDirectory()
+ } catch {
+ isLinkPointingToDir = false
+ }
+ }
+
+ if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
+ const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
+ const overlayExtensionsDir = join(overlayDir, 'extensions')
+ mkdirSync(overlayExtensionsDir, { recursive: true })
+ for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
+ if (extensionEntry.name === PI_EXTENSION_FILE) {
+ continue
+ }
+ mirrorEntry(
+ join(resolvedSource, extensionEntry.name),
+ join(overlayExtensionsDir, extensionEntry.name)
+ )
+ }
+ continue
+ }
+ }
+
+ mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
+ }
+ }
+
+ /** Materialize the Pi extension overlay for `id` and return the directory
+ * path that should be assigned to PI_CODING_AGENT_DIR. */
+ materializePi(id: string, existingAgentDir?: string): string | null {
+ if (!this.piExtensionSource || !isUsableId(id)) {
+ return null
+ }
+ const dir = join(this.piRoot, safeDirName(id))
+ try {
+ // Why: PI_CODING_AGENT_DIR is Pi's whole state root. Mirror the remote
+ // user's default agent dir so Orca's status extension does not hide auth,
+ // sessions, skills, prompts, themes, or user extensions inside SSH panes.
+ safeRemoveOverlay(dir, this.piRoot)
+ mkdirSync(dir, { recursive: true })
+ const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir()
+ if (existingAgentDir && !existsSync(existingAgentDir)) {
+ return null
+ }
+ this.mirrorPiAgentDir(sourceAgentDir, dir)
+ const extensionsDir = join(dir, 'extensions')
+ mkdirSync(extensionsDir, { recursive: true })
+ writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), this.piExtensionSource)
+ return dir
+ } catch (err) {
+ process.stderr.write(
+ `[plugin-overlay] failed to materialize Pi overlay: ${err instanceof Error ? err.message : String(err)}\n`
+ )
+ return null
+ }
+ }
+
+ /** Drop a paneKey's overlay dirs on PTY exit. Best-effort; cleanup over a
+ * recursive tree may fail on exotic filesystems but the worst-case
+ * outcome is unbounded growth on a long-lived relay, which the per-pane
+ * caches alone do not bound. */
+ clearOverlay(id: string): void {
+ if (!isUsableId(id)) {
+ return
+ }
+ const safe = safeDirName(id)
+ for (const root of [this.opencodeRoot, this.piRoot]) {
+ try {
+ safeRemoveOverlay(join(root, safe), root)
+ } catch (err) {
+ // Why: log the failed cleanup so a permission/IO error is observable.
+ // The leak is the failure mode the per-pane cache eviction exists to
+ // prevent — silent swallows would let it accumulate invisibly on
+ // long-running relays.
+ process.stderr.write(
+ `[plugin-overlay] failed to remove overlay dir ${join(root, safe)}: ${err instanceof Error ? err.message : String(err)}\n`
+ )
+ }
+ }
+ }
+}
diff --git a/src/relay/plugin-source-limit.test.ts b/src/relay/plugin-source-limit.test.ts
new file mode 100644
index 00000000000..87068802215
--- /dev/null
+++ b/src/relay/plugin-source-limit.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { assertPluginSourceUnderByteCap, PLUGIN_SOURCE_MAX_BYTES } from './plugin-source-limit'
+
+describe('plugin source byte limit', () => {
+ it('allows non-string values because installPlugins treats them as absent sources', () => {
+ expect(() => assertPluginSourceUnderByteCap('piExtensionSource', undefined)).not.toThrow()
+ })
+
+ it('allows sources at the byte cap', () => {
+ const source = 'a'.repeat(PLUGIN_SOURCE_MAX_BYTES)
+
+ expect(() => assertPluginSourceUnderByteCap('opencodePluginSource', source)).not.toThrow()
+ })
+
+ it('rejects sources over the byte cap using utf8 byte length, not string length', () => {
+ const source = 'é'.repeat(Math.floor(PLUGIN_SOURCE_MAX_BYTES / 2) + 1)
+
+ expect(source.length).toBeLessThanOrEqual(PLUGIN_SOURCE_MAX_BYTES)
+ expect(Buffer.byteLength(source, 'utf8')).toBeGreaterThan(PLUGIN_SOURCE_MAX_BYTES)
+ expect(() => assertPluginSourceUnderByteCap('piExtensionSource', source)).toThrow(
+ `piExtensionSource exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap`
+ )
+ })
+})
diff --git a/src/relay/plugin-source-limit.ts b/src/relay/plugin-source-limit.ts
new file mode 100644
index 00000000000..d06c24248a0
--- /dev/null
+++ b/src/relay/plugin-source-limit.ts
@@ -0,0 +1,12 @@
+export const PLUGIN_SOURCE_MAX_BYTES = 256 * 1024
+
+export function assertPluginSourceUnderByteCap(fieldName: string, value: unknown): void {
+ if (typeof value !== 'string') {
+ return
+ }
+ // Why: the relay receives JSON strings over the wire; cap actual UTF-8
+ // bytes instead of UTF-16 code units so non-ASCII source cannot bypass it.
+ if (Buffer.byteLength(value, 'utf8') > PLUGIN_SOURCE_MAX_BYTES) {
+ throw new Error(`${fieldName} exceeds ${PLUGIN_SOURCE_MAX_BYTES} byte cap`)
+ }
+}
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index ec34881e9a2..e806237a429 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -1,5 +1,8 @@
/* oxlint-disable max-lines */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({
mockPtySpawn: vi.fn(),
@@ -435,6 +438,113 @@ describe('PtyHandler', () => {
expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1')
})
+ it('passes the PTY id and renderer paneKey to env augmenters', async () => {
+ const seenContexts: { id: string; paneKey?: string; env: Record }[] = []
+ handler.addEnvAugmenter((ctx) => {
+ seenContexts.push(ctx)
+ return {
+ OVERLAY_ID: ctx.paneKey ?? ctx.id
+ }
+ })
+
+ await dispatcher.callRequest('pty.spawn', {
+ env: { ORCA_PANE_KEY: 'tab-context:0' }
+ })
+ await dispatcher.callRequest('pty.spawn', {})
+
+ const firstEnv = mockPtySpawn.mock.calls[0][2] as { env: Record }
+ const secondEnv = mockPtySpawn.mock.calls[1][2] as { env: Record }
+ expect(seenContexts[0]).toMatchObject({
+ id: 'pty-1',
+ paneKey: 'tab-context:0',
+ env: { ORCA_PANE_KEY: 'tab-context:0' }
+ })
+ expect(seenContexts[1]).toMatchObject({ id: 'pty-2', paneKey: undefined })
+ expect(firstEnv.env.OVERLAY_ID).toBe('tab-context:0')
+ expect(secondEnv.env.OVERLAY_ID).toBe('pty-2')
+ })
+
+ it('passes process and renderer env to env augmenters before augmenter overrides are applied', async () => {
+ const oldProcessValue = process.env.OPENCODE_CONFIG_DIR
+ process.env.OPENCODE_CONFIG_DIR = '/remote/default-opencode'
+ try {
+ handler.addEnvAugmenter((ctx) => ({
+ SEEN_OPENCODE_CONFIG_DIR: ctx.env.OPENCODE_CONFIG_DIR,
+ SEEN_PI_CODING_AGENT_DIR: ctx.env.PI_CODING_AGENT_DIR
+ }))
+
+ await dispatcher.callRequest('pty.spawn', {
+ env: {
+ OPENCODE_CONFIG_DIR: '/remote/renderer-opencode',
+ PI_CODING_AGENT_DIR: '/remote/pi'
+ }
+ })
+ } finally {
+ if (oldProcessValue === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = oldProcessValue
+ }
+ }
+
+ const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record }
+ expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode')
+ expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi')
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'wraps bash spawns to restore overlay env after remote startup files',
+ async () => {
+ const oldShell = process.env.SHELL
+ const oldHome = process.env.HOME
+ const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-'))
+
+ process.env.SHELL = '/bin/bash'
+ process.env.HOME = homeDir
+ try {
+ if (!existsSync('/bin/bash')) {
+ return
+ }
+
+ handler.addEnvAugmenter(() => ({
+ OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
+ ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
+ PI_CODING_AGENT_DIR: '/remote/overlay/pi',
+ ORCA_PI_CODING_AGENT_DIR: '/remote/overlay/pi'
+ }))
+
+ await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } })
+ } finally {
+ if (oldShell === undefined) {
+ delete process.env.SHELL
+ } else {
+ process.env.SHELL = oldShell
+ }
+ if (oldHome === undefined) {
+ delete process.env.HOME
+ } else {
+ process.env.HOME = oldHome
+ }
+ }
+
+ const shellArgs = mockPtySpawn.mock.calls[0][1]
+ const spawnOptions = mockPtySpawn.mock.calls[0][2] as { env: Record }
+ const rcfile = join(homeDir, '.orca-relay', 'shell-ready', 'bash', 'rcfile')
+
+ expect(shellArgs).toEqual(['--rcfile', rcfile])
+ expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode')
+ expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBe('/remote/overlay/pi')
+ expect(readFileSync(rcfile, 'utf8')).toContain(
+ 'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
+ )
+ expect(readFileSync(rcfile, 'utf8')).toContain(
+ 'export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
+ )
+
+ rmSync(homeDir, { recursive: true, force: true })
+ }
+ )
+
it('revive restores pane identity env alongside hook-server coordinates', async () => {
await dispatcher.callRequest('pty.spawn', {
cols: 90,
@@ -494,7 +604,32 @@ describe('PtyHandler', () => {
expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }])
})
- it('dispose kills all PTYs with SIGKILL', async () => {
+ it('immediate shutdown invokes the exit listener once even if onExit arrives later', async () => {
+ let onExitCb: ((evt: { exitCode: number }) => void) | undefined
+ const mockKill = vi.fn()
+ mockPtySpawn.mockReturnValue({
+ ...mockPtyInstance,
+ kill: mockKill,
+ onData: vi.fn(),
+ onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
+ onExitCb = cb
+ })
+ })
+ const exits: { id: string; paneKey?: string }[] = []
+ handler.setExitListener((evt) => exits.push(evt))
+
+ await dispatcher.callRequest('pty.spawn', {
+ env: { ORCA_PANE_KEY: 'tab-shutdown:0' }
+ })
+ await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: true })
+ onExitCb!({ exitCode: 0 })
+
+ expect(mockKill).toHaveBeenCalledWith('SIGKILL')
+ expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-shutdown:0' }])
+ expect(handler.activePtyCount).toBe(0)
+ })
+
+ it('dispose kills all PTYs with SIGKILL and invokes exit listeners', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
...mockPtyInstance,
@@ -502,9 +637,11 @@ describe('PtyHandler', () => {
onData: vi.fn(),
onExit: vi.fn()
})
+ const exits: { id: string; paneKey?: string }[] = []
+ handler.setExitListener((evt) => exits.push(evt))
- await dispatcher.callRequest('pty.spawn', {})
- await dispatcher.callRequest('pty.spawn', {})
+ await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:0' } })
+ await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-dispose:1' } })
expect(handler.activePtyCount).toBe(2)
handler.dispose()
@@ -513,6 +650,10 @@ describe('PtyHandler', () => {
// wedged process, uninterruptible sleep) would survive SIGTERM + immediate
// destroy() as an orphan on the remote host. SIGKILL is not ignorable.
expect(mockKill).toHaveBeenCalledWith('SIGKILL')
+ expect(exits).toEqual([
+ { id: 'pty-1', paneKey: 'tab-dispose:0' },
+ { id: 'pty-2', paneKey: 'tab-dispose:1' }
+ ])
expect(handler.activePtyCount).toBe(0)
})
})
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index b36b7b43180..b1b540cb867 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -9,6 +9,7 @@ import {
getForegroundProcessName,
listShellProfiles
} from './pty-shell-utils'
+import { getRelayShellLaunchConfig } from './pty-shell-launch'
// Why: node-pty is a native addon that may not be installed on the remote.
// Dynamic import keeps the require() lazy so loadPty() returns null gracefully
@@ -39,6 +40,10 @@ type ManagedPty = {
* entry-point calls into a clean "not found" error instead of a silent no-op
* (POSIX proc.kill is neutralized inside disposeManagedPty). */
disposed?: boolean
+ /** True once external cleanup observers have been notified. Forced cleanup
+ * paths can run before node-pty emits onExit; this prevents duplicate
+ * overlay/cache cleanup if onExit arrives later. */
+ exitListenerNotified?: boolean
/** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so
* external observers (the relay-hook-server cache) can evict per-pane
* state when this PTY exits. Symmetric with Orca's local pty.ts. */
@@ -102,7 +107,16 @@ type SerializedPtyEntry = {
}
export type PtyExitListener = (event: { id: string; paneKey?: string }) => void
-export type PtyEnvAugmenter = () => Record
+/** Returns env to merge into the PTY's spawn env. Receives spawn context so
+ * augmenters that need a per-PTY identity (e.g. OPENCODE_CONFIG_DIR overlay
+ * paths derived from the renderer's paneKey) can compute it without pulling
+ * the renderer's env in twice. */
+export type PtyEnvAugmenter = (ctx: {
+ id: string
+ paneKey?: string
+ shell: string
+ env: Record
+}) => Record
export class PtyHandler {
private ptys = new Map()
@@ -110,11 +124,13 @@ export class PtyHandler {
private dispatcher: RelayDispatcher
private graceTimeMs: number
private graceTimer: ReturnType | null = null
- // Why: external observers (the relay's hook-server cache) need to drop
- // per-pane state when a PTY exits. Multiple listeners is unnecessary today
- // — the hook server is the only consumer — so a single optional callback
- // keeps the surface tight. A throw inside the listener is swallowed so it
- // can never block disposeManagedPty / map cleanup.
+ // Why: external observers need to drop per-pane state when a PTY exits.
+ // Today the relay composes multiple consumers (hook-server cache eviction
+ // and plugin-overlay dir cleanup) into a single callback at the call site
+ // (see relay.ts setExitListener). A single optional slot is intentional —
+ // callers compose externally rather than us maintaining a listener list.
+ // A throw inside the listener is swallowed so it can never block
+ // disposeManagedPty / map cleanup.
private exitListener: PtyExitListener | null = null
// Why: env augmenters injected at relay boot (currently the relay-hook
// server's ORCA_AGENT_HOOK_* coords). Run on every spawn so every PTY
@@ -156,18 +172,22 @@ export class PtyHandler {
* drift between the two paths — revived shells after a relay restart must
* see the fresh ORCA_AGENT_HOOK_* coords just like freshly-spawned ones,
* otherwise agent-status over SSH silently breaks on every revive. */
- private buildSpawnEnv(rendererEnv?: Record): Record {
+ private buildSpawnEnv(
+ rendererEnv: Record | undefined,
+ ctx: { id: string; paneKey?: string; shell: string }
+ ): Record {
+ const baseEnv = { ...process.env, ...rendererEnv } as Record
const augmented: Record = {}
for (const augmenter of this.envAugmenters) {
try {
- Object.assign(augmented, augmenter())
+ Object.assign(augmented, augmenter({ ...ctx, env: baseEnv }))
} catch (err) {
process.stderr.write(
`[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n`
)
}
}
- return { ...process.env, ...rendererEnv, ...augmented } as Record
+ return { ...baseEnv, ...augmented }
}
/** Wire onData/onExit listeners for a managed PTY and store it. */
@@ -181,6 +201,9 @@ export class PtyHandler {
this.dispatcher.notify('pty.data', { id: managed.id, data })
})
managed.pty.onExit(({ exitCode }: { exitCode: number }) => {
+ if (managed.disposed) {
+ return
+ }
// Why: neutralize managed.pty.kill synchronously BEFORE anything else
// in this callback. node-pty's UnixTerminal has
// `_socket.once('close', () => this.kill('SIGHUP'))` wired at destroy
@@ -199,19 +222,7 @@ export class PtyHandler {
managed.killTimer = undefined
}
this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode })
- // Why: notify external observers BEFORE deleting the map entry so a
- // listener that needs to read paneKey from the managed entry still
- // can. Wrap in try/catch so a throwing listener cannot block fd
- // release or map cleanup.
- if (this.exitListener) {
- try {
- this.exitListener({ id: managed.id, paneKey: managed.paneKey })
- } catch (err) {
- process.stderr.write(
- `[pty-handler] onExit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
- )
- }
- }
+ this.notifyExitListener(managed)
this.ptys.delete(managed.id)
// Why: release the ptmx fd on the natural-exit path. Without this the
// node-pty wrapper's _socket stays alive until GC and the master fd
@@ -220,6 +231,26 @@ export class PtyHandler {
})
}
+ private notifyExitListener(managed: ManagedPty): void {
+ if (managed.exitListenerNotified) {
+ return
+ }
+ managed.exitListenerNotified = true
+ // Why: external observers own relay-hook cache eviction and plugin-overlay
+ // cleanup. Natural exits, immediate shutdown, SIGKILL fallback, and relay
+ // process disposal all need the same cleanup even when node-pty never
+ // delivers onExit.
+ if (this.exitListener) {
+ try {
+ this.exitListener({ id: managed.id, paneKey: managed.paneKey })
+ } catch (err) {
+ process.stderr.write(
+ `[pty-handler] exit listener threw: ${err instanceof Error ? err.message : String(err)}\n`
+ )
+ }
+ }
+ }
+
private registerHandlers(): void {
this.dispatcher.onRequest('pty.spawn', (p, context) => this.spawn(p, context))
this.dispatcher.onRequest('pty.attach', (p) => this.attach(p))
@@ -262,28 +293,31 @@ export class PtyHandler {
const shell = resolveDefaultShell()
const id = `pty-${this.nextId++}`
- // Why: server-side augmenter values (ORCA_AGENT_HOOK_*) override any
- // renderer-supplied env so the live hook-server coords always reach the
- // agent CLI — they come from the relay, not the renderer. See
- // buildSpawnEnv for the precedence contract.
- const spawnEnv = this.buildSpawnEnv(env)
+ // Why: server-side augmenter values (ORCA_AGENT_HOOK_* and plugin overlay
+ // dirs) override renderer-supplied env so live remote paths and hook coords
+ // win over local userData paths. The context lets overlay augmenters derive
+ // per-PTY OpenCode/Pi directories from the stable paneKey when present.
+ const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
+ const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell })
+ const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
// Why: SSH exec channels give the relay a minimal environment without
// .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH
// includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh).
- const term = pty.spawn(shell, ['-l'], {
+ // When overlays are injected, the launch wrapper keeps those paths after
+ // user startup files re-export their defaults.
+ const term = pty.spawn(shell, shellLaunch.args, {
name: 'xterm-256color',
cols,
rows,
cwd,
- env: spawnEnv
+ env: { ...spawnEnv, ...shellLaunch.env }
})
// Why: capture the renderer-supplied paneKey on the managed entry so the
// exit listener can evict per-pane caches without the relay needing a
// separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}`
// and is bounded by the renderer; the relay treats it as opaque.
- const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined
const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined
const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined
const managed: ManagedPty = {
@@ -309,6 +343,7 @@ export class PtyHandler {
// SIGKILL's onExit is missed (kernel edge case, uninterruptible
// sleep), the managed entry + ptmx fd would leak forever. Dispose
// synchronously so the entry is gone regardless of onExit timing.
+ this.notifyExitListener(still)
disposeManagedPty(still)
this.ptys.delete(id)
}
@@ -391,6 +426,7 @@ export class PtyHandler {
// here makes the map hygiene a hard guarantee, not "hopefully onExit
// runs". If onExit DOES fire later, its own `this.ptys.delete(id)` is
// a no-op.
+ this.notifyExitListener(managed)
this.ptys.delete(id)
} else {
managed.pty.kill('SIGTERM')
@@ -414,6 +450,7 @@ export class PtyHandler {
// graceful-shutdown's SIGKILL fallback is a hard guarantee, not
// "hopefully onExit will run". The disposed guard inside
// disposeManagedPty makes a later onExit's dispose a no-op.
+ this.notifyExitListener(still)
disposeManagedPty(still)
this.ptys.delete(id)
}
@@ -545,12 +582,19 @@ export class PtyHandler {
if (entry.worktreeId) {
revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId
}
- const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], {
+ const shell = resolveDefaultShell()
+ const spawnEnv = this.buildSpawnEnv(revivedEnv, {
+ id: entry.id,
+ paneKey: entry.paneKey,
+ shell
+ })
+ const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv)
+ const term = ptyMod.spawn(shell, shellLaunch.args, {
name: 'xterm-256color',
cols: entry.cols,
rows: entry.rows,
cwd: entry.cwd,
- env: this.buildSpawnEnv(revivedEnv)
+ env: { ...spawnEnv, ...shellLaunch.env }
})
this.wireAndStore({
id: entry.id,
@@ -612,6 +656,7 @@ export class PtyHandler {
} catch {
/* child may already be dead */
}
+ this.notifyExitListener(managed)
disposeManagedPty(managed)
}
this.ptys.clear()
diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts
new file mode 100644
index 00000000000..9307f22f865
--- /dev/null
+++ b/src/relay/pty-shell-launch.test.ts
@@ -0,0 +1,58 @@
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { getRelayShellLaunchConfig } from './pty-shell-launch'
+
+describe('getRelayShellLaunchConfig', () => {
+ let homeDir: string
+
+ beforeEach(() => {
+ homeDir = mkdtempSync(join(tmpdir(), 'relay-shell-launch-'))
+ })
+
+ afterEach(() => {
+ rmSync(homeDir, { recursive: true, force: true })
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'preserves a user ZDOTDIR exported from .zshenv for later startup files',
+ () => {
+ const config = getRelayShellLaunchConfig('/bin/zsh', {
+ HOME: homeDir,
+ ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay'
+ })
+ const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh')
+
+ expect(config.args).toEqual(['-l'])
+ expect(config.env.ZDOTDIR).toBe(zshRoot)
+ expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
+ 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
+ )
+ expect(readFileSync(join(zshRoot, '.zprofile'), 'utf8')).toContain(
+ '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
+ )
+ expect(readFileSync(join(zshRoot, '.zshrc'), 'utf8')).toContain(
+ '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
+ )
+ expect(readFileSync(join(zshRoot, '.zlogin'), 'utf8')).toContain(
+ '_orca_home="${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
+ )
+ }
+ )
+
+ it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => {
+ const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh')
+ mkdirSync(zshRoot, { recursive: true })
+ writeFileSync(join(zshRoot, '.zshenv'), '# stale relay wrapper\n')
+
+ getRelayShellLaunchConfig('/bin/zsh', {
+ HOME: homeDir,
+ ORCA_OPENCODE_CONFIG_DIR: '/tmp/orca-opencode-overlay'
+ })
+
+ expect(readFileSync(join(zshRoot, '.zshenv'), 'utf8')).toContain(
+ 'export ORCA_USER_ZDOTDIR="${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"'
+ )
+ })
+})
diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts
new file mode 100644
index 00000000000..f2f3f739bd9
--- /dev/null
+++ b/src/relay/pty-shell-launch.ts
@@ -0,0 +1,165 @@
+import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
+import { homedir } from 'os'
+import { basename, dirname, join } from 'path'
+
+const RELAY_SHELL_READY_DIR = '.orca-relay/shell-ready'
+const POSIX_LOGIN_ARGS = ['-l']
+
+export type RelayShellLaunchConfig = {
+ args: string[]
+ env: Record
+}
+
+function quotePosixSingle(value: string): string {
+ return `'${value.replace(/'/g, `'\\''`)}'`
+}
+
+function hasOverlayRestoreEnv(env: Record): boolean {
+ return Boolean(env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR)
+}
+
+function getWrapperRoot(env: Record): string {
+ return join(env.HOME || process.env.HOME || homedir(), RELAY_SHELL_READY_DIR)
+}
+
+function normalizeOriginalZdotdirCandidate(value: string | undefined): string | null {
+ if (!value) {
+ return null
+ }
+ const normalized = value.replace(/\/+$/, '')
+ if (!normalized || normalized.endsWith('/shell-ready/zsh')) {
+ return null
+ }
+ return value
+}
+
+function resolveOriginalZdotdir(env: Record): string {
+ return (
+ normalizeOriginalZdotdirCandidate(env.ZDOTDIR) ||
+ normalizeOriginalZdotdirCandidate(env.ORCA_ORIG_ZDOTDIR) ||
+ env.HOME ||
+ process.env.HOME ||
+ ''
+ )
+}
+
+function ensureOverlayRestoreWrappers(root: string): void {
+ const zshDir = join(root, 'zsh')
+ const bashDir = join(root, 'bash')
+
+ const zshEnv = `# Orca relay zsh overlay wrapper
+export ORCA_ORIG_ZDOTDIR="\${ORCA_ORIG_ZDOTDIR:-$HOME}"
+case "\${ORCA_ORIG_ZDOTDIR%/}" in
+ */shell-ready/zsh) export ORCA_ORIG_ZDOTDIR="$HOME" ;;
+esac
+[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
+export ORCA_USER_ZDOTDIR="\${ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
+case "\${ORCA_USER_ZDOTDIR%/}" in
+ */shell-ready/zsh) export ORCA_USER_ZDOTDIR="$HOME" ;;
+esac
+export ZDOTDIR=${quotePosixSingle(zshDir)}
+`
+ const zshProfile = `# Orca relay zsh overlay wrapper
+_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
+case "\${_orca_home%/}" in
+ */shell-ready/zsh) _orca_home="$HOME" ;;
+esac
+[[ -f "$_orca_home/.zprofile" ]] && source "$_orca_home/.zprofile"
+`
+ const zshRc = `# Orca relay zsh overlay wrapper
+_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
+case "\${_orca_home%/}" in
+ */shell-ready/zsh) _orca_home="$HOME" ;;
+esac
+if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
+ source "$_orca_home/.zshrc"
+fi
+if [[ ! -o login ]]; then
+ # Why: remote startup files can re-export user defaults after relay spawn.
+ [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
+ [[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
+fi
+`
+ const zshLogin = `# Orca relay zsh overlay wrapper
+_orca_home="\${ORCA_USER_ZDOTDIR:-\${ORCA_ORIG_ZDOTDIR:-$HOME}}"
+case "\${_orca_home%/}" in
+ */shell-ready/zsh) _orca_home="$HOME" ;;
+esac
+if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
+ source "$_orca_home/.zlogin"
+fi
+# Why: .zlogin is the final zsh login startup file before the prompt.
+[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
+[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
+`
+ const bashRc = `# Orca relay bash overlay wrapper
+[[ -f /etc/profile ]] && source /etc/profile
+if [[ -f "$HOME/.bash_profile" ]]; then
+ source "$HOME/.bash_profile"
+elif [[ -f "$HOME/.bash_login" ]]; then
+ source "$HOME/.bash_login"
+elif [[ -f "$HOME/.profile" ]]; then
+ source "$HOME/.profile"
+fi
+# Why: remote startup files can re-export user defaults after relay spawn.
+[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
+[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
+`
+
+ const files = [
+ [join(zshDir, '.zshenv'), zshEnv],
+ [join(zshDir, '.zprofile'), zshProfile],
+ [join(zshDir, '.zshrc'), zshRc],
+ [join(zshDir, '.zlogin'), zshLogin],
+ [join(bashDir, 'rcfile'), bashRc]
+ ] as const
+
+ for (const [path, content] of files) {
+ mkdirSync(dirname(path), { recursive: true })
+ let existing: string | null = null
+ try {
+ existing = readFileSync(path, 'utf8')
+ } catch {
+ existing = null
+ }
+ // Why: relay wrapper files persist under ~/.orca-relay across app
+ // upgrades. Existence alone is not enough; stale wrappers would miss
+ // later fixes such as preserving post-.zshenv ZDOTDIR.
+ if (existing !== content) {
+ writeFileSync(path, content, 'utf8')
+ }
+ chmodSync(path, 0o644)
+ }
+}
+
+export function getRelayShellLaunchConfig(
+ shellPath: string,
+ env: Record
+): RelayShellLaunchConfig {
+ if (!hasOverlayRestoreEnv(env) || process.platform === 'win32') {
+ return { args: POSIX_LOGIN_ARGS, env: {} }
+ }
+
+ const shellName = basename(shellPath).toLowerCase()
+ if (shellName !== 'zsh' && shellName !== 'bash') {
+ return { args: POSIX_LOGIN_ARGS, env: {} }
+ }
+
+ const root = getWrapperRoot(env)
+ ensureOverlayRestoreWrappers(root)
+
+ if (shellName === 'zsh') {
+ return {
+ args: POSIX_LOGIN_ARGS,
+ env: {
+ ORCA_ORIG_ZDOTDIR: resolveOriginalZdotdir(env),
+ ZDOTDIR: join(root, 'zsh')
+ }
+ }
+ }
+
+ return {
+ args: ['--rcfile', join(root, 'bash', 'rcfile')],
+ env: {}
+ }
+}
diff --git a/src/relay/relay.ts b/src/relay/relay.ts
index df6e075adb7..b1bc2df32a5 100644
--- a/src/relay/relay.ts
+++ b/src/relay/relay.ts
@@ -1,4 +1,9 @@
#!/usr/bin/env node
+/* oxlint-disable max-lines -- Why: the relay entry point centralizes process
+ lifecycle (stdio, --connect bridge, grace timer, signal handlers, socket
+ server) and handler registration in one file so the boot sequence stays in
+ topological order. Splitting by line count would scatter ordered side-
+ effects across modules and obscure the lifecycle. */
// Orca Relay — lightweight daemon deployed to remote hosts.
// Communicates over stdin/stdout using the framed JSON-RPC protocol.
@@ -24,11 +29,14 @@ import { GitHandler } from './git-handler'
import { PreflightHandler } from './preflight-handler'
import { PortScanHandler } from './port-scan-handler'
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
+import { PluginOverlayManager } from './plugin-overlay'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
AGENT_HOOK_REQUEST_REPLAY_METHOD
} from '../shared/agent-hook-relay'
+import { assertPluginSourceUnderByteCap } from './plugin-source-limit'
+import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugin-overlay-env'
const DEFAULT_GRACE_MS = 5 * 60 * 1000
const SOCK_NAME = 'relay.sock'
@@ -247,26 +255,77 @@ async function main(): Promise {
)
}
})
- // Why: wait for hook-server startup before the readiness sentinel. A PTY
- // spawned before the augmenter exists can never receive ORCA_AGENT_HOOK_*
- // later, so success registers the augmenter first; failure is the deliberate
- // fail-open path where agent status is disabled for this relay process.
+ // Why: await the hook-server bind before announcing readiness so the very
+ // first PTY spawn (which can land within milliseconds of the sentinel)
+ // already sees populated ORCA_AGENT_HOOK_* env. The bind is a local-loopback
+ // listen — measured in ms — so the latency cost is trivial and removes a
+ // class of "first agent invocation has no status" races. Bind failure is
+ // treated as soft: log and continue, the augmenter returns {} and agent
+ // status simply does not flow.
try {
await hookServer.start()
- ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
} catch (err) {
process.stderr.write(
`[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n`
)
}
- // Why: evict the per-pane last-status cache when the backing PTY exits so
- // a terminated pane's last working/done payload cannot resurface as a
- // ghost event after a later reconnect — see §5 Path 3.
- ptyHandler.setExitListener(({ paneKey }) => {
+ // Why: every relay-spawned PTY needs the live ORCA_AGENT_HOOK_* coords. The
+ // augmenter is read on every spawn so a hook-server bind that succeeded
+ // late (or after a stop/start) lands in the next PTY's env without a
+ // restart.
+ ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
+
+ // Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR`
+ // and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem
+ // — paths the renderer would synthesize for the Orca host's userData are
+ // meaningless on the remote. The overlay manager materializes a per-PTY
+ // dir on the remote (rooted at $HOME/.orca-relay/) so the agent CLI inside
+ // the relay-spawned PTY loads the bundled status plugin and posts to the
+ // relay's hook server. Source bodies arrive over JSON-RPC (see
+ // `agent_hook.installPlugins` below) — not bundled with the relay binary.
+ const pluginOverlay = new PluginOverlayManager()
+ ptyHandler.addEnvAugmenter((ctx) => {
+ const env: Record = {}
+ // Why: prefer paneKey for overlay identity so a renderer-side remount
+ // that reuses the paneKey lands in the same overlay dir. Falls back to
+ // the relay-internal pty-id when paneKey is absent (e.g. CLI-launched
+ // PTYs that don't go through the renderer).
+ const overlayId = ctx.paneKey ?? ctx.id
+ if (pluginOverlay.hasOpenCodeSource()) {
+ const sourceDir = resolveOpenCodeSourceConfigDir(ctx.env, ctx.shell)
+ const dir = pluginOverlay.materializeOpenCode(overlayId, sourceDir)
+ if (dir) {
+ env.OPENCODE_CONFIG_DIR = dir
+ env.ORCA_OPENCODE_CONFIG_DIR = dir
+ if (sourceDir) {
+ env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir
+ }
+ }
+ }
+ if (pluginOverlay.hasPiSource()) {
+ const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell)
+ const dir = pluginOverlay.materializePi(overlayId, sourceDir)
+ if (dir) {
+ env.PI_CODING_AGENT_DIR = dir
+ env.ORCA_PI_CODING_AGENT_DIR = dir
+ if (sourceDir) {
+ env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir
+ }
+ }
+ }
+ return env
+ })
+
+ // Why: evict the per-pane last-status cache AND any plugin overlay dirs
+ // when the backing PTY exits so terminated panes do not (a) resurface as
+ // ghost events after a later reconnect (§5 Path 3) or (b) leak overlay
+ // dirs on a long-lived relay.
+ ptyHandler.setExitListener(({ paneKey, id }) => {
if (paneKey) {
hookServer.clearPaneState(paneKey)
}
+ pluginOverlay.clearOverlay(paneKey ?? id)
})
// Why: request-driven replay. Orca issues this *after* it re-wires the
@@ -280,12 +339,31 @@ async function main(): Promise {
return { replayed }
})
- // Why: stub for the plugin-source sync handler used by OpenCode/Pi. The
- // real implementation is wired in commit #7 (deferred to keep this commit
- // tight). Stubbed out here as a method-found handler so a probing client
- // can detect support without -32601 noise on first connect.
- dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => {
- return { installed: false }
+ // Why: Orca ships the OpenCode plugin / Pi extension source bodies over
+ // the wire at session-ready (the renderer's bundled hook-service strings
+ // change as new agent events are added — pinning them to the relay binary
+ // would force a relay redeploy on every Orca update). Cache them so each
+ // subsequent PTY spawn can materialize a per-PTY overlay rooted under
+ // $HOME/.orca-relay/. See docs/design/agent-status-over-ssh.md §4.
+ // Why: bound the per-source size so a buggy/hostile Orca can't OOM the
+ // relay by pushing a giant string. The HTTP path has HOOK_REQUEST_MAX_BYTES
+ // = 1 MB; the JSON-RPC path needs an equivalent ceiling. Real plugin sources
+ // are <50 KB today; 256 KB leaves generous headroom.
+ dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => {
+ const opencode = params.opencodePluginSource
+ const pi = params.piExtensionSource
+ assertPluginSourceUnderByteCap('opencodePluginSource', opencode)
+ assertPluginSourceUnderByteCap('piExtensionSource', pi)
+ pluginOverlay.setSources({
+ opencodePluginSource: typeof opencode === 'string' ? opencode : undefined,
+ piExtensionSource: typeof pi === 'string' ? pi : undefined
+ })
+ return {
+ installed: {
+ opencode: pluginOverlay.hasOpenCodeSource(),
+ pi: pluginOverlay.hasPiSource()
+ }
+ }
})
// ── Socket server for reconnection ──────────────────────────────────
@@ -488,7 +566,7 @@ function cleanupSocket(sockPath: string): void {
void main().catch((err) => {
process.stderr.write(
- `[relay] Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n`
+ `[relay] Fatal startup error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`
)
process.exit(1)
})
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index f8469ee723d..f2887b30846 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -42,7 +42,9 @@ const MAX_WARNED_KEYS = 32
export const HOOK_REQUEST_SLOWLORIS_MS = 5_000
/** Bound paneKey size — `${tabId}:${paneId}` is well under 200 chars in
- * practice; cap defends per-pane caches against pathological input. */
+ * practice; cap defends per-pane caches against pathological input.
+ * Exported so non-HTTP ingest paths (e.g. Orca's `ingestRemote`) can apply
+ * the same cap as defense-in-depth. */
export const MAX_PANE_KEY_LEN = 200
/** Per-listener-instance state that holds caches needing per-PTY teardown
diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts
index 14747355cc0..75aae779999 100644
--- a/tests/e2e/ssh-localhost.spec.ts
+++ b/tests/e2e/ssh-localhost.spec.ts
@@ -1,5 +1,4 @@
import os from 'os'
-import path from 'path'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
@@ -204,27 +203,6 @@ test.describe('Localhost SSH', () => {
await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker))
await waitForTerminalOutput(orcaPage, terminalMarker, 20_000)
- // Why: managed hook scripts intentionally ignore Electron userData so
- // dev/prod Orca instances converge on the same installed command.
- const codexHookPath = path.join(os.homedir(), '.orca', 'agent-hooks', 'codex-hook.sh')
- const quotedCodexHookPath = shellQuote(codexHookPath)
- const codexHookStatus = await orcaPage.evaluate(() => window.api.agentHooks.codexStatus())
- expect(codexHookStatus.state).toBe('installed')
- const installMarker = marker('CODEX_HOOK_INSTALLED')
- const installFailedMarker = marker('CODEX_HOOK_INSTALL_FAILED')
- await execInTerminal(
- orcaPage,
- ptyId,
- [
- `if [ -x ${quotedCodexHookPath} ] && grep -F ${quotedCodexHookPath} "$HOME/.codex/hooks.json" >/dev/null 2>&1; then`,
- ` ${emitMarkerCommand(installMarker)}`,
- 'else',
- ` ${emitMarkerCommand(installFailedMarker)}`,
- 'fi'
- ].join('\n')
- )
- await waitForTerminalOutput(orcaPage, installMarker, 20_000)
-
const envMarker = marker('AGENT_HOOK_ENV_OK')
const envFailedMarker = marker('AGENT_HOOK_ENV_BAD')
await execInTerminal(
@@ -241,25 +219,44 @@ test.describe('Localhost SSH', () => {
)
await waitForTerminalOutput(orcaPage, envMarker, 20_000)
- const prompt = `orca ssh e2e prompt ${Date.now()}`
- const hookPostedMarker = marker('AGENT_HOOK_POSTED')
- const hookPayloadFile = `/tmp/orca-e2e-hook-payload-${Date.now()}.json`
+ const pluginOverlayMarker = marker('AGENT_PLUGIN_OVERLAYS_OK')
+ const pluginOverlayFailedMarker = marker('AGENT_PLUGIN_OVERLAYS_BAD')
await execInTerminal(
orcaPage,
ptyId,
[
- `if [ ! -x ${quotedCodexHookPath} ]; then`,
- ' echo __ORCA_CODEX_HOOK_SCRIPT_MISSING__',
- 'elif [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
+ 'opencode_status_file="$OPENCODE_CONFIG_DIR/plugins/orca-opencode-status.js"',
+ 'pi_status_file="$PI_CODING_AGENT_DIR/extensions/orca-agent-status.ts"',
+ 'if [ -n "$OPENCODE_CONFIG_DIR" ] && [ -f "$opencode_status_file" ] && [ -n "$PI_CODING_AGENT_DIR" ] && [ -f "$pi_status_file" ]; then',
+ ` ${emitMarkerCommand(pluginOverlayMarker)}`,
+ 'else',
+ ` printf '%s opencode=%s opencode_file=%s pi=%s pi_file=%s\\n' ${shellQuote(pluginOverlayFailedMarker)} "$OPENCODE_CONFIG_DIR" "$opencode_status_file" "$PI_CODING_AGENT_DIR" "$pi_status_file"`,
+ 'fi'
+ ].join('\n')
+ )
+ await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000)
+
+ const prompt = `orca ssh e2e prompt ${Date.now()}`
+ const hookPostedMarker = marker('AGENT_HOOK_POSTED')
+ await execInTerminal(
+ orcaPage,
+ ptyId,
+ [
+ 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
- ` printf '%s' ${shellQuote(
- JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt })
- )} > ${shellQuote(hookPayloadFile)}`,
- ` /bin/sh ${quotedCodexHookPath} < ${shellQuote(hookPayloadFile)}`,
- ' hook_status=$?',
- ` rm -f ${shellQuote(hookPayloadFile)}`,
- ` if [ "$hook_status" -eq 0 ]; then ${emitMarkerCommand(hookPostedMarker)}; fi`,
+ ` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`,
+ ' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
+ ' -H "Content-Type: application/x-www-form-urlencoded" \\',
+ ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
+ ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
+ ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
+ ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
+ ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
+ ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
+ ' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
+ ` ${emitMarkerCommand(hookPostedMarker)}`,
+ ' fi',
'fi'
].join('\n')
)
From cb58b109e0df43aa590fed44f9d1f430e3fe5c1b Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 14 May 2026 17:29:58 -0700
Subject: [PATCH 04/82] feat: add workspace space analyzer (#1877)
---
.gitignore | 3 +
.../macos-computer-use-permissions.test.ts | 27 +
.../macos-computer-use-permissions.ts | 45 +-
src/main/ipc/register-core-handlers.test.ts | 12 +-
src/main/ipc/register-core-handlers.ts | 2 +
src/main/ipc/workspace-space.test.ts | 69 ++
src/main/ipc/workspace-space.ts | 19 +
src/main/workspace-space-analysis.test.ts | 239 ++++++
src/main/workspace-space-analysis.ts | 689 ++++++++++++++++++
src/preload/api-types.ts | 4 +
src/preload/index.ts | 5 +
src/renderer/src/App.tsx | 27 +-
.../onboarding-feature-setup.test.ts | 4 +
.../components/settings/AppearancePane.tsx | 20 +-
.../components/settings/ComputerUsePane.tsx | 27 +-
.../sidebar/DeleteWorktreeDialog.tsx | 20 +-
.../sidebar/delete-worktree-flow.test.ts | 31 +-
.../sidebar/delete-worktree-flow.ts | 53 +-
src/renderer/src/components/sidebar/index.tsx | 2 -
.../status-bar/ResourceUsageStatusSegment.tsx | 24 +-
.../src/components/status-bar/StatusBar.tsx | 2 +-
.../status-bar/WorkspaceSpaceCompactPanel.tsx | 92 +++
.../status-bar/WorkspaceSpaceManagerPanel.tsx | 685 +++++++++++++++++
.../status-bar/mergeSnapshotAndSessions.ts | 2 +-
.../status-bar/workspace-space-format.test.ts | 21 +
.../status-bar/workspace-space-format.ts | 59 ++
.../status-bar/workspace-space-layout.test.ts | 24 +
.../status-bar/workspace-space-layout.ts | 124 ++++
.../workspace-space-presentation.test.ts | 79 ++
.../workspace-space-presentation.ts | 78 ++
.../workspace-space/WorkspaceSpacePage.tsx | 59 ++
src/renderer/src/hooks/resolve-zoom-target.ts | 2 +-
src/renderer/src/store/index.ts | 2 +
.../src/store/slices/diffComments.test.ts | 2 +
src/renderer/src/store/slices/memory.ts | 2 +-
.../slices/store-session-cascades.test.ts | 2 +
.../src/store/slices/store-test-helpers.ts | 2 +
src/renderer/src/store/slices/tabs.test.ts | 2 +
src/renderer/src/store/slices/ui.test.ts | 30 +
src/renderer/src/store/slices/ui.ts | 24 +-
.../src/store/slices/workspace-space.ts | 96 +++
src/renderer/src/store/types.ts | 2 +
src/shared/computer-use-permissions-types.ts | 2 +
src/shared/workspace-space-types.ts | 63 ++
44 files changed, 2682 insertions(+), 96 deletions(-)
create mode 100644 src/main/ipc/workspace-space.test.ts
create mode 100644 src/main/ipc/workspace-space.ts
create mode 100644 src/main/workspace-space-analysis.test.ts
create mode 100644 src/main/workspace-space-analysis.ts
create mode 100644 src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx
create mode 100644 src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
create mode 100644 src/renderer/src/components/status-bar/workspace-space-format.test.ts
create mode 100644 src/renderer/src/components/status-bar/workspace-space-format.ts
create mode 100644 src/renderer/src/components/status-bar/workspace-space-layout.test.ts
create mode 100644 src/renderer/src/components/status-bar/workspace-space-layout.ts
create mode 100644 src/renderer/src/components/status-bar/workspace-space-presentation.test.ts
create mode 100644 src/renderer/src/components/status-bar/workspace-space-presentation.ts
create mode 100644 src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx
create mode 100644 src/renderer/src/store/slices/workspace-space.ts
create mode 100644 src/shared/workspace-space-types.ts
diff --git a/.gitignore b/.gitignore
index 28e3868ff1c..2afd28d2a43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,3 +87,6 @@ playwright-report/
/.claude/skills/
/.agents/skills/
/skills-lock.json
+
+# Agent hook runtime endpoints (machine-local secrets)
+/agent-hooks/
diff --git a/src/main/computer/macos-computer-use-permissions.test.ts b/src/main/computer/macos-computer-use-permissions.test.ts
index 8345965c056..2f78b3cab23 100644
--- a/src/main/computer/macos-computer-use-permissions.test.ts
+++ b/src/main/computer/macos-computer-use-permissions.test.ts
@@ -135,6 +135,15 @@ describe('openComputerUsePermissions', () => {
expect(() => openComputerUsePermissions()).toThrow('Orca Computer Use.app was not found')
})
+ it('throws when the helper executable is missing during setup', () => {
+ resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
+ resolveHelperExecutablePathMock.mockReturnValue(null)
+
+ expect(() => openComputerUsePermissions('accessibility')).toThrow(
+ '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos was not found'
+ )
+ })
+
it('reads permission status through the helper app executable', async () => {
const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
resolveHelperAppPathMock.mockReturnValue('/Applications/Orca Computer Use.app')
@@ -142,6 +151,8 @@ describe('openComputerUsePermissions', () => {
expect(getComputerUsePermissionStatus()).toEqual({
platform: 'darwin',
+ helperAppPath: '/Applications/Orca Computer Use.app',
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'not-granted' }
@@ -153,6 +164,22 @@ describe('openComputerUsePermissions', () => {
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
})
+
+ it('returns unavailable permission status when the helper app is missing on macOS', async () => {
+ const { getComputerUsePermissionStatus } = await import('./macos-computer-use-permissions')
+ resolveHelperAppPathMock.mockReturnValue(null)
+
+ expect(getComputerUsePermissionStatus()).toEqual({
+ platform: 'darwin',
+ helperAppPath: null,
+ helperUnavailableReason: 'Orca Computer Use.app was not found',
+ permissions: [
+ { id: 'accessibility', status: 'not-granted' },
+ { id: 'screenshots', status: 'not-granted' }
+ ]
+ })
+ expect(execFileSync).not.toHaveBeenCalled()
+ })
})
function mockPermissionStatus(json: string): void {
diff --git a/src/main/computer/macos-computer-use-permissions.ts b/src/main/computer/macos-computer-use-permissions.ts
index 3de460eb29b..9ec40ef2d7d 100644
--- a/src/main/computer/macos-computer-use-permissions.ts
+++ b/src/main/computer/macos-computer-use-permissions.ts
@@ -34,6 +34,9 @@ export function openComputerUsePermissions(
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const status = getComputerUsePermissionStatus()
+ if (status.helperUnavailableReason) {
+ throw new RuntimeClientError('accessibility_error', status.helperUnavailableReason)
+ }
const nextStep = nextPermissionStep(status.permissions)
if (!permissionId && !nextStep) {
@@ -80,6 +83,8 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes
if (process.platform !== 'darwin') {
return {
platform: process.platform,
+ helperAppPath: null,
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'unsupported' },
{ id: 'screenshots', status: 'unsupported' }
@@ -89,13 +94,23 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes
const helperAppPath = resolveMacOSComputerUseAppPath()
if (!helperAppPath) {
- throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
+ return createUnavailablePermissionStatus('Orca Computer Use.app was not found', null)
}
- const raw = readPermissionStatusFromHelperApp(helperAppPath)
+ const executablePath = resolveMacOSComputerUseExecutablePath()
+ if (!executablePath) {
+ return createUnavailablePermissionStatus(
+ `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`,
+ helperAppPath
+ )
+ }
+
+ const raw = readPermissionStatusFromHelperExecutable(executablePath)
return {
platform: process.platform,
+ helperAppPath,
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: raw.accessibility ?? 'not-granted' },
{ id: 'screenshots', status: raw.screenshots ?? 'not-granted' }
@@ -103,16 +118,24 @@ export function getComputerUsePermissionStatus(): ComputerUsePermissionStatusRes
}
}
-function readPermissionStatusFromHelperApp(
- helperAppPath: string
-): Partial> {
- const executablePath = resolveMacOSComputerUseExecutablePath()
- if (!executablePath) {
- throw new RuntimeClientError(
- 'accessibility_error',
- `${helperAppPath}/Contents/MacOS/orca-computer-use-macos was not found`
- )
+function createUnavailablePermissionStatus(
+ reason: string,
+ helperAppPath: string | null
+): ComputerUsePermissionStatusResult {
+ return {
+ platform: process.platform,
+ helperAppPath,
+ helperUnavailableReason: reason,
+ permissions: [
+ { id: 'accessibility', status: 'not-granted' },
+ { id: 'screenshots', status: 'not-granted' }
+ ]
}
+}
+
+function readPermissionStatusFromHelperExecutable(
+ executablePath: string
+): Partial> {
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const output = execFileSync(executablePath, ['--permission-status'], {
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index 94897ad71dd..eb6fdd43d6a 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -41,7 +41,8 @@ const {
registerHostedReviewHandlersMock,
registerExportHandlersMock,
registerOnboardingHandlersMock,
- registerSpeechHandlersMock
+ registerSpeechHandlersMock,
+ registerWorkspaceSpaceHandlersMock
} = vi.hoisted(() => ({
registerCliHandlersMock: vi.fn(),
registerPreflightHandlersMock: vi.fn(),
@@ -81,7 +82,8 @@ const {
registerHostedReviewHandlersMock: vi.fn(),
registerExportHandlersMock: vi.fn(),
registerOnboardingHandlersMock: vi.fn(),
- registerSpeechHandlersMock: vi.fn()
+ registerSpeechHandlersMock: vi.fn(),
+ registerWorkspaceSpaceHandlersMock: vi.fn()
}))
vi.mock('./onboarding', () => ({
@@ -152,6 +154,10 @@ vi.mock('./settings', () => ({
registerSettingsHandlers: registerSettingsHandlersMock
}))
+vi.mock('./workspace-space', () => ({
+ registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock
+}))
+
vi.mock('./telemetry', () => ({
registerTelemetryHandlers: registerTelemetryHandlersMock
}))
@@ -273,6 +279,7 @@ describe('registerCoreHandlers', () => {
registerHostedReviewHandlersMock.mockReset()
registerExportHandlersMock.mockReset()
registerSpeechHandlersMock.mockReset()
+ registerWorkspaceSpaceHandlersMock.mockReset()
})
it('passes the store through to handler registrars that need it', () => {
@@ -315,6 +322,7 @@ describe('registerCoreHandlers', () => {
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store)
+ expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store)
expect(registerSessionHandlersMock).toHaveBeenCalledWith(store)
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index ca013544ad9..e445a8e659f 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -27,6 +27,7 @@ import { registerComputerUsePermissionHandlers } from './computer-use-permission
import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
+import { registerWorkspaceSpaceHandlers } from './workspace-space'
import { registerAutomationHandlers } from './automations'
import { registerTelemetryHandlers } from './telemetry'
import { registerBrowserHandlers } from './browser'
@@ -115,6 +116,7 @@ export function registerCoreHandlers(
registerPetHandlers()
registerSessionHandlers(store)
registerUIHandlers(store)
+ registerWorkspaceSpaceHandlers(store)
registerFilesystemHandlers(store)
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts
new file mode 100644
index 00000000000..24f590786d8
--- /dev/null
+++ b/src/main/ipc/workspace-space.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types'
+import type { Store } from '../persistence'
+
+const { handlers, analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock } = vi.hoisted(() => ({
+ handlers: new Map Promise>(),
+ analyzeWorkspaceSpaceMock: vi.fn(),
+ removeHandlerMock: vi.fn(),
+ handleMock: vi.fn((channel: string, handler: () => Promise) => {
+ handlers.set(channel, handler)
+ })
+}))
+
+vi.mock('electron', () => ({
+ ipcMain: {
+ removeHandler: removeHandlerMock,
+ handle: handleMock
+ }
+}))
+
+vi.mock('../workspace-space-analysis', () => ({
+ analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock
+}))
+
+import { registerWorkspaceSpaceHandlers } from './workspace-space'
+
+function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
+ return {
+ scannedAt,
+ totalSizeBytes: 0,
+ reclaimableBytes: 0,
+ worktreeCount: 0,
+ scannedWorktreeCount: 0,
+ unavailableWorktreeCount: 0,
+ repos: [],
+ worktrees: []
+ }
+}
+
+describe('registerWorkspaceSpaceHandlers', () => {
+ it('shares an in-flight analysis request', async () => {
+ const store = {} as Store
+ let resolveFirstScan: (analysis: WorkspaceSpaceAnalysis) => void = () => {}
+ const firstScan = new Promise((resolve) => {
+ resolveFirstScan = resolve
+ })
+ const secondScan = Promise.resolve(createAnalysis(2))
+ analyzeWorkspaceSpaceMock.mockReturnValueOnce(firstScan).mockReturnValueOnce(secondScan)
+
+ registerWorkspaceSpaceHandlers(store)
+ expect(removeHandlerMock).toHaveBeenCalledWith('workspaceSpace:analyze')
+
+ const handler = handlers.get('workspaceSpace:analyze')
+ expect(handler).toBeDefined()
+
+ const first = handler!()
+ const duplicate = handler!()
+ expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(1)
+ expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith(store)
+
+ const firstResult = createAnalysis(1)
+ resolveFirstScan(firstResult)
+ await expect(first).resolves.toBe(firstResult)
+ await expect(duplicate).resolves.toBe(firstResult)
+
+ await expect(handler!()).resolves.toEqual(createAnalysis(2))
+ expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts
new file mode 100644
index 00000000000..8302503bdf8
--- /dev/null
+++ b/src/main/ipc/workspace-space.ts
@@ -0,0 +1,19 @@
+import { ipcMain } from 'electron'
+import type { Store } from '../persistence'
+import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types'
+import { analyzeWorkspaceSpace } from '../workspace-space-analysis'
+
+export function registerWorkspaceSpaceHandlers(store: Store): void {
+ let inFlightScan: Promise | null = null
+ ipcMain.removeHandler('workspaceSpace:analyze')
+ ipcMain.handle('workspaceSpace:analyze', async (): Promise => {
+ if (!inFlightScan) {
+ // Why: large worktree fleets require real disk traversal; duplicate
+ // requests should share that IO instead of starting competing scans.
+ inFlightScan = analyzeWorkspaceSpace(store).finally(() => {
+ inFlightScan = null
+ })
+ }
+ return inFlightScan
+ })
+}
diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts
new file mode 100644
index 00000000000..041219501c8
--- /dev/null
+++ b/src/main/workspace-space-analysis.test.ts
@@ -0,0 +1,239 @@
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Repo } from '../shared/types'
+import type { Store } from './persistence'
+
+const { listRepoWorktreesMock, getSshFilesystemProviderMock, getSshGitProviderMock } = vi.hoisted(
+ () => ({
+ listRepoWorktreesMock: vi.fn(),
+ getSshFilesystemProviderMock: vi.fn(),
+ getSshGitProviderMock: vi.fn()
+ })
+)
+
+vi.mock('./repo-worktrees', () => ({
+ createFolderWorktree: (repo: Repo) => ({
+ path: repo.path,
+ head: '',
+ branch: '',
+ isBare: false,
+ isMainWorktree: true
+ }),
+ listRepoWorktrees: listRepoWorktreesMock
+}))
+
+vi.mock('./providers/ssh-filesystem-dispatch', () => ({
+ getSshFilesystemProvider: getSshFilesystemProviderMock
+}))
+
+vi.mock('./providers/ssh-git-dispatch', () => ({
+ getSshGitProvider: getSshGitProviderMock
+}))
+
+import { analyzeWorkspaceSpace } from './workspace-space-analysis'
+
+function createStore(repos: Repo[]): Store {
+ return {
+ getRepos: () => repos,
+ getWorktreeMeta: (worktreeId: string) => {
+ if (worktreeId.endsWith('feature')) {
+ return { displayName: 'Feature Workspace', lastActivityAt: 200 }
+ }
+ return undefined
+ }
+ } as Store
+}
+
+async function writeSizedFile(filePath: string, size: number): Promise {
+ await writeFile(filePath, Buffer.alloc(size, 1))
+}
+
+describe('analyzeWorkspaceSpace', () => {
+ let tempDir: string | null = null
+
+ beforeEach(async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-05-14T12:00:00Z'))
+ tempDir = await mkdtemp(join(tmpdir(), 'orca-space-'))
+ listRepoWorktreesMock.mockReset()
+ getSshFilesystemProviderMock.mockReset()
+ getSshGitProviderMock.mockReset()
+ })
+
+ afterEach(async () => {
+ vi.useRealTimers()
+ if (tempDir) {
+ await rm(tempDir, { recursive: true, force: true })
+ tempDir = null
+ }
+ })
+
+ it('scans local worktrees and counts only linked worktrees as reclaimable', async () => {
+ const root = tempDir!
+ const mainPath = join(root, 'repo')
+ const featurePath = join(root, 'feature')
+ await mkdir(join(mainPath, 'src'), { recursive: true })
+ await mkdir(join(featurePath, 'node_modules'), { recursive: true })
+ await mkdir(join(featurePath, 'src'), { recursive: true })
+ await writeSizedFile(join(mainPath, 'src', 'main.ts'), 256)
+ await writeSizedFile(join(featurePath, 'node_modules', 'pkg.js'), 2048)
+ await writeSizedFile(join(featurePath, 'src', 'feature.ts'), 512)
+
+ const repo: Repo = {
+ id: 'repo-1',
+ path: mainPath,
+ displayName: 'orca',
+ badgeColor: '#000',
+ addedAt: 0
+ }
+ listRepoWorktreesMock.mockResolvedValue([
+ {
+ path: mainPath,
+ head: 'a',
+ branch: 'refs/heads/main',
+ isBare: false,
+ isMainWorktree: true
+ },
+ {
+ path: featurePath,
+ head: 'b',
+ branch: 'refs/heads/feature',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ const result = await analyzeWorkspaceSpace(createStore([repo]))
+ const main = result.worktrees.find((row) => row.path === mainPath)
+ const feature = result.worktrees.find((row) => row.path === featurePath)
+
+ expect(result.scannedAt).toBe(Date.parse('2026-05-14T12:00:00Z'))
+ expect(result.worktreeCount).toBe(2)
+ expect(main?.status).toBe('ok')
+ expect(main?.canDelete).toBe(false)
+ expect(main?.reclaimableBytes).toBe(0)
+ expect(feature?.status).toBe('ok')
+ expect(feature?.displayName).toBe('Feature Workspace')
+ expect(feature?.canDelete).toBe(true)
+ expect(feature?.sizeBytes).toBeGreaterThanOrEqual(2048 + 512)
+ expect(feature?.reclaimableBytes).toBe(feature?.sizeBytes)
+ expect(feature?.topLevelItems[0]?.name).toBe('node_modules')
+ expect(result.reclaimableBytes).toBe(feature?.sizeBytes)
+ })
+
+ it('isolates missing worktrees as row-level scan failures', async () => {
+ const root = tempDir!
+ const repoPath = join(root, 'repo')
+ const missingPath = join(root, 'missing')
+ await mkdir(repoPath, { recursive: true })
+
+ const repo: Repo = {
+ id: 'repo-1',
+ path: repoPath,
+ displayName: 'orca',
+ badgeColor: '#000',
+ addedAt: 0
+ }
+ listRepoWorktreesMock.mockResolvedValue([
+ {
+ path: missingPath,
+ head: 'b',
+ branch: 'refs/heads/feature',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+
+ const result = await analyzeWorkspaceSpace(createStore([repo]))
+
+ expect(result.worktreeCount).toBe(1)
+ expect(result.scannedWorktreeCount).toBe(0)
+ expect(result.unavailableWorktreeCount).toBe(1)
+ expect(result.worktrees[0]?.status).toBe('missing')
+ expect(result.worktrees[0]?.sizeBytes).toBe(0)
+ })
+
+ it('scans SSH worktrees through the remote filesystem provider without following symlinks', async () => {
+ const repo: Repo = {
+ id: 'repo-remote',
+ path: '/remote/repo',
+ displayName: 'remote',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ getSshGitProviderMock.mockReturnValue({
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/feature',
+ head: 'c',
+ branch: 'refs/heads/feature',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ])
+ })
+
+ const statByPath = new Map([
+ ['/remote/feature', { size: 10, type: 'directory' as const, mtime: 0 }],
+ ['/remote/feature/node_modules', { size: 5, type: 'directory' as const, mtime: 0 }],
+ ['/remote/feature/node_modules/pkg.js', { size: 1000, type: 'file' as const, mtime: 0 }],
+ ['/remote/feature/file.log', { size: 200, type: 'file' as const, mtime: 0 }]
+ ])
+ const readDirMock = vi.fn(async (dirPath: string) => {
+ if (dirPath === '/remote/feature') {
+ return [
+ { name: 'node_modules', isDirectory: true, isSymlink: false },
+ { name: 'linked-cache', isDirectory: true, isSymlink: true },
+ { name: 'file.log', isDirectory: false, isSymlink: false }
+ ]
+ }
+ if (dirPath === '/remote/feature/node_modules') {
+ return [{ name: 'pkg.js', isDirectory: false, isSymlink: false }]
+ }
+ return []
+ })
+ const statMock = vi.fn(async (filePath: string) => {
+ const stat = statByPath.get(filePath)
+ if (!stat) {
+ throw Object.assign(new Error(`missing ${filePath}`), { code: 'ENOENT' })
+ }
+ return stat
+ })
+ getSshFilesystemProviderMock.mockReturnValue({
+ readDir: readDirMock,
+ stat: statMock
+ })
+
+ const result = await analyzeWorkspaceSpace(createStore([repo]))
+
+ expect(result.worktrees[0]?.status).toBe('ok')
+ expect(result.worktrees[0]?.sizeBytes).toBe(1215)
+ expect(result.worktrees[0]?.topLevelItems.map((item) => item.name)).toEqual([
+ 'node_modules',
+ 'file.log',
+ 'linked-cache'
+ ])
+ expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache')
+ })
+
+ it('reports disconnected SSH repos without failing the whole analysis', async () => {
+ const repo: Repo = {
+ id: 'repo-remote',
+ path: '/remote/repo',
+ displayName: 'remote',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'ssh-1'
+ }
+ getSshGitProviderMock.mockReturnValue(undefined)
+
+ const result = await analyzeWorkspaceSpace(createStore([repo]))
+
+ expect(result.worktrees).toEqual([])
+ expect(result.repos[0]?.error).toContain('not connected')
+ expect(result.unavailableWorktreeCount).toBe(1)
+ })
+})
diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts
new file mode 100644
index 00000000000..950f05c78bb
--- /dev/null
+++ b/src/main/workspace-space-analysis.ts
@@ -0,0 +1,689 @@
+/* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk
+ semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */
+import { lstat, readdir } from 'node:fs/promises'
+import { execFile } from 'node:child_process'
+import { basename, posix, win32 } from 'node:path'
+import { platform } from 'node:process'
+import { promisify } from 'node:util'
+import type { Dirent } from 'node:fs'
+import type { Store } from './persistence'
+import { isFolderRepo } from '../shared/repo-kind'
+import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types'
+import type {
+ WorkspaceSpaceAnalysis,
+ WorkspaceSpaceItem,
+ WorkspaceSpaceItemKind,
+ WorkspaceSpaceRepoSummary,
+ WorkspaceSpaceScanStatus,
+ WorkspaceSpaceWorktree
+} from '../shared/workspace-space-types'
+import type { IFilesystemProvider } from './providers/types'
+import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
+import { getSshGitProvider } from './providers/ssh-git-dispatch'
+import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
+import { mergeWorktree } from './ipc/worktree-logic'
+
+const MAX_TOP_LEVEL_ITEMS = 48
+const WORKTREE_SCAN_CONCURRENCY = 3
+const LOCAL_FS_CONCURRENCY = 48
+const REMOTE_FS_CONCURRENCY = 10
+const DU_TIMEOUT_MS = 120_000
+const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024
+const execFileAsync = promisify(execFile)
+
+type AsyncLimiter = (task: () => Promise) => Promise
+
+type ScanStats = {
+ name: string
+ path: string
+ kind: WorkspaceSpaceItemKind
+ sizeBytes: number
+ skippedEntryCount: number
+ children?: ScanStats[]
+}
+
+type WorktreeListResult =
+ | { ok: true; worktrees: GitWorktreeInfo[] }
+ | { ok: false; status: Exclude; error: string }
+
+type RepoScanResult = {
+ summary: WorkspaceSpaceRepoSummary
+ worktrees: WorkspaceSpaceWorktree[]
+}
+
+function createAsyncLimiter(maxConcurrent: number): AsyncLimiter {
+ let active = 0
+ const queue: (() => void)[] = []
+
+ const acquire = async (): Promise => {
+ if (active < maxConcurrent) {
+ active += 1
+ return
+ }
+ await new Promise((resolve) => queue.push(resolve))
+ active += 1
+ }
+
+ return async (task: () => Promise): Promise => {
+ await acquire()
+ try {
+ return await task()
+ } finally {
+ active -= 1
+ const next = queue.shift()
+ if (next) {
+ next()
+ }
+ }
+ }
+}
+
+async function mapLimit(
+ items: readonly T[],
+ maxConcurrent: number,
+ mapper: (item: T) => Promise
+): Promise {
+ const limit = createAsyncLimiter(maxConcurrent)
+ return Promise.all(items.map((item) => limit(() => mapper(item))))
+}
+
+function looksLikeWindowsPath(pathValue: string): boolean {
+ return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
+}
+
+function joinFilesystemPath(parent: string, child: string): string {
+ return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child)
+}
+
+function normalizeLocalDuPath(pathValue: string): string {
+ const trimmed = pathValue.replace(/[\\/]+$/, '')
+ return trimmed.length > 0 ? trimmed : pathValue
+}
+
+function parseDuDepthOneOutput(stdout: string): Map {
+ const sizes = new Map()
+ for (const line of stdout.split('\n')) {
+ const trimmed = line.trim()
+ if (!trimmed) {
+ continue
+ }
+ const match = /^(\d+)\s+(.+)$/.exec(trimmed)
+ if (!match) {
+ continue
+ }
+ sizes.set(normalizeLocalDuPath(match[2]), Number(match[1]) * 1024)
+ }
+ return sizes
+}
+
+async function readLocalDuDepthOne(rootPath: string): Promise> {
+ const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], {
+ encoding: 'utf8',
+ maxBuffer: DU_MAX_BUFFER_BYTES,
+ timeout: DU_TIMEOUT_MS
+ })
+ return parseDuDepthOneOutput(stdout)
+}
+
+function classifyError(error: unknown): {
+ status: Exclude
+ message: string
+} {
+ const code =
+ error && typeof error === 'object' && 'code' in error
+ ? String((error as { code?: unknown }).code)
+ : ''
+ const message = error instanceof Error ? error.message : String(error)
+
+ if (code === 'ENOENT' || code === 'ENOTDIR') {
+ return { status: 'missing', message }
+ }
+ if (code === 'EACCES' || code === 'EPERM') {
+ return { status: 'permission-denied', message }
+ }
+ return { status: 'error', message }
+}
+
+function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem {
+ return {
+ name: stats.name,
+ path: stats.path,
+ kind: stats.kind,
+ sizeBytes: stats.sizeBytes
+ }
+}
+
+function compactTopLevelItems(items: WorkspaceSpaceItem[]): {
+ topLevelItems: WorkspaceSpaceItem[]
+ omittedTopLevelItemCount: number
+ omittedTopLevelSizeBytes: number
+} {
+ const sorted = [...items].sort(
+ (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name)
+ )
+ if (sorted.length <= MAX_TOP_LEVEL_ITEMS) {
+ return {
+ topLevelItems: sorted,
+ omittedTopLevelItemCount: 0,
+ omittedTopLevelSizeBytes: 0
+ }
+ }
+
+ const visible = sorted.slice(0, MAX_TOP_LEVEL_ITEMS - 1)
+ const omitted = sorted.slice(MAX_TOP_LEVEL_ITEMS - 1)
+ const other = omitted.reduce(
+ (acc, item) => ({
+ ...acc,
+ sizeBytes: acc.sizeBytes + item.sizeBytes
+ }),
+ {
+ name: 'Other',
+ path: '',
+ kind: 'other',
+ sizeBytes: 0
+ }
+ )
+
+ return {
+ topLevelItems: [...visible, other],
+ omittedTopLevelItemCount: omitted.length,
+ omittedTopLevelSizeBytes: other.sizeBytes
+ }
+}
+
+function createBaseWorktreeRow(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number
+): Omit<
+ WorkspaceSpaceWorktree,
+ | 'status'
+ | 'error'
+ | 'sizeBytes'
+ | 'reclaimableBytes'
+ | 'skippedEntryCount'
+ | 'topLevelItems'
+ | 'omittedTopLevelItemCount'
+ | 'omittedTopLevelSizeBytes'
+> {
+ const canDelete = !worktree.isMainWorktree
+ return {
+ worktreeId: worktree.id,
+ repoId: repo.id,
+ repoDisplayName: repo.displayName,
+ repoPath: repo.path,
+ displayName: worktree.displayName,
+ path: worktree.path,
+ branch: worktree.branch,
+ isMainWorktree: worktree.isMainWorktree,
+ isRemote: Boolean(repo.connectionId),
+ isSparse: worktree.isSparse === true,
+ canDelete,
+ lastActivityAt: worktree.lastActivityAt,
+ scannedAt
+ }
+}
+
+function createUnavailableWorktreeRow(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number,
+ status: Exclude,
+ error: string
+): WorkspaceSpaceWorktree {
+ return {
+ ...createBaseWorktreeRow(repo, worktree, scannedAt),
+ status,
+ error,
+ sizeBytes: 0,
+ reclaimableBytes: 0,
+ skippedEntryCount: 0,
+ topLevelItems: [],
+ omittedTopLevelItemCount: 0,
+ omittedTopLevelSizeBytes: 0
+ }
+}
+
+async function scanLocalEntry(
+ entryPath: string,
+ name: string,
+ limit: AsyncLimiter
+): Promise {
+ const stats = await limit(() => lstat(entryPath))
+
+ if (stats.isSymbolicLink()) {
+ return {
+ name,
+ path: entryPath,
+ kind: 'symlink',
+ // Why: symlink targets may be shared outside the worktree. Counting the
+ // link itself reflects what deleting this worktree can actually reclaim.
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ if (!stats.isDirectory()) {
+ return {
+ name,
+ path: entryPath,
+ kind: 'file',
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ let entries: Dirent[]
+ try {
+ entries = await limit(() => readdir(entryPath, { withFileTypes: true }))
+ } catch {
+ return {
+ name,
+ path: entryPath,
+ kind: 'directory',
+ sizeBytes: stats.size,
+ skippedEntryCount: 1
+ }
+ }
+
+ const childStats = await Promise.all(
+ entries.map(async (entry): Promise => {
+ try {
+ return await scanLocalEntry(joinFilesystemPath(entryPath, entry.name), entry.name, limit)
+ } catch {
+ return null
+ }
+ })
+ )
+
+ let sizeBytes = stats.size
+ let skippedEntryCount = 0
+ for (const child of childStats) {
+ if (!child) {
+ skippedEntryCount += 1
+ continue
+ }
+ sizeBytes += child.sizeBytes
+ skippedEntryCount += child.skippedEntryCount
+ }
+
+ return {
+ name,
+ path: entryPath,
+ kind: 'directory',
+ sizeBytes,
+ skippedEntryCount,
+ children: childStats.filter((child): child is ScanStats => child !== null)
+ }
+}
+
+async function scanRemoteEntry(
+ entryPath: string,
+ name: string,
+ provider: IFilesystemProvider,
+ limit: AsyncLimiter,
+ knownSymlink = false
+): Promise {
+ if (knownSymlink) {
+ return {
+ name,
+ path: entryPath,
+ kind: 'symlink',
+ sizeBytes: 0,
+ skippedEntryCount: 0
+ }
+ }
+
+ const stats = await limit(() => provider.stat(entryPath))
+ if (stats.type === 'symlink') {
+ return {
+ name,
+ path: entryPath,
+ kind: 'symlink',
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ if (stats.type !== 'directory') {
+ return {
+ name,
+ path: entryPath,
+ kind: 'file',
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ let entries
+ try {
+ entries = await limit(() => provider.readDir(entryPath))
+ } catch {
+ return {
+ name,
+ path: entryPath,
+ kind: 'directory',
+ sizeBytes: stats.size,
+ skippedEntryCount: 1
+ }
+ }
+
+ const childStats = await Promise.all(
+ entries.map(async (entry): Promise => {
+ try {
+ return await scanRemoteEntry(
+ joinFilesystemPath(entryPath, entry.name),
+ entry.name,
+ provider,
+ limit,
+ entry.isSymlink
+ )
+ } catch {
+ return null
+ }
+ })
+ )
+
+ let sizeBytes = stats.size
+ let skippedEntryCount = 0
+ for (const child of childStats) {
+ if (!child) {
+ skippedEntryCount += 1
+ continue
+ }
+ sizeBytes += child.sizeBytes
+ skippedEntryCount += child.skippedEntryCount
+ }
+
+ return {
+ name,
+ path: entryPath,
+ kind: 'directory',
+ sizeBytes,
+ skippedEntryCount,
+ children: childStats.filter((child): child is ScanStats => child !== null)
+ }
+}
+
+async function scanLocalTopLevelEntry(
+ entryPath: string,
+ name: string,
+ duSizes: Map,
+ limit: AsyncLimiter
+): Promise {
+ const stats = await limit(() => lstat(entryPath))
+
+ if (stats.isSymbolicLink()) {
+ return {
+ name,
+ path: entryPath,
+ kind: 'symlink',
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ if (!stats.isDirectory()) {
+ return {
+ name,
+ path: entryPath,
+ kind: 'file',
+ sizeBytes: stats.size,
+ skippedEntryCount: 0
+ }
+ }
+
+ return {
+ name,
+ path: entryPath,
+ kind: 'directory',
+ sizeBytes: duSizes.get(normalizeLocalDuPath(entryPath)) ?? stats.size,
+ skippedEntryCount: 0
+ }
+}
+
+async function scanLocalWorktreeWithDu(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number
+): Promise {
+ const rootStats = await lstat(worktree.path)
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
+ const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY)
+ const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit)
+ const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem))
+ return {
+ ...createBaseWorktreeRow(repo, worktree, scannedAt),
+ status: 'ok',
+ error: null,
+ sizeBytes: root.sizeBytes,
+ reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes,
+ skippedEntryCount: root.skippedEntryCount,
+ ...compact
+ }
+ }
+
+ const [entries, duSizes] = await Promise.all([
+ readdir(worktree.path, { withFileTypes: true }),
+ readLocalDuDepthOne(worktree.path)
+ ])
+ const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY)
+ const childStats = await Promise.all(
+ entries.map(async (entry): Promise => {
+ try {
+ return await scanLocalTopLevelEntry(
+ joinFilesystemPath(worktree.path, entry.name),
+ entry.name,
+ duSizes,
+ limit
+ )
+ } catch {
+ return null
+ }
+ })
+ )
+ const children = childStats.filter((child): child is ScanStats => child !== null)
+ const skippedEntryCount = childStats.length - children.length
+ const rootSize =
+ duSizes.get(normalizeLocalDuPath(worktree.path)) ??
+ rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0)
+ const compact = compactTopLevelItems(children.map(toWorkspaceSpaceItem))
+
+ return {
+ ...createBaseWorktreeRow(repo, worktree, scannedAt),
+ status: 'ok',
+ error: null,
+ sizeBytes: rootSize,
+ reclaimableBytes: worktree.isMainWorktree ? 0 : rootSize,
+ skippedEntryCount,
+ ...compact
+ }
+}
+
+async function scanLocalWorktreeWithNode(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number
+): Promise {
+ try {
+ const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY)
+ const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit)
+ const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem))
+ return {
+ ...createBaseWorktreeRow(repo, worktree, scannedAt),
+ status: 'ok',
+ error: null,
+ sizeBytes: root.sizeBytes,
+ reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes,
+ skippedEntryCount: root.skippedEntryCount,
+ ...compact
+ }
+ } catch (error) {
+ const classified = classifyError(error)
+ return createUnavailableWorktreeRow(
+ repo,
+ worktree,
+ scannedAt,
+ classified.status,
+ classified.message
+ )
+ }
+}
+
+async function scanLocalWorktree(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number
+): Promise {
+ if (platform !== 'win32') {
+ try {
+ // Why: JS per-file stats are too slow for large local workspace fleets;
+ // POSIX du gives bounded top-level sizing without following symlinks.
+ return await scanLocalWorktreeWithDu(repo, worktree, scannedAt)
+ } catch {
+ // Fall through to the portable scanner so unsupported du variants or
+ // permission edge cases still produce partial rows instead of failing.
+ }
+ }
+ return scanLocalWorktreeWithNode(repo, worktree, scannedAt)
+}
+
+async function scanRemoteWorktree(
+ repo: Repo,
+ worktree: Worktree,
+ scannedAt: number,
+ provider: IFilesystemProvider
+): Promise {
+ try {
+ const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY)
+ const root = await scanRemoteEntry(worktree.path, basename(worktree.path), provider, limit)
+ const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem))
+ return {
+ ...createBaseWorktreeRow(repo, worktree, scannedAt),
+ status: 'ok',
+ error: null,
+ sizeBytes: root.sizeBytes,
+ reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes,
+ skippedEntryCount: root.skippedEntryCount,
+ ...compact
+ }
+ } catch (error) {
+ const classified = classifyError(error)
+ return createUnavailableWorktreeRow(
+ repo,
+ worktree,
+ scannedAt,
+ classified.status,
+ classified.message
+ )
+ }
+}
+
+async function listWorktreesForSpaceScan(repo: Repo): Promise {
+ try {
+ if (isFolderRepo(repo)) {
+ return { ok: true, worktrees: [createFolderWorktree(repo)] }
+ }
+ if (repo.connectionId) {
+ const provider = getSshGitProvider(repo.connectionId)
+ if (!provider) {
+ return {
+ ok: false,
+ status: 'unavailable',
+ error: `SSH connection "${repo.connectionId}" is not connected.`
+ }
+ }
+ return { ok: true, worktrees: await provider.listWorktrees(repo.path) }
+ }
+ return { ok: true, worktrees: await listRepoWorktrees(repo) }
+ } catch (error) {
+ const classified = classifyError(error)
+ return { ok: false, status: classified.status, error: classified.message }
+ }
+}
+
+function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree {
+ const worktreeId = `${repo.id}::${gitWorktree.path}`
+ return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName)
+}
+
+async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise {
+ const listed = await listWorktreesForSpaceScan(repo)
+ if (!listed.ok) {
+ return {
+ worktrees: [],
+ summary: {
+ repoId: repo.id,
+ displayName: repo.displayName,
+ path: repo.path,
+ isRemote: Boolean(repo.connectionId),
+ worktreeCount: 0,
+ scannedWorktreeCount: 0,
+ unavailableWorktreeCount: 1,
+ totalSizeBytes: 0,
+ reclaimableBytes: 0,
+ error: listed.error
+ }
+ }
+ }
+
+ const worktrees = listed.worktrees.map((gitWorktree) =>
+ mergeForSpaceScan(repo, gitWorktree, store)
+ )
+ const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined
+ const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
+ if (repo.connectionId) {
+ if (!remoteProvider) {
+ return createUnavailableWorktreeRow(
+ repo,
+ worktree,
+ scannedAt,
+ 'unavailable',
+ `SSH filesystem for "${repo.connectionId}" is not connected.`
+ )
+ }
+ return scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider)
+ }
+ return scanLocalWorktree(repo, worktree, scannedAt)
+ })
+
+ return {
+ worktrees: rows,
+ summary: {
+ repoId: repo.id,
+ displayName: repo.displayName,
+ path: repo.path,
+ isRemote: Boolean(repo.connectionId),
+ worktreeCount: rows.length,
+ scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length,
+ unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length,
+ totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
+ reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0),
+ error: null
+ }
+ }
+}
+
+export async function analyzeWorkspaceSpace(store: Store): Promise {
+ const scannedAt = Date.now()
+ const repoResults = await mapLimit(store.getRepos(), 2, (repo) =>
+ scanRepo(repo, scannedAt, store)
+ )
+ const repos = repoResults.map((result) => result.summary)
+ const worktrees = repoResults
+ .flatMap((result) => result.worktrees)
+ .sort((a, b) => b.sizeBytes - a.sizeBytes || a.displayName.localeCompare(b.displayName))
+
+ return {
+ scannedAt,
+ totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
+ reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0),
+ worktreeCount: worktrees.length,
+ scannedWorktreeCount: worktrees.filter((row) => row.status === 'ok').length,
+ unavailableWorktreeCount:
+ worktrees.filter((row) => row.status !== 'ok').length +
+ repos.filter((repo) => repo.error !== null).length,
+ repos,
+ worktrees
+ }
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index c988c5561af..f46f7e977ce 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -165,6 +165,7 @@ import type {
} from '../shared/claude-usage-types'
import type { RateLimitState } from '../shared/rate-limit-types'
import type { SpeechModelManifest, SpeechModelState } from '../shared/speech-types'
+import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
SshConnectionState,
@@ -526,6 +527,9 @@ export type PreloadApi = {
callback: (data: WorktreeRemoteBranchConflictEvent) => void
) => () => void
}
+ workspaceSpace: {
+ analyze: () => Promise
+ }
pty: {
spawn: (opts: {
cols: number
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 88bdf910a7c..c744b23d047 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -38,6 +38,7 @@ import type {
RuntimeMobileMarkdownResponse
} from '../shared/mobile-markdown-document'
import type { RateLimitState } from '../shared/rate-limit-types'
+import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types'
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
import type {
AddIssueCommentBySlugArgs,
@@ -489,6 +490,10 @@ const api = {
}
},
+ workspaceSpace: {
+ analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze')
+ },
+
pty: {
spawn: (opts: {
cols: number
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 68a809ddd05..b70bbbd11c0 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -49,6 +49,7 @@ import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSu
import { ZoomOverlay } from './components/ZoomOverlay'
import { shouldShowOnboarding } from './components/onboarding/should-show-onboarding'
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
+import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog'
import {
FloatingTerminalPanel,
FloatingTerminalToggleButton
@@ -159,6 +160,7 @@ const TaskPage = lazy(() => import('./components/TaskPage'))
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'))
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
const Settings = lazy(() => import('./components/settings/Settings'))
+const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage'))
const QuickOpen = lazy(() => import('./components/QuickOpen'))
const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette'))
const NewWorkspaceComposerModal = lazy(() => import('./components/NewWorkspaceComposerModal'))
@@ -802,10 +804,10 @@ function App(): React.JSX.Element {
activeWorktreeId !== null &&
!hasTabBar &&
effectiveActiveTabExpanded
- // Why: Activity is a full-page navigation surface — same treatment as
- // Settings — so the worktree sidebar is removed for that view, letting the
- // thread list + agent terminal span edge-to-edge.
- const showSidebar = activeView !== 'settings' && activeView !== 'activity'
+ // Why: Activity and Space are full-page navigation surfaces — same
+ // treatment as Settings — so the worktree sidebar is removed for those views.
+ const showSidebar =
+ activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
// Why: only the terminal workspace replaces the full-width titlebar with
// split-column chrome. Full-page navigation views keep the draggable app
// titlebar so their page-level controls can live in that window strip.
@@ -816,7 +818,8 @@ function App(): React.JSX.Element {
activeView !== 'settings' &&
activeView !== 'tasks' &&
activeView !== 'activity' &&
- activeView !== 'automations'
+ activeView !== 'automations' &&
+ activeView !== 'space'
const handleToggleExpand = (): void => {
if (!effectiveActiveTabId) {
@@ -903,7 +906,12 @@ function App(): React.JSX.Element {
// Why: full-page navigation surfaces should not reveal the right sidebar;
// they are designed as distraction-free content areas.
- if (activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') {
+ if (
+ activeView === 'tasks' ||
+ activeView === 'activity' ||
+ activeView === 'automations' ||
+ activeView === 'space'
+ ) {
return
}
@@ -1137,7 +1145,10 @@ function App(): React.JSX.Element {
useEffect(() => {
if (
- (activeView === 'tasks' || activeView === 'activity' || activeView === 'automations') &&
+ (activeView === 'tasks' ||
+ activeView === 'activity' ||
+ activeView === 'automations' ||
+ activeView === 'space') &&
rightSidebarOpen
) {
// Why: hide the right sidebar immediately when entering full-page
@@ -1307,6 +1318,7 @@ function App(): React.JSX.Element {
{activeView === 'tasks' ? : null}
{activeView === 'automations' ? : null}
{activeView === 'activity' ? : null}
+ {activeView === 'space' ? : null}
{activeView === 'terminal' && !activeWorktreeId ? : null}
@@ -1370,6 +1382,7 @@ function App(): React.JSX.Element {
+
{onboarding && shouldShowOnboarding(onboarding) ? (
diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
index 7fc319534ca..29c6a2fa58d 100644
--- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
+++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts
@@ -52,6 +52,8 @@ const INSTALLED_CLI_STATUS: CliInstallStatus = {
const GRANTED_COMPUTER_USE_STATUS: ComputerUsePermissionStatusResult = {
platform: 'darwin',
+ helperAppPath: '/Applications/Orca Computer Use.app',
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'granted' },
{ id: 'screenshots', status: 'granted' }
@@ -157,6 +159,8 @@ describe('onboarding feature setup runner', () => {
getComputerUsePermissionStatus: vi.fn(
async (): Promise => ({
platform: 'darwin',
+ helperAppPath: '/Applications/Orca Computer Use.app',
+ helperUnavailableReason: null,
permissions: [
{ id: 'accessibility', status: 'not-granted' },
{ id: 'screenshots', status: 'granted' }
diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx
index ad34aa40038..ee1d6567d75 100644
--- a/src/renderer/src/components/settings/AppearancePane.tsx
+++ b/src/renderer/src/components/settings/AppearancePane.tsx
@@ -61,23 +61,11 @@ const STATUS_BAR_TOGGLES: readonly {
},
{
id: 'resource-usage',
- title: 'Resource Usage',
- description: 'Show CPU, memory, and terminal session indicators in the status bar.',
- keywords: [
- 'status bar',
- 'resource',
- 'usage',
- 'memory',
- 'ram',
- 'cpu',
- 'terminal',
- 'sessions',
- 'pty',
- 'monitoring',
- 'performance'
- ],
+ title: 'Resource Manager',
+ description: 'Show CPU, memory, terminal sessions, and workspace disk usage in the status bar.',
+ keywords: ['status bar', 'resource', 'manager', 'memory', 'cpu', 'terminal', 'disk', 'space'],
toggleDescription:
- 'Show CPU, memory, and terminal session counts. Click it for a per-workspace breakdown and daemon controls.'
+ 'Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.'
}
]
diff --git a/src/renderer/src/components/settings/ComputerUsePane.tsx b/src/renderer/src/components/settings/ComputerUsePane.tsx
index 79bd613b37e..bf31a526a4b 100644
--- a/src/renderer/src/components/settings/ComputerUsePane.tsx
+++ b/src/renderer/src/components/settings/ComputerUsePane.tsx
@@ -67,20 +67,12 @@ function statusClass(status: ComputerUsePermissionStatus | undefined): string {
return 'border-border bg-muted text-muted-foreground'
}
-function isExpectedDevMissingHelperError(error: unknown): boolean {
- return (
- import.meta.env.DEV &&
- error instanceof Error &&
- error.message.includes('Orca Computer Use.app was not found')
- )
-}
-
export function ComputerUsePane(): React.JSX.Element {
const [platform, setPlatform] = useState(null)
const [states, setStates] = useState([])
const [loading, setLoading] = useState(true)
const [pendingId, setPendingId] = useState(null)
- const [helperUnavailableInDev, setHelperUnavailableInDev] = useState(false)
+ const [helperUnavailableReason, setHelperUnavailableReason] = useState(null)
const stateById = useMemo(
() => new Map(states.map((state) => [state.id, state.status] as const)),
@@ -93,14 +85,8 @@ export function ComputerUsePane(): React.JSX.Element {
const result = await window.api.computerUsePermissions.getStatus()
setPlatform(result.platform)
setStates(result.permissions)
- setHelperUnavailableInDev(false)
+ setHelperUnavailableReason(result.helperUnavailableReason)
} catch (error) {
- if (isExpectedDevMissingHelperError(error)) {
- setPlatform('darwin')
- setStates(PERMISSIONS.map((permission) => ({ id: permission.id, status: 'not-granted' })))
- setHelperUnavailableInDev(true)
- return
- }
toast.error(
error instanceof Error ? error.message : 'Could not load Computer Use permissions'
)
@@ -166,10 +152,9 @@ export function ComputerUsePane(): React.JSX.Element {
Computer Use needs macOS privacy permissions before agents can inspect and operate
app windows.
- {helperUnavailableInDev ? (
+ {helperUnavailableReason ? (
- Computer Use permissions are unavailable in this dev build because the helper app
- has not been built.
+ Computer Use permissions are unavailable because {helperUnavailableReason}.
) : null}
@@ -208,7 +193,9 @@ export function ComputerUsePane(): React.JSX.Element {
void openPermission(permission.id)}
className="shrink-0 gap-1.5"
>
diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
index 10c01efc75a..335a7f5b2c2 100644
--- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
+++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx
@@ -35,6 +35,10 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
: [],
[modalData.worktreeIds, worktreeId]
)
+ const onDeleted =
+ typeof modalData.onDeleted === 'function'
+ ? (modalData.onDeleted as (worktreeIds: string[]) => void)
+ : null
const worktree = useMemo(
() => (worktreeId ? (allWorktrees().find((item) => item.id === worktreeId) ?? null) : null),
[allWorktrees, worktreeId]
@@ -146,7 +150,9 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
toast.error('Force delete failed', {
description: result.error
})
+ return
}
+ onDeleted?.([worktreeId])
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
@@ -154,15 +160,23 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
})
})
} else {
- for (const target of worktrees) {
- runWorktreeDeleteWithToast(target.id, target.displayName)
- }
+ void Promise.all(
+ worktrees.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName))
+ ).then((results) => {
+ const deletedIds = worktrees
+ .filter((_, index) => results[index])
+ .map((target) => target.id)
+ if (deletedIds.length > 0) {
+ onDeleted?.(deletedIds)
+ }
+ })
}
closeModal()
},
[
closeModal,
dontAskAgain,
+ onDeleted,
persistDontAskAgainPreference,
removeWorktree,
worktreeIds.length,
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
index 35577dfca79..c562a9008b1 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
@@ -33,6 +33,7 @@ vi.mock('sonner', () => ({
}
}))
+import { toast } from 'sonner'
import { runWorktreeBatchDelete } from './delete-worktree-flow'
function setWorktrees(
@@ -57,14 +58,17 @@ describe('runWorktreeBatchDelete', () => {
mocks.state.openModal.mockClear()
mocks.state.removeWorktree.mockClear().mockResolvedValue({ ok: true })
mocks.state.deleteStateByWorktreeId = {}
+ vi.mocked(toast.error).mockClear()
+ vi.mocked(toast.info).mockClear()
setWorktrees([])
})
it('filters main worktrees and opens a batch confirmation for eligible targets', () => {
setWorktrees([{ id: 'main', isMainWorktree: true }, { id: 'wt-1' }, { id: 'wt-2' }])
- runWorktreeBatchDelete(['main', 'wt-1', 'wt-2'])
+ const started = runWorktreeBatchDelete(['main', 'wt-1', 'wt-2'])
+ expect(started).toBe(true)
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-1')
expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-2')
expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main')
@@ -76,22 +80,41 @@ describe('runWorktreeBatchDelete', () => {
it('opens the single-delete confirmation when only one target is eligible', () => {
setWorktrees([{ id: 'main', isMainWorktree: true }, { id: 'wt-1' }])
- runWorktreeBatchDelete(['main', 'wt-1'])
+ const started = runWorktreeBatchDelete(['main', 'wt-1'])
+ expect(started).toBe(true)
expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' })
})
- it('runs every eligible delete immediately when confirmation is skipped', () => {
+ it('runs every eligible delete immediately when confirmation is skipped', async () => {
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
setWorktrees([
{ id: 'wt-1', displayName: 'one' },
{ id: 'wt-2', displayName: 'two' }
])
+ const onDeleted = vi.fn()
- runWorktreeBatchDelete(['wt-1', 'wt-2'])
+ const started = runWorktreeBatchDelete(['wt-1', 'wt-2'], { onDeleted })
+ expect(started).toBe(true)
expect(mocks.state.openModal).not.toHaveBeenCalled()
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false)
expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-2', false)
+ await vi.waitFor(() => {
+ expect(onDeleted).toHaveBeenCalledWith(['wt-1', 'wt-2'])
+ })
+ })
+
+ it('reports when no selected worktrees are eligible', () => {
+ setWorktrees([{ id: 'main', isMainWorktree: true }])
+
+ const started = runWorktreeBatchDelete(['main', 'missing'])
+
+ expect(started).toBe(false)
+ expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalled()
+ expect(mocks.state.openModal).not.toHaveBeenCalled()
+ expect(toast.info).toHaveBeenCalledWith('No deletable workspaces selected', {
+ description: 'Refresh Space and try again if the workspace list looks stale.'
+ })
})
})
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
index d64d8a3fab7..e09200650cc 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts
@@ -5,6 +5,10 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { getDeleteWorktreeToastCopy } from './delete-worktree-toast'
import type { Worktree } from '../../../../shared/types'
+type WorktreeBatchDeleteOptions = {
+ onDeleted?: (worktreeIds: string[]) => void
+}
+
// Why: a failed delete almost always means the worktree still has changes
// that need attention (uncommitted work, unpushed commits, conflicts). The
// "View" affordance should surface those changes directly, not just bring
@@ -30,13 +34,16 @@ function viewWorktreeDiff(worktreeId: string): void {
* concerns into the store slice while still preventing the two delete
* entry points from drifting apart.
*/
-export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: string): void {
+export function runWorktreeDeleteWithToast(
+ worktreeId: string,
+ worktreeName: string
+): Promise {
const removeWorktree = useAppStore.getState().removeWorktree
- removeWorktree(worktreeId, false)
+ return removeWorktree(worktreeId, false)
.then((result) => {
if (result.ok) {
- return
+ return true
}
const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
const canForceDelete = state?.canForceDelete ?? false
@@ -80,11 +87,13 @@ export function runWorktreeDeleteWithToast(worktreeId: string, worktreeName: str
}
: undefined
})
+ return false
})
.catch((err: unknown) => {
toast.error('Failed to delete worktree', {
description: err instanceof Error ? err.message : String(err)
})
+ return false
})
}
@@ -122,13 +131,16 @@ export function runWorktreeDelete(worktreeId: string): void {
state.clearWorktreeDeleteState(worktreeId)
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
if (skipConfirm) {
- runWorktreeDeleteWithToast(worktreeId, target.displayName)
+ void runWorktreeDeleteWithToast(worktreeId, target.displayName)
return
}
state.openModal('delete-worktree', { worktreeId })
}
-export function runWorktreeBatchDelete(worktreeIds: readonly string[]): void {
+export function runWorktreeBatchDelete(
+ worktreeIds: readonly string[],
+ options: WorktreeBatchDeleteOptions = {}
+): boolean {
const state = useAppStore.getState()
const worktreeMap = getWorktreeMapFromState(state)
const targets = worktreeIds
@@ -136,7 +148,10 @@ export function runWorktreeBatchDelete(worktreeIds: readonly string[]): void {
.filter((worktree): worktree is Worktree => worktree != null && !worktree.isMainWorktree)
if (targets.length === 0) {
- return
+ toast.info('No deletable workspaces selected', {
+ description: 'Refresh Space and try again if the workspace list looks stale.'
+ })
+ return false
}
for (const target of targets) {
@@ -145,16 +160,28 @@ export function runWorktreeBatchDelete(worktreeIds: readonly string[]): void {
const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false
if (skipConfirm) {
- for (const target of targets) {
- runWorktreeDeleteWithToast(target.id, target.displayName)
- }
- return
+ void Promise.all(
+ targets.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName))
+ ).then((results) => {
+ const deletedIds = targets.filter((_, index) => results[index]).map((target) => target.id)
+ if (deletedIds.length > 0) {
+ options.onDeleted?.(deletedIds)
+ }
+ })
+ return true
}
if (targets.length === 1) {
- state.openModal('delete-worktree', { worktreeId: targets[0].id })
- return
+ state.openModal('delete-worktree', {
+ worktreeId: targets[0].id,
+ ...(options.onDeleted ? { onDeleted: options.onDeleted } : {})
+ })
+ return true
}
- state.openModal('delete-worktree', { worktreeIds: targets.map((target) => target.id) })
+ state.openModal('delete-worktree', {
+ worktreeIds: targets.map((target) => target.id),
+ ...(options.onDeleted ? { onDeleted: options.onDeleted } : {})
+ })
+ return true
}
diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx
index 51f9979a2f8..e06c40087fb 100644
--- a/src/renderer/src/components/sidebar/index.tsx
+++ b/src/renderer/src/components/sidebar/index.tsx
@@ -7,7 +7,6 @@ import SidebarNav from './SidebarNav'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorktreeMetaDialog from './WorktreeMetaDialog'
-import DeleteWorktreeDialog from './DeleteWorktreeDialog'
import NonGitFolderDialog from './NonGitFolderDialog'
import RemoveFolderDialog from './RemoveFolderDialog'
import AddRepoDialog from './AddRepoDialog'
@@ -64,7 +63,6 @@ function Sidebar(): React.JSX.Element {
{/* Dialog (rendered outside sidebar to avoid clipping) */}
-
diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
index 2908994204b..8a09b74d6df 100644
--- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
+++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
@@ -47,6 +47,7 @@ import {
type UnifiedSessionRow,
type UnifiedWorktreeRow
} from './mergeSnapshotAndSessions'
+import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel'
const POLL_MS = 2_000
const SESSIONS_POLL_MS = 10_000
@@ -654,6 +655,7 @@ export function ResourceUsageStatusSegment({
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const setActiveView = useAppStore((s) => s.setActiveView)
+ const openSpacePage = useAppStore((s) => s.openSpacePage)
const repos = useAppStore((s) => s.repos)
const [open, setOpen] = useState(false)
@@ -985,6 +987,11 @@ export function ResourceUsageStatusSegment({
}
}, [killConfirm, refreshSessions])
+ const openSpaceResults = useCallback((): void => {
+ setOpen(false)
+ openSpacePage()
+ }, [openSpacePage])
+
return (
@@ -993,7 +1000,7 @@ export function ResourceUsageStatusSegment({
{!iconOnly && (
@@ -1023,7 +1030,7 @@ export function ResourceUsageStatusSegment({
- Resource usage — {memBadgeLabel} · {sessions.length} session
+ Resource Manager — {memBadgeLabel} · {sessions.length} session
{sessions.length === 1 ? '' : 's'}
@@ -1032,7 +1039,7 @@ export function ResourceUsageStatusSegment({
side="top"
align="end"
sideOffset={8}
- className="w-[26rem] p-0"
+ className="w-[26rem] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
// Why: clicking a terminal row activates a tab, which causes xterm
// to programmatically focus the terminal DOM node. Radix would
@@ -1041,13 +1048,10 @@ export function ResourceUsageStatusSegment({
// outside-click (onPointerDownOutside default) and Escape.
onFocusOutside={(event) => event.preventDefault()}
>
- {/* Why: header strip — title on the left, daemon-control icons on
- the right. The tab switcher was removed because a single unified
- list now covers what the two tabs used to show. */}
-
-
-
Resource Usage
+
+
+ Resource Manager
@@ -1280,6 +1284,8 @@ export function ResourceUsageStatusSegment({
)}
+
+
{/* Why: Radix Dialog must not be a descendant of PopoverContent — when
the popover unmounts (e.g. clicking outside, focus moving to the
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx
index d33152a00ec..e5b935f5e02 100644
--- a/src/renderer/src/components/status-bar/StatusBar.tsx
+++ b/src/renderer/src/components/status-bar/StatusBar.tsx
@@ -985,7 +985,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
onCheckedChange={() => toggleStatusBarItem('resource-usage')}
>
- Resource Usage
+ Resource Manager
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx
new file mode 100644
index 00000000000..217816d5491
--- /dev/null
+++ b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx
@@ -0,0 +1,92 @@
+import { useCallback } from 'react'
+import { AlertTriangle, HardDrive, Loader2, RefreshCw } from 'lucide-react'
+import { useAppStore } from '../../store'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { formatBytes, getWorkspaceSpaceScanTimeLabel } from './workspace-space-format'
+
+export function WorkspaceSpaceCompactPanel({
+ onOpenFullPage
+}: {
+ onOpenFullPage: () => void
+}): React.JSX.Element {
+ const analysis = useAppStore((state) => state.workspaceSpaceAnalysis)
+ const scanError = useAppStore((state) => state.workspaceSpaceScanError)
+ const isScanning = useAppStore((state) => state.workspaceSpaceScanning)
+ const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace)
+
+ const scan = useCallback((): void => {
+ void refreshWorkspaceSpace().catch(() => {
+ /* scanError is stored by the slice */
+ })
+ }, [refreshWorkspaceSpace])
+
+ return (
+
+
+
+
+
+
+ Space
+
+ Beta
+
+
+
+ {analysis
+ ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces`
+ : isScanning
+ ? 'Scanning workspace sizes.'
+ : 'Workspace disk usage is not scanned.'}
+
+
+
+
+
+
+ {isScanning ? (
+
+ ) : (
+
+ )}
+ {isScanning ? 'Scanning' : analysis ? 'Refresh' : 'Scan'}
+
+
+ Open
+
+
+
+
+ {analysis ? (
+
+
+
Scanned
+
+ {formatBytes(analysis.totalSizeBytes)}
+
+
+
+
Freeable
+
+ {formatBytes(analysis.reclaimableBytes)}
+
+
+
+
Updated
+
+ {getWorkspaceSpaceScanTimeLabel(analysis.scannedAt)}
+
+
+
+ ) : null}
+
+ {scanError ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
new file mode 100644
index 00000000000..9c8452003da
--- /dev/null
+++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -0,0 +1,685 @@
+/* eslint-disable max-lines -- Why: the analyzer's private treemap, selection,
+ breakdown, and table pieces share one scan state and should evolve as one resource-manager surface. */
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ Check,
+ Circle,
+ GitBranch,
+ HardDrive,
+ Loader2,
+ RefreshCw,
+ Search,
+ Server,
+ Trash2
+} from 'lucide-react'
+import type {
+ WorkspaceSpaceItem,
+ WorkspaceSpaceWorktree
+} from '../../../../shared/workspace-space-types'
+import { cn } from '@/lib/utils'
+import { toast } from 'sonner'
+import { useAppStore } from '../../store'
+import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { Input } from '../ui/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
+import {
+ formatBytes,
+ formatCompactCount,
+ getWorkspaceSpaceBranchLabel,
+ getWorkspaceSpaceScanTimeLabel,
+ getWorkspaceSpaceStatusLabel
+} from './workspace-space-format'
+import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout'
+import {
+ filterWorkspaceSpaceRows,
+ getSelectedDeletableWorkspaceIds,
+ sortWorkspaceSpaceRows,
+ type WorkspaceSpaceSortDirection,
+ type WorkspaceSpaceSortKey
+} from './workspace-space-presentation'
+
+const TREEMAP_FILLS = [
+ 'color-mix(in srgb, var(--chart-2) 34%, var(--card))',
+ 'color-mix(in srgb, var(--foreground) 20%, var(--card))',
+ 'color-mix(in srgb, var(--chart-4) 28%, var(--card))',
+ 'color-mix(in srgb, var(--primary) 24%, var(--card))',
+ 'color-mix(in srgb, var(--chart-1) 38%, var(--card))'
+]
+
+function getTreemapFill(rect: TreemapRect, selected: boolean): string {
+ if (selected) {
+ return 'color-mix(in srgb, var(--ring) 40%, var(--card))'
+ }
+ return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length]
+}
+
+function Metric({ label, value }: { label: string; value: string }): React.JSX.Element {
+ return (
+
+
+ {label}
+
+
{value}
+
+ )
+}
+
+function CheckButton({
+ checked,
+ disabled,
+ label,
+ onClick
+}: {
+ checked: boolean
+ disabled?: boolean
+ label: string
+ onClick: () => void
+}): React.JSX.Element {
+ return (
+
{
+ event.stopPropagation()
+ onClick()
+ }}
+ className={cn(
+ 'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ checked
+ ? 'border-foreground bg-foreground text-background'
+ : 'border-muted-foreground/50 bg-background/40 text-transparent',
+ disabled && 'cursor-default opacity-35'
+ )}
+ >
+ {checked ? : null}
+
+ )
+}
+
+function SortIndicator({
+ sortKey,
+ activeKey,
+ direction
+}: {
+ sortKey: WorkspaceSpaceSortKey
+ activeKey: WorkspaceSpaceSortKey
+ direction: WorkspaceSpaceSortDirection
+}): React.JSX.Element {
+ if (sortKey !== activeKey) {
+ return
+ }
+ return direction === 'asc' ?
:
+}
+
+function StatusBadge({ worktree }: { worktree: WorkspaceSpaceWorktree }): React.JSX.Element {
+ if (worktree.status !== 'ok') {
+ return (
+
+ {getWorkspaceSpaceStatusLabel(worktree.status)}
+
+ )
+ }
+ if (worktree.isMainWorktree) {
+ return
Main
+ }
+ return
Deletable
+}
+
+function WorkspaceTreemap({
+ rows,
+ isScanning,
+ selectedWorktreeId,
+ onSelect
+}: {
+ rows: WorkspaceSpaceWorktree[]
+ isScanning: boolean
+ selectedWorktreeId: string | null
+ onSelect: (worktreeId: string) => void
+}): React.JSX.Element {
+ const rects = useMemo(
+ () =>
+ buildTreemapLayout(
+ rows
+ .filter((row) => row.status === 'ok' && row.sizeBytes > 0)
+ .map((row) => ({
+ id: row.worktreeId,
+ label: row.displayName,
+ sizeBytes: row.sizeBytes
+ }))
+ ),
+ [rows]
+ )
+
+ if (rects.length === 0) {
+ return (
+
+
+ {isScanning ? : null}
+ {isScanning
+ ? 'Scanning workspace sizes. You can leave this page.'
+ : 'No scanned workspace sizes yet.'}
+
+
+ )
+ }
+
+ return (
+
+ {rects.map((rect) => {
+ const area = rect.width * rect.height
+ const selected = rect.id === selectedWorktreeId
+ return (
+ onSelect(rect.id)}
+ className={cn(
+ 'absolute overflow-hidden border border-background/80 p-2 text-left transition-[filter,outline] hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ selected && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
+ )}
+ style={{
+ left: `${rect.x}%`,
+ top: `${rect.y}%`,
+ width: `${rect.width}%`,
+ height: `${rect.height}%`,
+ background: getTreemapFill(rect, selected)
+ }}
+ >
+ {area >= 80 ? (
+
+ {rect.label}
+ {area >= 180 ? (
+
+ {formatBytes(rect.sizeBytes)}
+
+ ) : null}
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
+
+function SizeBar({ value, max }: { value: number; max: number }): React.JSX.Element {
+ const pct = max > 0 ? Math.max(2, Math.min(100, (value / max) * 100)) : 0
+ return (
+
+ )
+}
+
+function BreakdownList({
+ worktree,
+ isScanning
+}: {
+ worktree: WorkspaceSpaceWorktree | null
+ isScanning: boolean
+}): React.JSX.Element {
+ if (!worktree) {
+ return (
+
+
+ {isScanning ? : null}
+ {isScanning
+ ? 'Scanning workspace sizes. You can leave this page.'
+ : 'Select a workspace to inspect.'}
+
+
+ )
+ }
+
+ const maxChildSize = Math.max(...worktree.topLevelItems.map((item) => item.sizeBytes), 0)
+ const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount
+ return (
+
+
+
+
+
{worktree.displayName}
+
+ {worktree.repoDisplayName}
+
+
+
+
+ {formatBytes(worktree.sizeBytes)}
+
+
+ {formatCompactCount(topLevelItemCount)} top-level items
+
+
+
+
+
+ {worktree.status !== 'ok' ? (
+
+
+
{worktree.error ?? 'Scan failed.'}
+
+ ) : worktree.topLevelItems.length === 0 ? (
+
No files found.
+ ) : (
+
+
+ {worktree.topLevelItems.slice(0, 12).map((item) => (
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function BreakdownRow({
+ item,
+ maxSize
+}: {
+ item: WorkspaceSpaceItem
+ maxSize: number
+}): React.JSX.Element {
+ return (
+
+
+ {item.name}
+
+ {formatBytes(item.sizeBytes)}
+
+
+
+
+ )
+}
+
+function WorkspaceRow({
+ worktree,
+ maxSize,
+ selected,
+ inspected,
+ onToggleSelected,
+ onInspect
+}: {
+ worktree: WorkspaceSpaceWorktree
+ maxSize: number
+ selected: boolean
+ inspected: boolean
+ onToggleSelected: () => void
+ onInspect: () => void
+}): React.JSX.Element {
+ return (
+
{
+ if (event.key !== 'Enter' && event.key !== ' ') {
+ return
+ }
+ event.preventDefault()
+ onInspect()
+ }}
+ className={cn(
+ 'grid w-full cursor-pointer grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] items-center gap-3 border-b border-border/45 px-3 py-2.5 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ inspected && 'bg-accent/55'
+ )}
+ >
+
+
+
+
+ {worktree.displayName}
+ {worktree.isRemote ? (
+
+ ) : null}
+ {worktree.isSparse ? Sparse : null}
+
+
+
+ {getWorkspaceSpaceBranchLabel(worktree)}
+
+
+ {worktree.path}
+
+
+
+
+
{worktree.repoDisplayName}
+
+ {worktree.repoPath}
+
+
+
+
+
+ {worktree.status === 'ok' ? formatBytes(worktree.sizeBytes) : '—'}
+
+
+
+
+
+
+
+
+ )
+}
+
+export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
+ const analysis = useAppStore((state) => state.workspaceSpaceAnalysis)
+ const scanError = useAppStore((state) => state.workspaceSpaceScanError)
+ const isScanning = useAppStore((state) => state.workspaceSpaceScanning)
+ const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace)
+ const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees)
+ const [query, setQuery] = useState('')
+ const [onlyDeletable, setOnlyDeletable] = useState(false)
+ const [sortKey, setSortKey] = useState
('size')
+ const [sortDirection, setSortDirection] = useState('desc')
+ const [selectedIds, setSelectedIds] = useState>(() => new Set())
+ const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null)
+
+ const refresh = useCallback((): void => {
+ void refreshWorkspaceSpace().catch(() => {
+ /* scanError is stored by the slice */
+ })
+ }, [refreshWorkspaceSpace])
+
+ const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
+
+ const rows = useMemo(
+ () =>
+ sortWorkspaceSpaceRows(
+ filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable),
+ sortKey,
+ sortDirection
+ ),
+ [onlyDeletable, query, sortDirection, sortKey, sourceRows]
+ )
+
+ const inspectedWorktree =
+ rows.find((row) => row.worktreeId === inspectedWorktreeId) ??
+ rows.find((row) => row.status === 'ok') ??
+ null
+ const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0)
+ const selectedDeletableIds = getSelectedDeletableWorkspaceIds(rows, selectedIds)
+ const visibleDeletableIds = rows
+ .filter((row) => row.canDelete && row.status === 'ok')
+ .map((row) => row.worktreeId)
+ const allVisibleSelected =
+ visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id))
+ const isInitialScan = isScanning && !analysis
+
+ useEffect(() => {
+ if (!analysis) {
+ setInspectedWorktreeId(null)
+ return
+ }
+ setInspectedWorktreeId((current) =>
+ current && analysis.worktrees.some((worktree) => worktree.worktreeId === current)
+ ? current
+ : (analysis.worktrees.find((worktree) => worktree.status === 'ok')?.worktreeId ?? null)
+ )
+ }, [analysis])
+
+ useEffect(() => {
+ setSelectedIds((current) => {
+ const valid = new Set(sourceRows.map((row) => row.worktreeId))
+ const next = new Set([...current].filter((id) => valid.has(id)))
+ return next.size === current.size ? current : next
+ })
+ }, [sourceRows])
+
+ const toggleSort = (key: WorkspaceSpaceSortKey): void => {
+ if (sortKey === key) {
+ setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
+ return
+ }
+ setSortKey(key)
+ setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc')
+ }
+
+ const toggleSelection = (worktreeId: string): void => {
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ if (next.has(worktreeId)) {
+ next.delete(worktreeId)
+ } else {
+ next.add(worktreeId)
+ }
+ return next
+ })
+ }
+
+ const toggleVisibleSelection = (): void => {
+ setSelectedIds((current) => {
+ const next = new Set(current)
+ if (allVisibleSelected) {
+ for (const id of visibleDeletableIds) {
+ next.delete(id)
+ }
+ } else {
+ for (const id of visibleDeletableIds) {
+ next.add(id)
+ }
+ }
+ return next
+ })
+ }
+
+ const deleteSelected = (): void => {
+ if (selectedDeletableIds.length === 0) {
+ return
+ }
+ const started = runWorktreeBatchDelete(selectedDeletableIds, {
+ onDeleted: (deletedIds) => {
+ removeWorkspaceSpaceWorktrees(deletedIds)
+ setInspectedWorktreeId((current) =>
+ current && deletedIds.includes(current) ? null : current
+ )
+ toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', {
+ description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.`
+ })
+ }
+ })
+ if (started) {
+ setSelectedIds(new Set())
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {analysis
+ ? `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.`
+ : isScanning
+ ? 'Scanning workspace sizes in the background. You can leave this page.'
+ : 'Run a scan to inspect workspace sizes.'}
+
+
+
+ {isScanning ? (
+
+ ) : (
+
+ )}
+ {isScanning ? 'Scanning' : analysis ? 'Refresh' : 'Scan'}
+
+
+
+ {scanError ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ placeholder="Filter workspaces"
+ className="pl-9"
+ />
+
+
+
setSortKey(value as WorkspaceSpaceSortKey)}
+ >
+
+
+
+
+ Size
+ Name
+ Repository
+ Activity
+
+
+
+
setOnlyDeletable((current) => !current)}
+ className="w-32"
+ >
+ {onlyDeletable ? 'Deletable' : 'All'}
+
+
+
+
+ {allVisibleSelected ? 'Clear' : 'Select'}
+
+
+
+
+ {selectedDeletableIds.length > 0 ? `Delete ${selectedDeletableIds.length}` : 'Delete'}
+
+
+
+
+
+
+
toggleSort('name')}
+ className="flex items-center gap-1 text-left"
+ >
+ Workspace
+
+
+
toggleSort('repo')}
+ className="flex items-center gap-1 text-left"
+ >
+ Repository
+
+
+
toggleSort('size')}
+ className="flex items-center justify-end gap-1 text-right"
+ >
+ Size
+
+
+
State
+
+
+
+ {isInitialScan ? (
+
+
+ Scanning workspaces. You can leave this page.
+
+ ) : rows.length === 0 ? (
+
+ No matching workspaces.
+
+ ) : (
+ rows.map((worktree) => (
+
toggleSelection(worktree.worktreeId)}
+ onInspect={() => setInspectedWorktreeId(worktree.worktreeId)}
+ />
+ ))
+ )}
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
index 665b3b620db..8bf4c3e9e93 100644
--- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
+++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.ts
@@ -4,7 +4,7 @@
in ResourceUsageStatusSegment.tsx. Splitting would scatter logic that has
exactly one consumer. See docs/resource-usage-merge-spec.md. */
/**
- * Resource Usage popover merge helper.
+ * Resource Manager popover merge helper.
*
* Produces a single grouped list (repo → worktree → session) by unifying:
*
diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts
new file mode 100644
index 00000000000..9f239b0e075
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import {
+ formatBytes,
+ formatCompactCount,
+ getWorkspaceSpaceStatusLabel
+} from './workspace-space-format'
+
+describe('workspace space format helpers', () => {
+ it('formats byte values with stable units', () => {
+ expect(formatBytes(0)).toBe('0 B')
+ expect(formatBytes(512)).toBe('512 B')
+ expect(formatBytes(1536)).toBe('1.50 KB')
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB')
+ })
+
+ it('formats counts and statuses for dense table UI', () => {
+ expect(formatCompactCount(1530)).toBe('1.5k')
+ expect(formatCompactCount(25_000)).toBe('25k')
+ expect(getWorkspaceSpaceStatusLabel('permission-denied')).toBe('No access')
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts
new file mode 100644
index 00000000000..9566e4e3911
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-format.ts
@@ -0,0 +1,59 @@
+import type {
+ WorkspaceSpaceScanStatus,
+ WorkspaceSpaceWorktree
+} from '../../../../shared/workspace-space-types'
+
+const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
+
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) {
+ return '0 B'
+ }
+
+ let value = bytes
+ let unitIndex = 0
+ while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
+ value /= 1024
+ unitIndex += 1
+ }
+
+ const precision = value >= 100 || unitIndex === 0 ? 0 : value >= 10 ? 1 : 2
+ return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`
+}
+
+export function formatCompactCount(count: number): string {
+ if (!Number.isFinite(count) || count <= 0) {
+ return '0'
+ }
+ if (count < 1000) {
+ return String(count)
+ }
+ if (count < 1_000_000) {
+ return `${(count / 1000).toFixed(count >= 10_000 ? 0 : 1)}k`
+ }
+ return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}m`
+}
+
+export function getWorkspaceSpaceScanTimeLabel(scannedAt: number): string {
+ return new Date(scannedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
+}
+
+export function getWorkspaceSpaceStatusLabel(status: WorkspaceSpaceScanStatus): string {
+ switch (status) {
+ case 'ok':
+ return 'Scanned'
+ case 'missing':
+ return 'Missing'
+ case 'permission-denied':
+ return 'No access'
+ case 'unavailable':
+ return 'Unavailable'
+ case 'error':
+ return 'Failed'
+ }
+}
+
+export function getWorkspaceSpaceBranchLabel(worktree: WorkspaceSpaceWorktree): string {
+ const branch = worktree.branch.replace(/^refs\/heads\//, '').trim()
+ return branch || (worktree.isMainWorktree ? 'main worktree' : 'detached')
+}
diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.test.ts b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts
new file mode 100644
index 00000000000..d1857a17e74
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-layout.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { buildTreemapLayout } from './workspace-space-layout'
+
+describe('buildTreemapLayout', () => {
+ it('lays out positive-size items inside a 100x100 treemap', () => {
+ const rects = buildTreemapLayout([
+ { id: 'a', label: 'A', sizeBytes: 60 },
+ { id: 'b', label: 'B', sizeBytes: 30 },
+ { id: 'c', label: 'C', sizeBytes: 10 },
+ { id: 'empty', label: 'Empty', sizeBytes: 0 }
+ ])
+
+ expect(rects.map((rect) => rect.id)).toEqual(['a', 'b', 'c'])
+ for (const rect of rects) {
+ expect(rect.x).toBeGreaterThanOrEqual(0)
+ expect(rect.y).toBeGreaterThanOrEqual(0)
+ expect(rect.x + rect.width).toBeLessThanOrEqual(100)
+ expect(rect.y + rect.height).toBeLessThanOrEqual(100)
+ }
+ const area = rects.reduce((sum, rect) => sum + rect.width * rect.height, 0)
+ expect(area).toBeCloseTo(10_000, 5)
+ expect(rects[0].width * rects[0].height).toBeGreaterThan(rects[1].width * rects[1].height)
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-layout.ts b/src/renderer/src/components/status-bar/workspace-space-layout.ts
new file mode 100644
index 00000000000..18f739ad99f
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-layout.ts
@@ -0,0 +1,124 @@
+export type TreemapInput = {
+ id: string
+ label: string
+ sizeBytes: number
+}
+
+export type TreemapRect = TreemapInput & {
+ x: number
+ y: number
+ width: number
+ height: number
+ depth: number
+ index: number
+}
+
+type Bounds = {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+function sumSizes(items: readonly TreemapInput[]): number {
+ return items.reduce((sum, item) => sum + Math.max(0, item.sizeBytes), 0)
+}
+
+function splitBalanced(items: readonly TreemapInput[]): {
+ first: TreemapInput[]
+ second: TreemapInput[]
+} {
+ const total = sumSizes(items)
+ if (items.length <= 1 || total <= 0) {
+ return { first: [...items], second: [] }
+ }
+
+ const target = total / 2
+ let running = 0
+ let splitIndex = 0
+ for (let index = 0; index < items.length; index += 1) {
+ const next = running + Math.max(0, items[index].sizeBytes)
+ if (index > 0 && Math.abs(target - running) < Math.abs(target - next)) {
+ break
+ }
+ running = next
+ splitIndex = index + 1
+ }
+
+ splitIndex = Math.min(items.length - 1, Math.max(1, splitIndex))
+ return {
+ first: items.slice(0, splitIndex),
+ second: items.slice(splitIndex)
+ }
+}
+
+function layoutTreemapRecursive(
+ items: readonly TreemapInput[],
+ bounds: Bounds,
+ depth: number,
+ output: TreemapRect[]
+): void {
+ if (items.length === 0 || bounds.width <= 0 || bounds.height <= 0) {
+ return
+ }
+
+ if (items.length === 1) {
+ const item = items[0]
+ output.push({
+ ...item,
+ ...bounds,
+ depth,
+ index: output.length
+ })
+ return
+ }
+
+ const total = sumSizes(items)
+ if (total <= 0) {
+ return
+ }
+
+ const { first, second } = splitBalanced(items)
+ const firstSize = sumSizes(first)
+ const ratio = firstSize / total
+
+ if (bounds.width >= bounds.height) {
+ const firstWidth = bounds.width * ratio
+ layoutTreemapRecursive(first, { ...bounds, width: firstWidth }, depth + 1, output)
+ layoutTreemapRecursive(
+ second,
+ {
+ x: bounds.x + firstWidth,
+ y: bounds.y,
+ width: bounds.width - firstWidth,
+ height: bounds.height
+ },
+ depth + 1,
+ output
+ )
+ return
+ }
+
+ const firstHeight = bounds.height * ratio
+ layoutTreemapRecursive(first, { ...bounds, height: firstHeight }, depth + 1, output)
+ layoutTreemapRecursive(
+ second,
+ {
+ x: bounds.x,
+ y: bounds.y + firstHeight,
+ width: bounds.width,
+ height: bounds.height - firstHeight
+ },
+ depth + 1,
+ output
+ )
+}
+
+export function buildTreemapLayout(items: readonly TreemapInput[]): TreemapRect[] {
+ const filtered = items
+ .filter((item) => item.sizeBytes > 0)
+ .sort((a, b) => b.sizeBytes - a.sizeBytes || a.label.localeCompare(b.label))
+ const output: TreemapRect[] = []
+ layoutTreemapRecursive(filtered, { x: 0, y: 0, width: 100, height: 100 }, 0, output)
+ return output
+}
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts
new file mode 100644
index 00000000000..7c386b07867
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vitest'
+import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
+import {
+ filterWorkspaceSpaceRows,
+ getSelectedDeletableWorkspaceIds,
+ sortWorkspaceSpaceRows
+} from './workspace-space-presentation'
+
+function row(overrides: Partial): WorkspaceSpaceWorktree {
+ return {
+ worktreeId: 'wt',
+ repoId: 'repo',
+ repoDisplayName: 'repo',
+ repoPath: '/repo',
+ displayName: 'workspace',
+ path: '/workspace',
+ branch: 'refs/heads/main',
+ isMainWorktree: false,
+ isRemote: false,
+ isSparse: false,
+ canDelete: true,
+ lastActivityAt: 0,
+ status: 'ok',
+ error: null,
+ scannedAt: 0,
+ sizeBytes: 0,
+ reclaimableBytes: 0,
+ skippedEntryCount: 0,
+ topLevelItems: [],
+ omittedTopLevelItemCount: 0,
+ omittedTopLevelSizeBytes: 0,
+ ...overrides
+ }
+}
+
+describe('workspace space presentation helpers', () => {
+ it('sorts rows by the selected key and direction', () => {
+ const rows = [
+ row({ worktreeId: 'small', displayName: 'Small', sizeBytes: 10 }),
+ row({ worktreeId: 'large', displayName: 'Large', sizeBytes: 100 }),
+ row({ worktreeId: 'mid', displayName: 'Mid', sizeBytes: 50 })
+ ]
+
+ expect(sortWorkspaceSpaceRows(rows, 'size', 'desc').map((item) => item.worktreeId)).toEqual([
+ 'large',
+ 'mid',
+ 'small'
+ ])
+ expect(sortWorkspaceSpaceRows(rows, 'name', 'asc').map((item) => item.worktreeId)).toEqual([
+ 'large',
+ 'mid',
+ 'small'
+ ])
+ })
+
+ it('filters by search text and deletable status', () => {
+ const rows = [
+ row({ worktreeId: 'a', displayName: 'Frontend Cache', repoDisplayName: 'app' }),
+ row({ worktreeId: 'b', displayName: 'Main', repoDisplayName: 'api', canDelete: false })
+ ]
+
+ expect(filterWorkspaceSpaceRows(rows, 'cache', false).map((item) => item.worktreeId)).toEqual([
+ 'a'
+ ])
+ expect(filterWorkspaceSpaceRows(rows, '', true).map((item) => item.worktreeId)).toEqual(['a'])
+ })
+
+ it('returns only selected worktrees that can be deleted', () => {
+ const rows = [
+ row({ worktreeId: 'ok', canDelete: true, status: 'ok' }),
+ row({ worktreeId: 'main', canDelete: false, status: 'ok' }),
+ row({ worktreeId: 'failed', canDelete: true, status: 'error' })
+ ]
+
+ expect(getSelectedDeletableWorkspaceIds(rows, new Set(['ok', 'main', 'failed']))).toEqual([
+ 'ok'
+ ])
+ })
+})
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
new file mode 100644
index 00000000000..4dd3c1d5059
--- /dev/null
+++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts
@@ -0,0 +1,78 @@
+import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
+
+export type WorkspaceSpaceSortKey = 'size' | 'name' | 'repo' | 'activity'
+export type WorkspaceSpaceSortDirection = 'asc' | 'desc'
+
+export function getWorkspaceSpaceSearchText(worktree: WorkspaceSpaceWorktree): string {
+ return [
+ worktree.displayName,
+ worktree.repoDisplayName,
+ worktree.path,
+ worktree.branch,
+ worktree.status
+ ]
+ .join(' ')
+ .toLowerCase()
+}
+
+function compareRows(
+ left: WorkspaceSpaceWorktree,
+ right: WorkspaceSpaceWorktree,
+ sortKey: WorkspaceSpaceSortKey
+): number {
+ switch (sortKey) {
+ case 'size':
+ return left.sizeBytes - right.sizeBytes
+ case 'name':
+ return left.displayName.localeCompare(right.displayName)
+ case 'repo':
+ return (
+ left.repoDisplayName.localeCompare(right.repoDisplayName) ||
+ left.displayName.localeCompare(right.displayName)
+ )
+ case 'activity':
+ return left.lastActivityAt - right.lastActivityAt
+ }
+}
+
+export function sortWorkspaceSpaceRows(
+ rows: readonly WorkspaceSpaceWorktree[],
+ sortKey: WorkspaceSpaceSortKey,
+ direction: WorkspaceSpaceSortDirection
+): WorkspaceSpaceWorktree[] {
+ const multiplier = direction === 'asc' ? 1 : -1
+ return [...rows].sort((left, right) => {
+ const primary = compareRows(left, right, sortKey) * multiplier
+ return (
+ primary ||
+ right.sizeBytes - left.sizeBytes ||
+ left.displayName.localeCompare(right.displayName)
+ )
+ })
+}
+
+export function filterWorkspaceSpaceRows(
+ rows: readonly WorkspaceSpaceWorktree[],
+ query: string,
+ onlyDeletable: boolean
+): WorkspaceSpaceWorktree[] {
+ const normalizedQuery = query.trim().toLowerCase()
+ return rows.filter((row) => {
+ if (onlyDeletable && !row.canDelete) {
+ return false
+ }
+ if (!normalizedQuery) {
+ return true
+ }
+ return getWorkspaceSpaceSearchText(row).includes(normalizedQuery)
+ })
+}
+
+export function getSelectedDeletableWorkspaceIds(
+ rows: readonly WorkspaceSpaceWorktree[],
+ selectedIds: ReadonlySet
+): string[] {
+ return rows
+ .filter((row) => row.canDelete && row.status === 'ok' && selectedIds.has(row.worktreeId))
+ .map((row) => row.worktreeId)
+}
diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx
new file mode 100644
index 00000000000..0fed37c3afa
--- /dev/null
+++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx
@@ -0,0 +1,59 @@
+import { useEffect } from 'react'
+import { ArrowLeft, HardDrive } from 'lucide-react'
+import { Badge } from '../ui/badge'
+import { Button } from '../ui/button'
+import { WorkspaceSpaceManagerPanel } from '../status-bar/WorkspaceSpaceManagerPanel'
+import { useAppStore } from '../../store'
+
+export default function WorkspaceSpacePage(): React.JSX.Element {
+ const closeSpacePage = useAppStore((state) => state.closeSpacePage)
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return
+ }
+ // Why: confirmation dialogs own Escape first; page-level Escape should
+ // only leave the full Space surface when no modal is active.
+ if (document.querySelector('[role="dialog"]')) {
+ return
+ }
+ event.preventDefault()
+ closeSpacePage()
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [closeSpacePage])
+
+ return (
+
+
+
+
+ Back
+
+
+
+
+
+
+
+
Space
+ Beta
+
+
+ Workspace disk usage and reclaimable worktree storage.
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts
index 255fbf386ce..f03b3786cd0 100644
--- a/src/renderer/src/hooks/resolve-zoom-target.ts
+++ b/src/renderer/src/hooks/resolve-zoom-target.ts
@@ -3,7 +3,7 @@
* based on current view, tab type, and focused element.
*/
export function resolveZoomTarget(args: {
- activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
+ activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space'
activeTabType: 'terminal' | 'editor' | 'browser' | 'notes'
activeElement: unknown
}): 'terminal' | 'editor' | 'ui' {
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index d7e07344be9..76a8c91ed87 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -13,6 +13,7 @@ import { createLinearSlice } from './slices/linear'
import { createEditorSlice } from './slices/editor'
import { createStatsSlice } from './slices/stats'
import { createMemorySlice } from './slices/memory'
+import { createWorkspaceSpaceSlice } from './slices/workspace-space'
import { createClaudeUsageSlice } from './slices/claude-usage'
import { createCodexUsageSlice } from './slices/codex-usage'
import { createBrowserSlice } from './slices/browser'
@@ -40,6 +41,7 @@ export const useAppStore = create()((...a) => ({
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+ ...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createBrowserSlice(...a),
diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts
index b989a7667dd..827d9e3831e 100644
--- a/src/renderer/src/store/slices/diffComments.test.ts
+++ b/src/renderer/src/store/slices/diffComments.test.ts
@@ -85,6 +85,7 @@ import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
+import { createWorkspaceSpaceSlice } from './workspace-space'
import { createClaudeUsageSlice } from './claude-usage'
import { createCodexUsageSlice } from './codex-usage'
import { createBrowserSlice } from './browser'
@@ -111,6 +112,7 @@ function createTestStore() {
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+ ...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createBrowserSlice(...a),
diff --git a/src/renderer/src/store/slices/memory.ts b/src/renderer/src/store/slices/memory.ts
index b24de6d0c89..a011adaf87c 100644
--- a/src/renderer/src/store/slices/memory.ts
+++ b/src/renderer/src/store/slices/memory.ts
@@ -17,7 +17,7 @@ export const createMemorySlice: StateCreator = (s
const snapshot = await window.api.memory.getSnapshot()
set({ memorySnapshot: snapshot, memorySnapshotError: null })
} catch (err) {
- // Why: the always-on Resource Usage status-bar segment needs to know when
+ // Why: the always-on Resource Manager status-bar segment needs to know when
// the snapshot IPC is failing so it can surface a "daemon not responding"
// banner with a Restart CTA. Prior code only console.error'd.
console.error('Failed to fetch memory snapshot:', err)
diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts
index 80495de0ceb..2e86742c240 100644
--- a/src/renderer/src/store/slices/store-session-cascades.test.ts
+++ b/src/renderer/src/store/slices/store-session-cascades.test.ts
@@ -102,6 +102,7 @@ import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
+import { createWorkspaceSpaceSlice } from './workspace-space'
import { createClaudeUsageSlice } from './claude-usage'
import { createCodexUsageSlice } from './codex-usage'
import { createBrowserSlice } from './browser'
@@ -128,6 +129,7 @@ function createTestStore() {
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+ ...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createBrowserSlice(...a),
diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts
index 21b9fff0da4..e4c43e4dfb8 100644
--- a/src/renderer/src/store/slices/store-test-helpers.ts
+++ b/src/renderer/src/store/slices/store-test-helpers.ts
@@ -21,6 +21,7 @@ import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
+import { createWorkspaceSpaceSlice } from './workspace-space'
import { createClaudeUsageSlice } from './claude-usage'
import { createCodexUsageSlice } from './codex-usage'
import { createBrowserSlice } from './browser'
@@ -55,6 +56,7 @@ export function createTestStore() {
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+ ...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createBrowserSlice(...a),
diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts
index 2af817e444e..3394284cf3e 100644
--- a/src/renderer/src/store/slices/tabs.test.ts
+++ b/src/renderer/src/store/slices/tabs.test.ts
@@ -97,6 +97,7 @@ import { createLinearSlice } from './linear'
import { createEditorSlice } from './editor'
import { createStatsSlice } from './stats'
import { createMemorySlice } from './memory'
+import { createWorkspaceSpaceSlice } from './workspace-space'
import { createClaudeUsageSlice } from './claude-usage'
import { createCodexUsageSlice } from './codex-usage'
import { createBrowserSlice } from './browser'
@@ -125,6 +126,7 @@ function createTestStore() {
...createEditorSlice(...a),
...createStatsSlice(...a),
...createMemorySlice(...a),
+ ...createWorkspaceSpaceSlice(...a),
...createClaudeUsageSlice(...a),
...createCodexUsageSlice(...a),
...createBrowserSlice(...a),
diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts
index bc12e7e1007..ce28fbca880 100644
--- a/src/renderer/src/store/slices/ui.test.ts
+++ b/src/renderer/src/store/slices/ui.test.ts
@@ -446,3 +446,33 @@ describe('createUISlice feature tour nudge', () => {
expect(store.getState().featureTourNudgeVisible).toBe(false)
})
})
+
+describe('createUISlice space navigation', () => {
+ it('returns to the tasks page after opening Space from an in-progress draft', () => {
+ const store = createUIStore()
+
+ store.getState().openTaskPage({ preselectedRepoId: 'repo-1' })
+ store.getState().openSpacePage()
+
+ expect(store.getState().activeView).toBe('space')
+ expect(store.getState().previousViewBeforeSpace).toBe('tasks')
+
+ store.getState().closeSpacePage()
+
+ expect(store.getState().activeView).toBe('tasks')
+ })
+
+ it('keeps the original return target when Space is reopened while already visible', () => {
+ const store = createUIStore()
+
+ store.getState().openTaskPage()
+ store.getState().openSpacePage()
+ store.getState().openSpacePage()
+
+ expect(store.getState().previousViewBeforeSpace).toBe('tasks')
+
+ store.getState().closeSpacePage()
+
+ expect(store.getState().activeView).toBe('tasks')
+ })
+})
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 44acdabf574..8fce0f6cb15 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -194,11 +194,12 @@ export type UISlice = {
acknowledgedAgentsByPaneKey: Record
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
- activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
- previousViewBeforeTasks: 'terminal' | 'settings' | 'activity' | 'automations'
- previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity' | 'automations'
- previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks' | 'automations'
- previousViewBeforeAutomations: 'terminal' | 'settings' | 'tasks' | 'activity'
+ activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space'
+ previousViewBeforeTasks: 'terminal' | 'settings' | 'activity' | 'automations' | 'space'
+ previousViewBeforeSettings: 'terminal' | 'tasks' | 'activity' | 'automations' | 'space'
+ previousViewBeforeActivity: 'terminal' | 'settings' | 'tasks' | 'automations' | 'space'
+ previousViewBeforeAutomations: 'terminal' | 'settings' | 'tasks' | 'activity' | 'space'
+ previousViewBeforeSpace: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations'
setActiveView: (view: UISlice['activeView']) => void
taskPageData: {
preselectedRepoId?: string
@@ -238,6 +239,8 @@ export type UISlice = {
setSelectedAutomationId: (id: string | null) => void
openAutomationsPage: () => void
closeAutomationsPage: () => void
+ openSpacePage: () => void
+ closeSpacePage: () => void
setNewWorkspaceDraft: (draft: NonNullable) => void
clearNewWorkspaceDraft: () => void
openSettingsPage: () => void
@@ -423,6 +426,7 @@ export const createUISlice: StateCreator = (set, get)
previousViewBeforeSettings: 'terminal',
previousViewBeforeActivity: 'terminal',
previousViewBeforeAutomations: 'terminal',
+ previousViewBeforeSpace: 'terminal',
setActiveView: (view) => set({ activeView: view }),
taskPageData: {},
taskResumeState: undefined,
@@ -536,6 +540,16 @@ export const createUISlice: StateCreator = (set, get)
set((state) => ({
activeView: state.previousViewBeforeAutomations
})),
+ openSpacePage: () =>
+ set((state) => ({
+ activeView: 'space',
+ previousViewBeforeSpace:
+ state.activeView === 'space' ? state.previousViewBeforeSpace : state.activeView
+ })),
+ closeSpacePage: () =>
+ set((state) => ({
+ activeView: state.previousViewBeforeSpace
+ })),
setNewWorkspaceDraft: (draft) => set({ newWorkspaceDraft: draft }),
clearNewWorkspaceDraft: () => set({ newWorkspaceDraft: null }),
openSettingsPage: () =>
diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts
new file mode 100644
index 00000000000..2d1bc99c000
--- /dev/null
+++ b/src/renderer/src/store/slices/workspace-space.ts
@@ -0,0 +1,96 @@
+import type { StateCreator } from 'zustand'
+import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types'
+import type { AppState } from '../types'
+
+let inFlightScan: Promise | null = null
+
+export type WorkspaceSpaceSlice = {
+ workspaceSpaceAnalysis: WorkspaceSpaceAnalysis | null
+ workspaceSpaceScanError: string | null
+ workspaceSpaceScanning: boolean
+ refreshWorkspaceSpace: () => Promise
+ removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void
+}
+
+function removeDeletedWorktreesFromAnalysis(
+ analysis: WorkspaceSpaceAnalysis,
+ deletedWorktreeIds: readonly string[]
+): WorkspaceSpaceAnalysis {
+ const deletedSet = new Set(deletedWorktreeIds)
+ const worktrees = analysis.worktrees.filter((worktree) => !deletedSet.has(worktree.worktreeId))
+ const rowsByRepoId = new Map()
+ for (const worktree of worktrees) {
+ const repoRows = rowsByRepoId.get(worktree.repoId) ?? []
+ repoRows.push(worktree)
+ rowsByRepoId.set(worktree.repoId, repoRows)
+ }
+ const repos = analysis.repos.map((repo) => {
+ const repoRows = rowsByRepoId.get(repo.repoId) ?? []
+ return {
+ ...repo,
+ worktreeCount: repoRows.length,
+ scannedWorktreeCount: repoRows.filter((row) => row.status === 'ok').length,
+ unavailableWorktreeCount: repoRows.filter((row) => row.status !== 'ok').length,
+ totalSizeBytes: repoRows.reduce((sum, row) => sum + row.sizeBytes, 0),
+ reclaimableBytes: repoRows.reduce((sum, row) => sum + row.reclaimableBytes, 0)
+ }
+ })
+
+ return {
+ ...analysis,
+ totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
+ reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0),
+ worktreeCount: worktrees.length,
+ scannedWorktreeCount: worktrees.filter((row) => row.status === 'ok').length,
+ unavailableWorktreeCount:
+ worktrees.filter((row) => row.status !== 'ok').length +
+ repos.filter((repo) => repo.error !== null).length,
+ repos,
+ worktrees
+ }
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+export const createWorkspaceSpaceSlice: StateCreator = (
+ set
+) => ({
+ workspaceSpaceAnalysis: null,
+ workspaceSpaceScanError: null,
+ workspaceSpaceScanning: false,
+ refreshWorkspaceSpace: async () => {
+ if (inFlightScan) {
+ return inFlightScan
+ }
+ set({ workspaceSpaceScanning: true, workspaceSpaceScanError: null })
+ // Why: the compact Resource Manager card and the full Space page share
+ // one manual scan result; duplicate button presses should join the same IO.
+ inFlightScan = window.api.workspaceSpace
+ .analyze()
+ .then((analysis) => {
+ set({ workspaceSpaceAnalysis: analysis, workspaceSpaceScanning: false })
+ return analysis
+ })
+ .catch((error: unknown) => {
+ set({ workspaceSpaceScanError: errorMessage(error), workspaceSpaceScanning: false })
+ throw error
+ })
+ .finally(() => {
+ inFlightScan = null
+ })
+ return inFlightScan
+ },
+ removeWorkspaceSpaceWorktrees: (worktreeIds) =>
+ set((state) =>
+ state.workspaceSpaceAnalysis
+ ? {
+ workspaceSpaceAnalysis: removeDeletedWorktreesFromAnalysis(
+ state.workspaceSpaceAnalysis,
+ worktreeIds
+ )
+ }
+ : state
+ )
+})
diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts
index dce995c3fd9..4c8f148985f 100644
--- a/src/renderer/src/store/types.ts
+++ b/src/renderer/src/store/types.ts
@@ -11,6 +11,7 @@ import type { LinearSlice } from './slices/linear'
import type { EditorSlice } from './slices/editor'
import type { StatsSlice } from './slices/stats'
import type { MemorySlice } from './slices/memory'
+import type { WorkspaceSpaceSlice } from './slices/workspace-space'
import type { ClaudeUsageSlice } from './slices/claude-usage'
import type { CodexUsageSlice } from './slices/codex-usage'
import type { BrowserSlice } from './slices/browser'
@@ -35,6 +36,7 @@ export type AppState = RepoSlice &
EditorSlice &
StatsSlice &
MemorySlice &
+ WorkspaceSpaceSlice &
ClaudeUsageSlice &
CodexUsageSlice &
BrowserSlice &
diff --git a/src/shared/computer-use-permissions-types.ts b/src/shared/computer-use-permissions-types.ts
index 761dba95d42..d1f4c488fb0 100644
--- a/src/shared/computer-use-permissions-types.ts
+++ b/src/shared/computer-use-permissions-types.ts
@@ -9,6 +9,8 @@ export type ComputerUsePermissionState = {
export type ComputerUsePermissionStatusResult = {
platform: NodeJS.Platform
+ helperAppPath: string | null
+ helperUnavailableReason: string | null
permissions: ComputerUsePermissionState[]
}
diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts
new file mode 100644
index 00000000000..9913ec87200
--- /dev/null
+++ b/src/shared/workspace-space-types.ts
@@ -0,0 +1,63 @@
+export type WorkspaceSpaceScanStatus =
+ | 'ok'
+ | 'missing'
+ | 'permission-denied'
+ | 'unavailable'
+ | 'error'
+
+export type WorkspaceSpaceItemKind = 'directory' | 'file' | 'symlink' | 'other'
+
+export type WorkspaceSpaceItem = {
+ name: string
+ path: string
+ kind: WorkspaceSpaceItemKind
+ sizeBytes: number
+}
+
+export type WorkspaceSpaceWorktree = {
+ worktreeId: string
+ repoId: string
+ repoDisplayName: string
+ repoPath: string
+ displayName: string
+ path: string
+ branch: string
+ isMainWorktree: boolean
+ isRemote: boolean
+ isSparse: boolean
+ canDelete: boolean
+ lastActivityAt: number
+ status: WorkspaceSpaceScanStatus
+ error: string | null
+ scannedAt: number
+ sizeBytes: number
+ reclaimableBytes: number
+ skippedEntryCount: number
+ topLevelItems: WorkspaceSpaceItem[]
+ omittedTopLevelItemCount: number
+ omittedTopLevelSizeBytes: number
+}
+
+export type WorkspaceSpaceRepoSummary = {
+ repoId: string
+ displayName: string
+ path: string
+ isRemote: boolean
+ worktreeCount: number
+ scannedWorktreeCount: number
+ unavailableWorktreeCount: number
+ totalSizeBytes: number
+ reclaimableBytes: number
+ error: string | null
+}
+
+export type WorkspaceSpaceAnalysis = {
+ scannedAt: number
+ totalSizeBytes: number
+ reclaimableBytes: number
+ worktreeCount: number
+ scannedWorktreeCount: number
+ unavailableWorktreeCount: number
+ repos: WorkspaceSpaceRepoSummary[]
+ worktrees: WorkspaceSpaceWorktree[]
+}
From 8a0b9b6bc56841185c695db9996569737d5d9da6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 15 May 2026 00:45:31 +0000
Subject: [PATCH 05/82] release: v1.4.2-rc.3
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 262e804eaa3..75544a91102 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "orca",
- "version": "1.4.2-rc.2",
+ "version": "1.4.2-rc.3",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
From 2d9cf720845e6e94829d0feebec1f87f1488e918 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 14 May 2026 19:02:37 -0700
Subject: [PATCH 06/82] fix: verify macOS entitlements before release
---
.github/workflows/pr.yml | 3 +
.github/workflows/release-cut.yml | 6 +
config/scripts/verify-macos-entitlements.mjs | 136 +++++++++++++++++++
package.json | 1 +
resources/build/entitlements.mac.plist | 2 -
5 files changed, 146 insertions(+), 2 deletions(-)
create mode 100644 config/scripts/verify-macos-entitlements.mjs
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index b70a92d35dc..22b332c1a21 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -71,6 +71,9 @@ jobs:
- name: Check feature wall asset budget
run: pnpm check:feature-wall-assets
+ - name: Verify macOS entitlements
+ run: pnpm verify:macos-entitlements
+
- name: Typecheck
run: pnpm typecheck
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 0f2dc8ba6c0..5128f74d079 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -634,6 +634,12 @@ jobs:
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ # Why: `plutil -lint` accepts duplicate plist keys, but `codesign`
+ # rejects duplicate entitlements after the expensive app build.
+ - name: Verify macOS entitlements
+ if: matrix.platform == 'mac'
+ run: pnpm verify:macos-entitlements
+
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
# requires the build identity to be the literal string `stable` or `rc`,
# substituted by electron-vite's `define` block at build time. Derive
diff --git a/config/scripts/verify-macos-entitlements.mjs b/config/scripts/verify-macos-entitlements.mjs
new file mode 100644
index 00000000000..20bb7230510
--- /dev/null
+++ b/config/scripts/verify-macos-entitlements.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = fileURLToPath(new URL('.', import.meta.url))
+const repoRoot = resolve(__dirname, '../..')
+
+const defaultPlists = [
+ 'resources/build/entitlements.mac.plist',
+ 'resources/build/entitlements.computer-use.mac.plist'
+]
+
+const plistPaths = process.argv.slice(2)
+const pathsToCheck = plistPaths.length > 0 ? plistPaths : defaultPlists
+
+let failed = false
+
+for (const plistPath of pathsToCheck) {
+ const absolutePath = resolve(repoRoot, plistPath)
+ const xml = readFileSync(absolutePath, 'utf8')
+ const problems = findDuplicateDictKeys(xml)
+
+ if (problems.length === 0) {
+ console.log(`${plistPath}: OK`)
+ continue
+ }
+
+ failed = true
+ console.error(`${plistPath}: duplicate plist dict keys found`)
+ for (const problem of problems) {
+ console.error(
+ `- ${problem.key} first appears on line ${problem.firstLine}, duplicated on line ${problem.duplicateLine}`
+ )
+ }
+}
+
+if (failed) {
+ process.exit(1)
+}
+
+function findDuplicateDictKeys(xml) {
+ const problems = []
+ const lineStarts = buildLineStarts(xml)
+ const dictStack = []
+ // Why: `plutil -lint` accepts duplicate keys, but `codesign` rejects
+ // duplicate entitlements during release signing.
+ const tokenPattern =
+ /||]*>([\s\S]*?)<\/key>|<(\/?)dict\b[^>]*>/g
+
+ for (const match of xml.matchAll(tokenPattern)) {
+ if (match[0].startsWith(' B[End]\n```')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts inline math as a rendered rich editor node', () => {
+ const editor = runCommand('inline-math')
+
+ try {
+ expect(editor.getMarkdown()).toBe('$x$')
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ {
+ content: [
+ {
+ type: 'inlineMath',
+ attrs: { latex: 'x' }
+ }
+ ]
+ }
+ ]
+ })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts display math as a rendered rich editor node', () => {
+ const editor = runCommand('math-block')
+
+ try {
+ expect(editor.getMarkdown()).toBe('$$\nx\n$$')
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ {
+ type: 'blockMath',
+ attrs: { latex: 'x' }
+ }
+ ]
+ })
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('parses existing display math as a rendered rich editor node', () => {
+ const editor = createEditor('Before\n\n$$\nx + y\n$$\n\nAfter')
+
+ try {
+ expect(editor.getJSON()).toMatchObject({
+ content: [
+ { type: 'paragraph' },
+ {
+ type: 'blockMath',
+ attrs: { latex: 'x + y' }
+ },
+ { type: 'paragraph' }
+ ]
+ })
+ expect(editor.getMarkdown()).toBe('Before\n\n$$\nx + y\n$$\n\nAfter')
+ } finally {
+ editor.destroy()
+ }
+ })
+
+ it('inserts a plain unicode emoji', () => {
+ const editor = runCommand('emoji')
+
+ try {
+ expect(editor.getMarkdown()).toBe('🙂')
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-commands.tsx b/src/renderer/src/components/editor/rich-markdown-commands.tsx
index aee9405ce83..03da78f3920 100644
--- a/src/renderer/src/components/editor/rich-markdown-commands.tsx
+++ b/src/renderer/src/components/editor/rich-markdown-commands.tsx
@@ -1,6 +1,20 @@
+/* eslint-disable max-lines -- Why: slash commands and doc-link command helpers share trigger-range contracts used by the editor keyboard path. */
import React from 'react'
import type { Editor } from '@tiptap/react'
-import { Heading1, Heading2, Heading3, ImageIcon, List, ListOrdered, Quote } from 'lucide-react'
+import { TextSelection } from '@tiptap/pm/state'
+import type {} from '@tiptap/extension-mathematics'
+import {
+ Heading1,
+ Heading2,
+ Heading3,
+ ImageIcon,
+ List,
+ ListOrdered,
+ Quote,
+ Sigma,
+ Table2,
+ Workflow
+} from 'lucide-react'
import type { MarkdownDocument } from '../../../../shared/types'
import { stripMarkdownExtension } from './markdown-doc-links'
@@ -41,16 +55,72 @@ export type SlashCommandId =
| 'code-block'
| 'divider'
| 'image'
+ | 'table'
+ | 'mermaid'
+ | 'inline-math'
+ | 'math-block'
+ | 'emoji'
+
+export type SlashCommandIcon =
+ | { kind: 'component'; component: React.ComponentType<{ className?: string }> }
+ | { kind: 'text'; value: string }
+
+export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others'
export type SlashCommand = {
id: SlashCommandId
label: string
aliases: string[]
- icon: React.ComponentType<{ className?: string }>
+ icon: SlashCommandIcon
+ group: SlashCommandGroup
description: string
run: (editor: Editor) => void
}
+function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon {
+ return { kind: 'component', component }
+}
+
+function textIcon(value: string): SlashCommandIcon {
+ return { kind: 'text', value }
+}
+
+function insertTextWithSelection(
+ editor: Editor,
+ text: string,
+ selectionStartOffset?: number,
+ selectionEndOffset = selectionStartOffset
+): void {
+ editor.commands.command(({ state, dispatch }) => {
+ const from = state.selection.from
+ const tr = state.tr.insertText(text, from, state.selection.to)
+
+ if (selectionStartOffset !== undefined) {
+ const selectionFrom = from + selectionStartOffset
+ const selectionTo = from + (selectionEndOffset ?? selectionStartOffset)
+ tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo))
+ }
+
+ dispatch?.(tr.scrollIntoView())
+ return true
+ })
+}
+
+function insertCodeBlock(editor: Editor, language: string, text: string): void {
+ editor.commands.command(({ state, dispatch }) => {
+ const codeBlockType = state.schema.nodes.codeBlock
+ if (!codeBlockType) {
+ return false
+ }
+ const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined)
+ const tr = state.tr.replaceSelectionWith(node).scrollIntoView()
+ const cursor = tr.selection.from + 1
+ tr.setSelection(TextSelection.create(tr.doc, cursor, cursor))
+ dispatch?.(tr)
+ return true
+ })
+}
+
/**
* Executes a slash command by first deleting the typed slash text, then
* delegating to the command's run method. Image is special-cased because
@@ -60,7 +130,8 @@ export function runSlashCommand(
editor: Editor,
slashMenu: { from: number; to: number },
command: SlashCommand,
- onImageCommand?: () => void
+ onImageCommand?: () => void,
+ onEmojiCommand?: () => void
): void {
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run()
// Why: image insertion cannot rely on window.prompt() in Electron, so this
@@ -69,25 +140,20 @@ export function runSlashCommand(
onImageCommand()
return
}
+ if (command.id === 'emoji' && onEmojiCommand) {
+ onEmojiCommand()
+ return
+ }
command.run(editor)
}
export const slashCommands: SlashCommand[] = [
- {
- id: 'text',
- label: 'Text',
- aliases: ['paragraph', 'plain'],
- icon: List,
- description: 'Start a normal paragraph.',
- run: (editor) => {
- editor.chain().focus().setParagraph().run()
- }
- },
{
id: 'heading-1',
label: 'Heading 1',
aliases: ['h1', 'title'],
- icon: Heading1,
+ icon: icon(Heading1),
+ group: 'Headings',
description: 'Large section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -99,7 +165,8 @@ export const slashCommands: SlashCommand[] = [
id: 'heading-2',
label: 'Heading 2',
aliases: ['h2'],
- icon: Heading2,
+ icon: icon(Heading2),
+ group: 'Headings',
description: 'Medium section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -111,7 +178,8 @@ export const slashCommands: SlashCommand[] = [
id: 'heading-3',
label: 'Heading 3',
aliases: ['h3'],
- icon: Heading3,
+ icon: icon(Heading3),
+ group: 'Headings',
description: 'Small section heading.',
run: (editor) => {
// Use setHeading (not toggleHeading) so the slash command is idempotent —
@@ -119,51 +187,67 @@ export const slashCommands: SlashCommand[] = [
editor.chain().focus().setHeading({ level: 3 }).run()
}
},
- {
- id: 'task-list',
- label: 'To-do List',
- aliases: ['todo', 'task', 'checkbox'],
- icon: List,
- description: 'Create a checklist.',
- run: (editor) => {
- editor.chain().focus().toggleTaskList().run()
- }
- },
- {
- id: 'bullet-list',
- label: 'Bullet List',
- aliases: ['bullet', 'ul', 'list'],
- icon: List,
- description: 'Create an unordered list.',
- run: (editor) => {
- editor.chain().focus().toggleBulletList().run()
- }
- },
- {
- id: 'ordered-list',
- label: 'Numbered List',
- aliases: ['ordered', 'ol', 'numbered'],
- icon: ListOrdered,
- description: 'Create an ordered list.',
- run: (editor) => {
- editor.chain().focus().toggleOrderedList().run()
- }
- },
{
id: 'blockquote',
label: 'Quote',
aliases: ['quote', 'blockquote'],
- icon: Quote,
+ icon: icon(Quote),
+ group: 'Basic blocks',
description: 'Insert a blockquote.',
run: (editor) => {
editor.chain().focus().toggleBlockquote().run()
}
},
+ {
+ id: 'ordered-list',
+ label: 'Numbered List',
+ aliases: ['ordered', 'ol', 'numbered'],
+ icon: icon(ListOrdered),
+ group: 'Basic blocks',
+ description: 'Create an ordered list.',
+ run: (editor) => {
+ editor.chain().focus().toggleOrderedList().run()
+ }
+ },
+ {
+ id: 'bullet-list',
+ label: 'Bullet List',
+ aliases: ['bullet', 'ul', 'list'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Create an unordered list.',
+ run: (editor) => {
+ editor.chain().focus().toggleBulletList().run()
+ }
+ },
+ {
+ id: 'task-list',
+ label: 'Check List',
+ aliases: ['todo', 'task', 'checkbox'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Create a checklist.',
+ run: (editor) => {
+ editor.chain().focus().toggleTaskList().run()
+ }
+ },
+ {
+ id: 'text',
+ label: 'Paragraph',
+ aliases: ['paragraph', 'plain'],
+ icon: icon(List),
+ group: 'Basic blocks',
+ description: 'Start a normal paragraph.',
+ run: (editor) => {
+ editor.chain().focus().setParagraph().run()
+ }
+ },
{
id: 'code-block',
label: 'Code Block',
aliases: ['code', 'snippet'],
- icon: List,
+ icon: icon(List),
+ group: 'Basic blocks',
description: 'Insert a fenced code block.',
run: (editor) => {
editor.chain().focus().toggleCodeBlock().run()
@@ -173,23 +257,80 @@ export const slashCommands: SlashCommand[] = [
id: 'divider',
label: 'Divider',
aliases: ['divider', 'rule', 'hr'],
- icon: List,
+ icon: icon(List),
+ group: 'Basic blocks',
description: 'Insert a horizontal rule.',
run: (editor) => {
editor.chain().focus().setHorizontalRule().run()
}
},
+ {
+ id: 'table',
+ label: 'Table',
+ aliases: ['grid', 'columns', 'rows'],
+ icon: icon(Table2),
+ group: 'Advanced',
+ description: 'Insert a 3x3 markdown table.',
+ run: (editor) => {
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ },
+ {
+ id: 'mermaid',
+ label: 'Mermaid Diagram',
+ aliases: ['diagram', 'flowchart', 'chart', 'graph'],
+ icon: icon(Workflow),
+ group: 'Advanced',
+ description: 'Insert a Mermaid fenced block.',
+ run: (editor) => {
+ insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]')
+ }
+ },
+ {
+ id: 'inline-math',
+ label: 'Inline Math',
+ aliases: ['math', 'latex', 'equation', 'formula'],
+ icon: icon(Sigma),
+ group: 'Advanced',
+ description: 'Insert inline LaTeX math.',
+ run: (editor) => {
+ editor.commands.insertInlineMath({ latex: 'x' })
+ }
+ },
+ {
+ id: 'math-block',
+ label: 'Math Block',
+ aliases: ['display math', 'latex block', 'equation block'],
+ icon: icon(Sigma),
+ group: 'Advanced',
+ description: 'Insert display LaTeX math.',
+ run: (editor) => {
+ editor.commands.insertBlockMath({ latex: 'x' })
+ }
+ },
{
id: 'image',
label: 'Image',
aliases: ['image', 'img'],
- icon: ImageIcon,
+ icon: icon(ImageIcon),
+ group: 'Media',
description: 'Insert an image from your computer.',
// Why: window.prompt() is not supported in Electron's renderer process,
// so image URL input is handled by an inline input bar in RichMarkdownEditor.
run: (editor) => {
editor.chain().focus().run()
}
+ },
+ {
+ id: 'emoji',
+ label: 'Emoji',
+ aliases: ['smile', 'reaction', 'icon'],
+ icon: textIcon('🙂'),
+ group: 'Others',
+ description: 'Insert a plain Unicode emoji.',
+ run: (editor) => {
+ insertTextWithSelection(editor, '🙂')
+ }
}
]
diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts
index 00479bfd89f..4cc560b2636 100644
--- a/src/renderer/src/components/editor/rich-markdown-extensions.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts
@@ -10,6 +10,7 @@ import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
+import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
import { Markdown } from '@tiptap/markdown'
import { createLowlight, common } from 'lowlight'
import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc'
@@ -132,6 +133,17 @@ export function createRichMarkdownExtensions({
TableRow,
TableHeader,
TableCell,
+ InlineMath.configure({
+ katexOptions: {
+ throwOnError: false
+ }
+ }),
+ BlockMath.configure({
+ katexOptions: {
+ displayMode: true,
+ throwOnError: false
+ }
+ }),
RawMarkdownHtmlInline,
RawMarkdownHtmlBlock,
MarkdownDocLink,
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
index 059c0a0448f..17bfb45df53 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts
@@ -45,6 +45,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
filteredDocLinkRowsRef: { current: [] },
selectedDocLinkIndexRef: { current: 0 },
handleLocalImagePickRef: { current: vi.fn() },
+ handleEmojiPickRef: { current: vi.fn() },
typedEmptyOrderedListMarkerRef: { current: typedMarker },
flushPendingSerialization: vi.fn(),
openSearchRef: { current: vi.fn() },
@@ -117,4 +118,31 @@ describe('rich markdown key handler', () => {
editor.destroy()
}
})
+
+ it('keeps slash-menu filter input out of the document', () => {
+ const editor = createEditor({
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }]
+ })
+
+ try {
+ editor.commands.setTextSelection(2)
+ let slashMenu = { query: '', from: 1, to: 2, left: 0, top: 0 }
+ const ctx = createContext(editor, false)
+ ctx.slashMenuRef.current = slashMenu
+ ctx.filteredSlashCommandsRef.current = [{ id: 'heading-1' } as never]
+ ctx.setSlashMenu = vi.fn((next) => {
+ slashMenu = typeof next === 'function' ? next(slashMenu) : next
+ ctx.slashMenuRef.current = slashMenu
+ })
+ const event = keyEvent('h')
+
+ expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true)
+ expect(event.preventDefault).toHaveBeenCalled()
+ expect(editor.getText()).toBe('/')
+ expect(ctx.slashMenuRef.current?.query).toBe('h')
+ } finally {
+ editor.destroy()
+ }
+ })
})
diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
index 70591cb00f9..c1bbd9851e1 100644
--- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts
+++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts
@@ -31,6 +31,7 @@ export type KeyHandlerContext = {
filteredDocLinkRowsRef: MutableRefObject
selectedDocLinkIndexRef: MutableRefObject
handleLocalImagePickRef: MutableRefObject<() => void>
+ handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
typedEmptyOrderedListMarkerRef: MutableRefObject
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
@@ -38,7 +39,7 @@ export type KeyHandlerContext = {
setLinkBubble: (bubble: LinkBubbleState | null) => void
setSelectedCommandIndex: Dispatch>
setSelectedDocLinkIndex: Dispatch>
- setSlashMenu: (menu: SlashMenuState | null) => void
+ setSlashMenu: Dispatch>
setDocLinkMenu: (menu: DocLinkMenuState | null) => void
}
@@ -231,6 +232,20 @@ export function createRichMarkdownKeyHandler(
return false
}
+ if (!event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1) {
+ event.preventDefault()
+ ctx.setSlashMenu((menu) => (menu ? { ...menu, query: `${menu.query}${event.key}` } : menu))
+ ctx.setSelectedCommandIndex(0)
+ return true
+ }
+
+ if (event.key === 'Backspace' && currentSlashMenu.query.length > 0) {
+ event.preventDefault()
+ ctx.setSlashMenu((menu) => (menu ? { ...menu, query: menu.query.slice(0, -1) } : menu))
+ ctx.setSelectedCommandIndex(0)
+ return true
+ }
+
const currentFilteredSlashCommands = ctx.filteredSlashCommandsRef.current
if (currentFilteredSlashCommands.length === 0) {
return false
@@ -265,8 +280,12 @@ export function createRichMarkdownKeyHandler(
// mirrors the latest highlighted slash-menu item for keyboard picks.
const selectedCommand = currentFilteredSlashCommands[ctx.selectedCommandIndexRef.current]
if (selectedCommand) {
- runSlashCommand(activeEditor, currentSlashMenu, selectedCommand, () =>
- ctx.handleLocalImagePickRef.current()
+ runSlashCommand(
+ activeEditor,
+ currentSlashMenu,
+ selectedCommand,
+ () => ctx.handleLocalImagePickRef.current(),
+ () => ctx.handleEmojiPickRef.current(currentSlashMenu)
)
}
return true
From b7a00aaf0e65be033ec89c7428c4884dcbbef7bb Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 14 May 2026 22:15:44 -0700
Subject: [PATCH 16/82] fix: normalize empty markdown list items (#1897)
---
.../editor/rich-markdown-normalize.test.ts | 35 +++++++++++++++++
.../editor/rich-markdown-normalize.ts | 38 +++++++++++++++----
2 files changed, 65 insertions(+), 8 deletions(-)
create mode 100644 src/renderer/src/components/editor/rich-markdown-normalize.test.ts
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.test.ts b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
new file mode 100644
index 00000000000..7f81eb795c0
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { Editor } from '@tiptap/core'
+import StarterKit from '@tiptap/starter-kit'
+import { Markdown } from '@tiptap/markdown'
+import { normalizeSoftBreaks } from './rich-markdown-normalize'
+
+const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
+
+function createEditor(content: string): Editor {
+ return new Editor({
+ element: null,
+ extensions,
+ content,
+ contentType: 'markdown'
+ })
+}
+
+describe('rich markdown normalization', () => {
+ it('normalizes empty ordered list items into caret targets', () => {
+ const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n')
+
+ try {
+ normalizeSoftBreaks(editor)
+
+ const list = editor.state.doc.child(0)
+ const emptyItem = list.child(2)
+ expect(emptyItem.type.name).toBe('listItem')
+ expect(emptyItem.childCount).toBe(1)
+ expect(emptyItem.child(0).type.name).toBe('paragraph')
+ expect(emptyItem.child(0).content.size).toBe(0)
+ } finally {
+ editor.destroy()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-normalize.ts b/src/renderer/src/components/editor/rich-markdown-normalize.ts
index a87aa972020..a3ead9a798b 100644
--- a/src/renderer/src/components/editor/rich-markdown-normalize.ts
+++ b/src/renderer/src/components/editor/rich-markdown-normalize.ts
@@ -10,8 +10,9 @@ import { Fragment, type Node as PmNode } from '@tiptap/pm/model'
* of just one logical line.
*
* This function normalises the ProseMirror document by splitting any paragraph whose
- * text nodes contain `\n` into separate paragraph nodes — one per line. Inline marks
- * (bold, italic, links, etc.) are preserved on each resulting paragraph. This is
+ * text nodes contain `\n` into separate paragraph nodes — one per line — and by
+ * giving empty parsed list items a paragraph caret target. Inline marks (bold,
+ * italic, links, etc.) are preserved on each resulting paragraph. This is
* structurally correct for the editing model: each visual line becomes its own block,
* so the cut handler (and all other block-level operations) work on a per-line basis.
*/
@@ -30,9 +31,24 @@ export function normalizeSoftBreaks(editor: Editor): void {
// Why: doc.forEach only iterates top-level children, so paragraphs nested inside
// blockquotes, table cells, or other container nodes would be missed.
// doc.descendants walks every node at every depth and provides absolute positions.
- const replacements: { from: number; to: number; paragraphs: Fragment[] }[] = []
+ const replacements: (
+ | { from: number; to: number; kind: 'soft-break-paragraphs'; paragraphs: Fragment[] }
+ | { from: number; to: number; kind: 'empty-list-item'; node: PmNode }
+ )[] = []
doc.descendants((node, pos) => {
+ if (node.type.name === 'listItem' && node.childCount === 0) {
+ // Why: marked parses `3. ` immediately before a heading as a list item
+ // with no paragraph. It renders a marker but has no editable caret target.
+ replacements.push({
+ from: pos,
+ to: pos + node.nodeSize,
+ kind: 'empty-list-item',
+ node: node.type.create(node.attrs, paragraphType.create(), node.marks)
+ })
+ return false
+ }
+
if (node.type !== paragraphType) {
return true // continue descending into container nodes
}
@@ -77,6 +93,7 @@ export function normalizeSoftBreaks(editor: Editor): void {
replacements.push({
from: pos,
to: pos + node.nodeSize,
+ kind: 'soft-break-paragraphs',
paragraphs: lines
})
@@ -90,11 +107,16 @@ export function normalizeSoftBreaks(editor: Editor): void {
// Capture the transaction lazily — only after all replacements are collected.
const tr = editor.view.state.tr
- // Apply replacements in reverse order to preserve positions.
- for (let i = replacements.length - 1; i >= 0; i--) {
- const { from, to, paragraphs } = replacements[i]
- const newNodes = paragraphs.map((content) => paragraphType.create(null, content))
- tr.replaceWith(from, to, newNodes)
+ // Apply replacements in reverse document order to preserve positions.
+ replacements.sort((a, b) => b.from - a.from)
+ for (const replacement of replacements) {
+ if (replacement.kind === 'empty-list-item') {
+ tr.replaceWith(replacement.from, replacement.to, replacement.node)
+ continue
+ }
+
+ const newNodes = replacement.paragraphs.map((content) => paragraphType.create(null, content))
+ tr.replaceWith(replacement.from, replacement.to, newNodes)
}
// Why: this normalization is a structural housekeeping step, not a user edit.
From a9b47759fa88e1b0478dbddd3be8b6d710dbf7d8 Mon Sep 17 00:00:00 2001
From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com>
Date: Thu, 14 May 2026 22:34:22 -0700
Subject: [PATCH 17/82] fix: pr-bug-scan validated finding from #1857 (#1886)
* fix: address pr-bug-scan validated finding from #1857
Restored alt-buffer guards at both the double-rAF and 200ms phases of scheduleSplitScrollRestore so TUI panes skip restoreScrollState+refresh, blocking the #1298 cursor-drift regression.
* fix: preserve split scroll state around alternate buffers
---------
Co-authored-by: orca-bug-scan-bot
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
---
.../pane-fit-resize-observer.test.ts | 1 +
.../lib/pane-manager/pane-manager-types.ts | 1 +
.../src/lib/pane-manager/pane-scroll.ts | 3 +-
.../pane-manager/pane-split-scroll.test.ts | 180 ++++++++++++++++++
.../src/lib/pane-manager/pane-split-scroll.ts | 83 +++++++-
.../lib/pane-manager/pane-tree-ops.test.ts | 1 +
6 files changed, 260 insertions(+), 9 deletions(-)
create mode 100644 src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts
diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts
index c2cdb6e03a8..2085a3fa407 100644
--- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts
@@ -62,6 +62,7 @@ function createPane(): ManagedPaneInternal {
compositionHandler: null,
debugLabel: null,
pendingSplitScrollState: {
+ bufferType: 'normal',
wasAtBottom: true,
firstVisibleLineContent: '',
viewportY: 0,
diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
index 32041bd811f..08d1661f135 100644
--- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts
+++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts
@@ -67,6 +67,7 @@ export type ManagedPane = {
// ---------------------------------------------------------------------------
export type ScrollState = {
+ bufferType: 'normal' | 'alternate'
wasAtBottom: boolean
firstVisibleLineContent: string
viewportY: number
diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts
index 6f430476246..ff895174c1e 100644
--- a/src/renderer/src/lib/pane-manager/pane-scroll.ts
+++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts
@@ -51,11 +51,12 @@ export function findLineByContent(terminal: Terminal, content: string, hintRatio
export function captureScrollState(terminal: Terminal): ScrollState {
const buf = terminal.buffer.active
+ const bufferType = buf.type
const viewportY = buf.viewportY
const wasAtBottom = viewportY >= buf.baseY
const firstVisibleLineContent = buf.getLine(viewportY)?.translateToString(true)?.trimEnd() ?? ''
const totalLines = buf.baseY + terminal.rows
- return { wasAtBottom, firstVisibleLineContent, viewportY, totalLines }
+ return { bufferType, wasAtBottom, firstVisibleLineContent, viewportY, totalLines }
}
export function restoreScrollState(terminal: Terminal, state: ScrollState): void {
diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts
new file mode 100644
index 00000000000..4b059bb2991
--- /dev/null
+++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.test.ts
@@ -0,0 +1,180 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
+
+const restoreScrollState = vi.hoisted(() => vi.fn())
+
+vi.mock('./pane-scroll', () => ({
+ restoreScrollState
+}))
+
+import { scheduleSplitScrollRestore } from './pane-split-scroll'
+
+const scrollState = {
+ bufferType: 'normal',
+ wasAtBottom: true,
+ firstVisibleLineContent: '',
+ viewportY: 0,
+ totalLines: 24
+} satisfies ScrollState
+
+const alternateScrollState = {
+ ...scrollState,
+ bufferType: 'alternate'
+} satisfies ScrollState
+
+function createPane(bufferType: 'normal' | 'alternate'): {
+ pane: ManagedPaneInternal
+ bufferChangeDisposable: { dispose: ReturnType }
+ triggerBufferChange: (bufferType: 'normal' | 'alternate') => void
+} {
+ let bufferChangeHandler: ((buffer: { type: 'normal' | 'alternate' }) => void) | null = null
+ const bufferChangeDisposable = { dispose: vi.fn() }
+ const pane: ManagedPaneInternal = {
+ id: 1,
+ terminal: {
+ rows: 24,
+ refresh: vi.fn(),
+ buffer: {
+ active: {
+ type: bufferType,
+ length: 24
+ },
+ onBufferChange: vi.fn((handler: (buffer: { type: 'normal' | 'alternate' }) => void) => {
+ bufferChangeHandler = handler
+ return bufferChangeDisposable
+ })
+ }
+ } as never,
+ container: {
+ querySelectorAll: vi.fn(() => [])
+ } as never,
+ xtermContainer: {} as never,
+ linkTooltip: {} as never,
+ terminalGpuAcceleration: 'auto',
+ gpuRenderingEnabled: true,
+ webglAttachmentDeferred: false,
+ webglDisabledAfterContextLoss: false,
+ hasComplexScriptOutput: false,
+ webglAddon: null,
+ ligaturesAddon: null,
+ fitResizeObserver: null,
+ pendingObservedFitRafId: null,
+ fitAddon: {} as never,
+ searchAddon: {} as never,
+ serializeAddon: {
+ serialize: vi.fn(() => '')
+ } as never,
+ unicode11Addon: {} as never,
+ webLinksAddon: {} as never,
+ compositionHandler: null,
+ pendingSplitScrollState: scrollState,
+ debugLabel: null
+ }
+ return {
+ pane,
+ bufferChangeDisposable,
+ triggerBufferChange: (bufferType) => bufferChangeHandler?.({ type: bufferType })
+ }
+}
+
+describe('scheduleSplitScrollRestore', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+ callback(16)
+ return 1
+ })
+ restoreScrollState.mockClear()
+ })
+
+ afterEach(() => {
+ vi.clearAllTimers()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('restores and refreshes normal-screen panes after split reparenting settles', () => {
+ const { pane } = createPane('normal')
+ const reattachWebgl = vi.fn()
+
+ scheduleSplitScrollRestore(
+ () => pane,
+ pane.id,
+ scrollState,
+ () => false,
+ reattachWebgl
+ )
+
+ expect(restoreScrollState).toHaveBeenCalledTimes(1)
+ expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
+
+ vi.advanceTimersByTime(200)
+
+ expect(pane.pendingSplitScrollState).toBeNull()
+ expect(reattachWebgl).toHaveBeenCalledWith(pane)
+ expect(restoreScrollState).toHaveBeenCalledTimes(2)
+ expect(pane.terminal.refresh).toHaveBeenCalledTimes(2)
+ })
+
+ it('defers WebGL reattach and skips scroll restore for alternate-screen panes', () => {
+ const { pane, bufferChangeDisposable, triggerBufferChange } = createPane('alternate')
+ const reattachWebgl = vi.fn()
+
+ scheduleSplitScrollRestore(
+ () => pane,
+ pane.id,
+ alternateScrollState,
+ () => false,
+ reattachWebgl
+ )
+
+ expect(restoreScrollState).not.toHaveBeenCalled()
+ expect(pane.terminal.refresh).not.toHaveBeenCalled()
+ expect(pane.pendingSplitScrollState).toBe(scrollState)
+
+ vi.advanceTimersByTime(200)
+
+ expect(pane.pendingSplitScrollState).toBeNull()
+ expect(reattachWebgl).not.toHaveBeenCalled()
+ expect(restoreScrollState).not.toHaveBeenCalled()
+ expect(pane.terminal.refresh).not.toHaveBeenCalled()
+
+ triggerBufferChange('alternate')
+
+ expect(reattachWebgl).not.toHaveBeenCalled()
+ expect(bufferChangeDisposable.dispose).not.toHaveBeenCalled()
+
+ triggerBufferChange('normal')
+
+ expect(bufferChangeDisposable.dispose).toHaveBeenCalledTimes(1)
+ expect(reattachWebgl).toHaveBeenCalledWith(pane)
+ expect(restoreScrollState).not.toHaveBeenCalled()
+ expect(pane.terminal.refresh).not.toHaveBeenCalled()
+ })
+
+ it('defers normal-screen scroll restore until an active TUI exits alternate screen', () => {
+ const { pane, triggerBufferChange } = createPane('alternate')
+ const reattachWebgl = vi.fn()
+
+ scheduleSplitScrollRestore(
+ () => pane,
+ pane.id,
+ scrollState,
+ () => false,
+ reattachWebgl
+ )
+
+ vi.advanceTimersByTime(200)
+
+ expect(pane.pendingSplitScrollState).toBe(scrollState)
+ expect(restoreScrollState).not.toHaveBeenCalled()
+ expect(pane.terminal.refresh).not.toHaveBeenCalled()
+
+ triggerBufferChange('normal')
+
+ expect(pane.pendingSplitScrollState).toBeNull()
+ expect(reattachWebgl).toHaveBeenCalledWith(pane)
+ expect(restoreScrollState).toHaveBeenCalledWith(pane.terminal, scrollState)
+ expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
+ })
+})
diff --git a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts
index ac102135d15..b8b00747cad 100644
--- a/src/renderer/src/lib/pane-manager/pane-split-scroll.ts
+++ b/src/renderer/src/lib/pane-manager/pane-split-scroll.ts
@@ -1,3 +1,4 @@
+import type { IBuffer, IDisposable } from '@xterm/xterm'
import type { ManagedPaneInternal, ScrollState } from './pane-manager-types'
import { restoreScrollState } from './pane-scroll'
@@ -9,6 +10,43 @@ function refreshAfterReparent(pane: ManagedPaneInternal): void {
}
}
+function runAfterNormalBuffer(
+ pane: ManagedPaneInternal,
+ getPaneById: (id: number) => ManagedPaneInternal | undefined,
+ paneId: number,
+ isDestroyed: () => boolean,
+ callback: (pane: ManagedPaneInternal) => void
+): void {
+ let disposable: IDisposable | null = null
+ disposable = pane.terminal.buffer.onBufferChange((buffer: IBuffer) => {
+ if (buffer.type === 'alternate') {
+ return
+ }
+ disposable?.dispose()
+ disposable = null
+ if (isDestroyed()) {
+ return
+ }
+ const live = getPaneById(paneId)
+ if (live) {
+ callback(live)
+ }
+ })
+}
+
+function restoreCapturedScrollState(
+ pane: ManagedPaneInternal,
+ scrollState: ScrollState,
+ reattachWebgl?: (pane: ManagedPaneInternal) => void
+): void {
+ pane.pendingSplitScrollState = null
+ if (reattachWebgl) {
+ reattachWebgl(pane)
+ }
+ restoreScrollState(pane.terminal, scrollState)
+ refreshAfterReparent(pane)
+}
+
function logPaneHealth(pane: ManagedPaneInternal, phase: string): void {
const canvases = pane.container.querySelectorAll('canvas')
const canvasInfo = Array.from(canvases).map((c) => {
@@ -89,10 +127,19 @@ export function scheduleSplitScrollRestore(
return
}
const live = getPaneById(paneId)
- if (live?.pendingSplitScrollState) {
- restoreScrollState(live.terminal, scrollState)
- refreshAfterReparent(live)
+ if (!live?.pendingSplitScrollState) {
+ return
}
+ // Why: see the 200ms timer below — the alt-screen buffer belongs to a
+ // TUI and restore-during-draw knocks its cursor one row off (#1298).
+ if (
+ scrollState.bufferType === 'alternate' ||
+ live.terminal.buffer.active.type === 'alternate'
+ ) {
+ return
+ }
+ restoreScrollState(live.terminal, scrollState)
+ refreshAfterReparent(live)
})
})
@@ -104,12 +151,32 @@ export function scheduleSplitScrollRestore(
if (!live) {
return
}
- live.pendingSplitScrollState = null
- if (reattachWebgl) {
- reattachWebgl(live)
+ // Why: the alt-screen buffer belongs to a full-screen TUI (Claude Code,
+ // vim, less) that owns its cursor position. Re-running scroll restore
+ // and a full refresh here clobbers an in-progress draw — refresh(0,
+ // rows-1) repaints rows from xterm's buffer, racing the TUI's next
+ // write and leaving its cursor one row off (#1298 regression).
+ // WebGL reattach also refreshes, so defer it until the TUI exits the
+ // alternate buffer. Alt-screen has no scrollback, so scroll restore has
+ // nothing legitimate to do.
+ if (scrollState.bufferType === 'alternate') {
+ live.pendingSplitScrollState = null
+ if (live.terminal.buffer.active.type === 'alternate' && reattachWebgl) {
+ runAfterNormalBuffer(live, getPaneById, paneId, isDestroyed, reattachWebgl)
+ return
+ }
+ if (reattachWebgl) {
+ reattachWebgl(live)
+ }
+ return
}
- restoreScrollState(live.terminal, scrollState)
- refreshAfterReparent(live)
+ if (live.terminal.buffer.active.type === 'alternate') {
+ runAfterNormalBuffer(live, getPaneById, paneId, isDestroyed, (normalPane) => {
+ restoreCapturedScrollState(normalPane, scrollState, reattachWebgl)
+ })
+ return
+ }
+ restoreCapturedScrollState(live, scrollState, reattachWebgl)
}, 200)
setTimeout(() => {
diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
index 695f3aaeb2d..f0c78cce205 100644
--- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
+++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts
@@ -106,6 +106,7 @@ describe('safeFit', () => {
terminalRows: 32
})
pane.pendingSplitScrollState = {
+ bufferType: 'normal',
wasAtBottom: true,
firstVisibleLineContent: '',
viewportY: 0,
From 89827117e589b1b1ddc9adc42f8aa87efa261ac9 Mon Sep 17 00:00:00 2001
From: "buf0-bot[bot]" <252831055+buf0-bot[bot]@users.noreply.github.com>
Date: Thu, 14 May 2026 22:54:34 -0700
Subject: [PATCH 18/82] fix: pr-bug-scan validated finding from #1865 (#1888)
* fix: address pr-bug-scan validated finding from #1865
SIGKILL fallback in graceful pty.shutdown now emits pty.exit before notifyExitListener; renderer sees the exit even though onExit short-circuits on disposed.
* test: cover relay pty kill fallback exit
---------
Co-authored-by: orca-bug-scan-bot
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
---
src/relay/pty-handler.test.ts | 26 ++++++++++++++++++++++++++
src/relay/pty-handler.ts | 5 +++++
2 files changed, 31 insertions(+)
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index e806237a429..d52b5b1ea5b 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -284,6 +284,32 @@ describe('PtyHandler', () => {
expect(mockKill).toHaveBeenCalledWith('SIGTERM')
})
+ it('notifies pty.exit when graceful shutdown falls back to SIGKILL', async () => {
+ let onExitCb: ((evt: { exitCode: number }) => void) | undefined
+ const mockKill = vi.fn()
+ mockPtySpawn.mockReturnValue({
+ ...mockPtyInstance,
+ kill: mockKill,
+ onData: vi.fn(),
+ onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => {
+ onExitCb = cb
+ })
+ })
+ const exits: { id: string; paneKey?: string }[] = []
+ handler.setExitListener((evt) => exits.push(evt))
+
+ await dispatcher.callRequest('pty.spawn', { env: { ORCA_PANE_KEY: 'tab-fallback:0' } })
+ await dispatcher.callRequest('pty.shutdown', { id: 'pty-1', immediate: false })
+ vi.advanceTimersByTime(5000)
+ onExitCb!({ exitCode: 137 })
+
+ expect(mockKill).toHaveBeenCalledWith('SIGTERM')
+ expect(mockKill).toHaveBeenCalledWith('SIGKILL')
+ expect(dispatcher.notify).toHaveBeenCalledWith('pty.exit', { id: 'pty-1', code: -1 })
+ expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-fallback:0' }])
+ expect(handler.activePtyCount).toBe(0)
+ })
+
it('kills PTY on shutdown with SIGKILL when immediate', async () => {
const mockKill = vi.fn()
mockPtySpawn.mockReturnValue({
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index b1b540cb867..deb6658b838 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -444,6 +444,11 @@ export class PtyHandler {
const still = this.ptys.get(id)
if (still && !still.disposed) {
still.pty.kill('SIGKILL')
+ // Why: emit pty.exit BEFORE disposeManagedPty sets disposed=true.
+ // The natural onExit short-circuits on `managed.disposed`, so
+ // without this notify the renderer never learns the pane is dead
+ // when the SIGKILL fallback fires for a SIGTERM-ignoring child.
+ this.dispatcher.notify('pty.exit', { id, code: -1 })
// Why: if SIGKILL's onExit never fires (kernel edge case,
// uninterruptible sleep, child wedged on a bad NFS mount), the
// fd and map entry would leak forever. Dispose synchronously so
From a22717bb35d50fcef57348039bbe0f4a7c4fa714 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Fri, 15 May 2026 02:13:37 -0400
Subject: [PATCH 19/82] Refactor runtime app architecture (#1878)
Co-authored-by: Orca
---
.gitignore | 3 +
config/electron-builder.config.cjs | 4 +
.../expo-two-way-audio/ios/AudioEngine.swift | 116 +-
mobile/scripts/test-subscribe.ts | 9 +-
mobile/src/transport/protocol-version.ts | 10 +-
package.json | 2 +-
src/cli/args.ts | 3 +-
src/cli/dispatch.ts | 2 +
src/cli/format.ts | 28 +
src/cli/handlers/core.ts | 27 +
src/cli/handlers/environment.ts | 75 +
src/cli/handlers/repo.ts | 31 +-
src/cli/handlers/terminal.ts | 6 +
src/cli/help.ts | 15 +
src/cli/index.test.ts | 369 +-
src/cli/index.ts | 14 +-
src/cli/runtime-client.ts | 1 +
src/cli/runtime/client.ts | 137 +-
src/cli/runtime/envelope-schema.ts | 67 +-
src/cli/runtime/environments.test.ts | 68 +
src/cli/runtime/environments.ts | 81 +
src/cli/runtime/index.ts | 1 +
src/cli/runtime/launch.test.ts | 88 +
src/cli/runtime/launch.ts | 89 +-
src/cli/runtime/status.ts | 2 +-
src/cli/runtime/transport.ts | 6 +-
src/cli/runtime/types.ts | 33 +-
src/cli/runtime/websocket-transport.test.ts | 237 ++
src/cli/runtime/websocket-transport.ts | 22 +
src/cli/selectors.ts | 29 +-
src/cli/specs/core.ts | 18 +
src/cli/specs/environment.ts | 30 +
src/cli/specs/index.ts | 2 +
src/main/cli/packaged-cli-assets.test.ts | 19 +
src/main/git/status.test.ts | 12 +-
src/main/git/status.ts | 13 +-
src/main/index.ts | 202 +-
.../ipc/filesystem-import-ssh-ops.test.ts | 11 +-
src/main/ipc/filesystem-import-ssh.test.ts | 49 +-
src/main/ipc/filesystem-import-ssh.ts | 10 +-
src/main/ipc/filesystem-import.test.ts | 290 +-
src/main/ipc/filesystem-mutations.test.ts | 46 +-
src/main/ipc/filesystem-mutations.ts | 350 +-
src/main/ipc/filesystem-watcher.test.ts | 31 +
src/main/ipc/filesystem-watcher.ts | 12 +-
src/main/ipc/mobile.test.ts | 72 +
src/main/ipc/mobile.ts | 41 +-
src/main/ipc/pty.ts | 35 +
src/main/ipc/register-core-handlers.test.ts | 8 +
src/main/ipc/register-core-handlers.ts | 2 +
.../ipc/runtime-environment-call-queue.ts | 92 +
...runtime-environment-request-connections.ts | 46 +
src/main/ipc/runtime-environments.test.ts | 615 +++
src/main/ipc/runtime-environments.ts | 313 ++
src/main/ipc/runtime.test.ts | 32 +-
src/main/ipc/runtime.ts | 18 +
src/main/ipc/worktree-remote.ts | 4 +-
src/main/powershell-osc133-bootstrap.test.ts | 11 +-
src/main/powershell-osc133-bootstrap.ts | 10 +-
.../providers/ssh-filesystem-provider.test.ts | 167 +
src/main/providers/ssh-filesystem-provider.ts | 94 +-
src/main/providers/types.ts | 3 +
src/main/providers/windows-shell-args.test.ts | 7 +-
src/main/repo-worktrees.ts | 8 +
src/main/runtime/device-registry.ts | 37 +-
src/main/runtime/e2ee-keypair.ts | 7 +-
src/main/runtime/mobile-presence-lock.test.ts | 21 +
src/main/runtime/orca-runtime-browser.ts | 1455 +++++++
src/main/runtime/orca-runtime-files.test.ts | 112 +
src/main/runtime/orca-runtime-files.ts | 918 +++++
src/main/runtime/orca-runtime-git.ts | 308 ++
src/main/runtime/orca-runtime.test.ts | 554 ++-
src/main/runtime/orca-runtime.ts | 3586 +++++++++--------
.../orchestration-cli-subprocess.test.ts} | 14 +-
...ime-request-connection.integration.test.ts | 67 +
src/main/runtime/rpc/core.ts | 8 +
src/main/runtime/rpc/dispatcher.ts | 14 +-
src/main/runtime/rpc/e2ee-channel.test.ts | 16 +-
src/main/runtime/rpc/e2ee-channel.ts | 13 +
src/main/runtime/rpc/e2ee-crypto.ts | 61 +-
src/main/runtime/rpc/methods/browser-core.ts | 16 +
.../runtime/rpc/methods/browser-schemas.ts | 6 +
src/main/runtime/rpc/methods/browser.test.ts | 144 +
.../rpc/methods/file-watch-event-batcher.ts | 65 +
src/main/runtime/rpc/methods/files.test.ts | 394 ++
src/main/runtime/rpc/methods/files.ts | 286 +-
src/main/runtime/rpc/methods/git.test.ts | 157 +
src/main/runtime/rpc/methods/git.ts | 175 +
src/main/runtime/rpc/methods/github.test.ts | 519 +++
src/main/runtime/rpc/methods/github.ts | 442 ++
.../runtime/rpc/methods/hosted-review.test.ts | 48 +
src/main/runtime/rpc/methods/hosted-review.ts | 26 +
src/main/runtime/rpc/methods/index.ts | 8 +
src/main/runtime/rpc/methods/linear.test.ts | 101 +
src/main/runtime/rpc/methods/linear.ts | 136 +
src/main/runtime/rpc/methods/notes.test.ts | 148 +
src/main/runtime/rpc/methods/notes.ts | 88 +-
.../runtime/rpc/methods/orchestration.test.ts | 36 +
src/main/runtime/rpc/methods/orchestration.ts | 6 +
src/main/runtime/rpc/methods/repo.test.ts | 96 +
src/main/runtime/rpc/methods/repo.ts | 94 +-
src/main/runtime/rpc/methods/terminal.ts | 642 ++-
src/main/runtime/rpc/methods/worktree.test.ts | 64 +
src/main/runtime/rpc/methods/worktree.ts | 104 +-
src/main/runtime/rpc/streaming.test.ts | 55 +
.../runtime/rpc/terminal-multiplex.test.ts | 395 ++
.../rpc/terminal-output-batching.test.ts | 249 ++
src/main/runtime/rpc/terminal-send.test.ts | 104 +
.../rpc/terminal-subscribe-buffer.test.ts | 104 +
src/main/runtime/rpc/ws-transport.test.ts | 108 +-
src/main/runtime/rpc/ws-transport.ts | 84 +-
src/main/runtime/runtime-metadata.test.ts | 95 +-
src/main/runtime/runtime-metadata.ts | 97 +-
src/main/runtime/runtime-relative-paths.ts | 29 +
src/main/runtime/runtime-rpc.test.ts | 484 +++
src/main/runtime/runtime-rpc.ts | 245 +-
src/main/ssh/sftp-upload.test.ts | 63 +
src/main/ssh/sftp-upload.ts | 114 +-
src/main/ssh/ssh-relay-session.ts | 16 +-
src/preload/api-types.ts | 71 +
src/preload/index.ts | 79 +
.../runtime-environment-subscriptions.test.ts | 103 +
.../runtime-environment-subscriptions.ts | 99 +
src/relay/fs-handler.test.ts | 64 +
src/relay/fs-handler.ts | 30 +-
src/renderer/src/App.tsx | 8 +-
src/renderer/src/app-startup-routing.test.ts | 20 +
.../src/components/GitHubItemDialog.tsx | 304 +-
.../src/components/LinearItemDrawer.tsx | 53 +-
src/renderer/src/components/QuickOpen.tsx | 20 +-
src/renderer/src/components/TaskPage.tsx | 107 +-
.../TelemetryFirstLaunchSurface.tsx | 5 +-
src/renderer/src/components/Terminal.tsx | 9 +-
.../src/components/WorktreeJumpPalette.tsx | 64 +-
.../automations/CreateFromPicker.tsx | 18 +-
.../components/browser-pane/BrowserPane.tsx | 524 ++-
.../components/editor/CombinedDiffViewer.tsx | 70 +-
.../src/components/editor/EditorContent.tsx | 1 +
.../src/components/editor/EditorPanel.tsx | 136 +-
.../src/components/editor/MarkdownPreview.tsx | 61 +-
.../editor/MonacoGutterContextMenu.tsx | 16 +-
.../components/editor/RichMarkdownEditor.tsx | 58 +-
.../editor/UntitledFileRenameDialog.tsx | 7 +-
.../editor/editor-autosave-controller.test.ts | 74 +
.../editor/editor-autosave-controller.ts | 24 +-
.../editor/rich-markdown-extensions.ts | 8 +-
.../src/components/editor/useLinkBubble.ts | 5 +-
.../components/editor/useLocalImagePick.ts | 62 +-
.../editor/useLocalImageSrc.test.ts | 25 +
.../src/components/editor/useLocalImageSrc.ts | 72 +-
.../components/editor/useMarkdownDocuments.ts | 48 +-
.../FloatingTerminalPanel.tsx | 6 +-
.../components/github-project/ProjectCell.tsx | 105 +-
.../github-project/ProjectPicker.tsx | 75 +-
.../github-project/ProjectViewWrapper.tsx | 36 +-
.../slug-dialog/AssigneesEditor.tsx | 51 +-
.../github-project/slug-dialog/Comments.tsx | 48 +-
.../slug-dialog/LabelsEditor.tsx | 41 +-
.../components/github/GitHubRateLimitPill.tsx | 51 +-
.../new-workspace/SmartWorkspaceNameField.tsx | 152 +-
.../notes/ProjectNotesTabContent.tsx | 42 +-
.../components/onboarding/OnboardingFlow.tsx | 6 +
.../src/components/onboarding/RepoStep.tsx | 120 +-
.../onboarding/use-onboarding-flow.ts | 119 +-
.../components/right-sidebar/ChecksPanel.tsx | 20 +-
.../components/right-sidebar/FileExplorer.tsx | 2 +-
.../right-sidebar/FileExplorerRow.tsx | 22 +-
.../components/right-sidebar/NotesPanel.tsx | 18 +-
.../components/right-sidebar/PRActions.tsx | 22 +-
.../src/components/right-sidebar/Search.tsx | 36 +-
.../right-sidebar/SourceControl.tsx | 137 +-
.../right-sidebar/git-status-refresh.ts | 9 +-
.../right-sidebar/useFileDeletion.ts | 46 +-
.../right-sidebar/useFileDuplicate.ts | 18 +-
.../right-sidebar/useFileExplorerDragDrop.ts | 13 +-
.../right-sidebar/useFileExplorerImport.ts | 28 +-
.../useFileExplorerInlineInput.ts | 23 +-
.../right-sidebar/useFileExplorerTree.ts | 12 +-
.../useFileExplorerWatch.test.ts | 40 +
.../right-sidebar/useFileExplorerWatch.ts | 126 +-
.../right-sidebar/useGitStatusPolling.test.ts | 8 +-
.../right-sidebar/useGitStatusPolling.ts | 6 +-
.../src/components/settings/BaseRefPicker.tsx | 21 +-
.../settings/ManageSessionsSection.tsx | 36 +-
.../settings/RepositoryHooksSection.tsx | 14 +-
.../settings/RuntimeEnvironmentsPane.tsx | 413 ++
.../src/components/settings/Settings.tsx | 35 +-
.../settings/WorktreeSymlinksSection.tsx | 8 +-
.../components/sidebar/AddRepoCreateStep.tsx | 50 +-
.../src/components/sidebar/AddRepoDialog.tsx | 144 +-
.../src/components/sidebar/AddRepoSteps.tsx | 5 +-
.../src/components/sidebar/WorktreeCard.tsx | 5 +-
.../sidebar/WorktreeContextMenu.tsx | 11 +-
.../src/components/sidebar/WorktreeList.tsx | 11 +-
.../status-bar/ResourceUsageStatusSegment.tsx | 83 +-
.../src/components/tab-bar/EditorFileTab.tsx | 16 +-
.../src/components/tab-bar/TabBar.tsx | 6 +-
.../editor-tab-local-open-guard.test.ts | 22 +
.../tab-bar/editor-tab-local-open-guard.ts | 13 +
.../tab-group/useTabGroupWorkspaceModel.ts | 26 +-
.../components/terminal-pane/TerminalPane.tsx | 60 +-
.../terminal-pane/pty-connection.test.ts | 74 +-
.../terminal-pane/pty-connection.ts | 79 +-
.../terminal-pane/pty-transport.test.ts | 220 +
.../components/terminal-pane/pty-transport.ts | 286 +-
.../remote-runtime-pty-batching.ts | 81 +
.../remote-runtime-pty-binary-control.ts | 47 +
.../remote-runtime-pty-transport.test.ts | 512 +++
.../remote-runtime-pty-transport.ts | 324 ++
.../terminal-pane/resolve-split-cwd.test.ts | 20 +-
.../terminal-pane/resolve-split-cwd.ts | 3 +-
.../terminal-drop-handler.test.ts | 166 +
.../terminal-pane/terminal-drop-handler.ts | 139 +-
.../terminal-link-handlers.test.ts | 150 +-
.../terminal-pane/terminal-link-handlers.ts | 70 +-
.../use-terminal-pane-lifecycle.ts | 20 +-
src/renderer/src/hooks/useComposerState.ts | 353 +-
.../src/hooks/useEditorExternalWatch.test.ts | 108 +-
.../src/hooks/useEditorExternalWatch.ts | 111 +-
.../src/hooks/useGitHubSlugMetadata.ts | 70 +-
.../src/hooks/useGlobalFileDrop.test.ts | 20 +
src/renderer/src/hooks/useGlobalFileDrop.ts | 82 +-
src/renderer/src/hooks/useIpcEvents.ts | 98 +-
src/renderer/src/hooks/useIssueMetadata.ts | 157 +-
src/renderer/src/lib/agent-paste-draft.ts | 28 +-
src/renderer/src/lib/agent-ready-wait.ts | 7 +-
.../src/lib/codex-session-restart.test.ts | 68 +-
src/renderer/src/lib/codex-session-restart.ts | 7 +-
.../src/lib/create-untitled-markdown.test.ts | 89 +-
.../src/lib/create-untitled-markdown.ts | 17 +-
.../src/lib/ensure-hooks-confirmed.ts | 5 +-
.../src/lib/http-link-routing.test.ts | 14 +-
src/renderer/src/lib/http-link-routing.ts | 8 +-
.../launch-agent-background-session.test.ts | 73 +-
.../lib/launch-agent-background-session.ts | 101 +-
.../src/lib/launch-work-item-direct.ts | 3 +-
.../src/lib/local-path-open-guard.test.ts | 18 +
src/renderer/src/lib/local-path-open-guard.ts | 15 +
src/renderer/src/lib/new-workspace.ts | 12 +-
.../src/lib/open-project-notes-tab.ts | 6 +-
src/renderer/src/lib/rename-file.ts | 13 +-
src/renderer/src/lib/repo-slug-index.ts | 65 +-
src/renderer/src/lib/workspace-session.ts | 3 +-
.../src/runtime/mobile-markdown-bridge.ts | 8 +-
.../src/runtime/remote-file-client.ts | 22 +
.../remote-runtime-terminal-multiplexer.ts | 379 ++
.../runtime-compatibility-test-fixture.ts | 37 +
.../src/runtime/runtime-file-client.test.ts | 1186 ++++++
.../src/runtime/runtime-file-client.ts | 860 ++++
.../src/runtime/runtime-git-client.test.ts | 144 +
.../src/runtime/runtime-git-client.ts | 340 ++
.../src/runtime/runtime-hooks-client.test.ts | 96 +
.../src/runtime/runtime-hooks-client.ts | 66 +
.../src/runtime/runtime-linear-client.test.ts | 148 +
.../src/runtime/runtime-linear-client.ts | 222 +
.../src/runtime/runtime-notes-client.test.ts | 228 ++
.../src/runtime/runtime-notes-client.ts | 229 ++
.../src/runtime/runtime-protocol-compat.ts | 19 +
.../src/runtime/runtime-repo-client.ts | 42 +
.../src/runtime/runtime-rpc-client.test.ts | 155 +
.../src/runtime/runtime-rpc-client.ts | 108 +
.../runtime-terminal-inspection.test.ts | 72 +
.../runtime/runtime-terminal-inspection.ts | 62 +
.../runtime/runtime-terminal-stream.test.ts | 120 +
.../src/runtime/runtime-terminal-stream.ts | 163 +
src/renderer/src/store/slices/browser.test.ts | 1144 ++----
src/renderer/src/store/slices/browser.ts | 280 +-
.../src/store/slices/diffComments.test.ts | 56 +
src/renderer/src/store/slices/diffComments.ts | 27 +-
src/renderer/src/store/slices/editor.test.ts | 295 +-
src/renderer/src/store/slices/editor.ts | 149 +-
src/renderer/src/store/slices/github.test.ts | 130 +-
src/renderer/src/store/slices/github.ts | 251 +-
.../src/store/slices/hosted-review.test.ts | 51 +-
.../src/store/slices/hosted-review.ts | 34 +-
src/renderer/src/store/slices/linear.ts | 26 +-
src/renderer/src/store/slices/repos.test.ts | 256 ++
src/renderer/src/store/slices/repos.ts | 119 +-
.../src/store/slices/settings.test.ts | 246 ++
src/renderer/src/store/slices/settings.ts | 236 +-
src/renderer/src/store/slices/terminals.ts | 15 +-
src/renderer/src/store/slices/ui.ts | 1 +
.../src/store/slices/worktrees.test.ts | 169 +
src/renderer/src/store/slices/worktrees.ts | 116 +-
src/shared/constants.ts | 3 +
src/shared/cross-platform-path.test.ts | 26 +
src/shared/cross-platform-path.ts | 57 +
src/shared/e2ee-crypto.ts | 65 +
src/shared/protocol-compat.test.ts | 85 +-
src/shared/protocol-compat.ts | 69 +-
src/shared/protocol-version.ts | 45 +-
src/shared/remote-runtime-client.test.ts | 140 +
src/shared/remote-runtime-client.ts | 552 +++
...e-runtime-request-connection-stale.test.ts | 115 +
.../remote-runtime-request-connection.test.ts | 142 +
.../remote-runtime-request-connection.ts | 292 ++
src/shared/remote-runtime-request-frames.ts | 98 +
.../remote-runtime-request-websocket.ts | 96 +
src/shared/runtime-environment-store.test.ts | 47 +
src/shared/runtime-environment-store.ts | 166 +
src/shared/runtime-environments.ts | 99 +
src/shared/runtime-rpc-envelope.ts | 73 +
src/shared/runtime-types.ts | 58 +-
src/shared/secure-file.ts | 109 +
src/shared/terminal-stream-protocol.test.ts | 55 +
src/shared/terminal-stream-protocol.ts | 12 +-
src/shared/types.ts | 4 +
src/shared/workspace-session-schema.ts | 3 +-
308 files changed, 32140 insertions(+), 4464 deletions(-)
create mode 100644 src/cli/handlers/environment.ts
create mode 100644 src/cli/runtime/environments.test.ts
create mode 100644 src/cli/runtime/environments.ts
create mode 100644 src/cli/runtime/launch.test.ts
create mode 100644 src/cli/runtime/websocket-transport.test.ts
create mode 100644 src/cli/runtime/websocket-transport.ts
create mode 100644 src/cli/specs/environment.ts
create mode 100644 src/main/cli/packaged-cli-assets.test.ts
create mode 100644 src/main/ipc/mobile.test.ts
create mode 100644 src/main/ipc/runtime-environment-call-queue.ts
create mode 100644 src/main/ipc/runtime-environment-request-connections.ts
create mode 100644 src/main/ipc/runtime-environments.test.ts
create mode 100644 src/main/ipc/runtime-environments.ts
create mode 100644 src/main/runtime/orca-runtime-browser.ts
create mode 100644 src/main/runtime/orca-runtime-files.test.ts
create mode 100644 src/main/runtime/orca-runtime-files.ts
create mode 100644 src/main/runtime/orca-runtime-git.ts
rename src/{cli/orchestration.subprocess.test.ts => main/runtime/orchestration-cli-subprocess.test.ts} (93%)
create mode 100644 src/main/runtime/remote-runtime-request-connection.integration.test.ts
create mode 100644 src/main/runtime/rpc/methods/browser.test.ts
create mode 100644 src/main/runtime/rpc/methods/file-watch-event-batcher.ts
create mode 100644 src/main/runtime/rpc/methods/git.test.ts
create mode 100644 src/main/runtime/rpc/methods/git.ts
create mode 100644 src/main/runtime/rpc/methods/github.test.ts
create mode 100644 src/main/runtime/rpc/methods/github.ts
create mode 100644 src/main/runtime/rpc/methods/hosted-review.test.ts
create mode 100644 src/main/runtime/rpc/methods/hosted-review.ts
create mode 100644 src/main/runtime/rpc/methods/linear.test.ts
create mode 100644 src/main/runtime/rpc/methods/linear.ts
create mode 100644 src/main/runtime/rpc/methods/notes.test.ts
create mode 100644 src/main/runtime/rpc/methods/repo.test.ts
create mode 100644 src/main/runtime/rpc/methods/worktree.test.ts
create mode 100644 src/main/runtime/rpc/terminal-multiplex.test.ts
create mode 100644 src/main/runtime/rpc/terminal-output-batching.test.ts
create mode 100644 src/main/runtime/rpc/terminal-send.test.ts
create mode 100644 src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
create mode 100644 src/main/runtime/runtime-relative-paths.ts
create mode 100644 src/main/ssh/sftp-upload.test.ts
create mode 100644 src/preload/runtime-environment-subscriptions.test.ts
create mode 100644 src/preload/runtime-environment-subscriptions.ts
create mode 100644 src/renderer/src/app-startup-routing.test.ts
create mode 100644 src/renderer/src/components/editor/useLocalImageSrc.test.ts
create mode 100644 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts
create mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts
create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts
create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-binary-control.ts
create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
create mode 100644 src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts
create mode 100644 src/renderer/src/hooks/useGlobalFileDrop.test.ts
create mode 100644 src/renderer/src/lib/local-path-open-guard.test.ts
create mode 100644 src/renderer/src/lib/local-path-open-guard.ts
create mode 100644 src/renderer/src/runtime/remote-file-client.ts
create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
create mode 100644 src/renderer/src/runtime/runtime-compatibility-test-fixture.ts
create mode 100644 src/renderer/src/runtime/runtime-file-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-file-client.ts
create mode 100644 src/renderer/src/runtime/runtime-git-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-git-client.ts
create mode 100644 src/renderer/src/runtime/runtime-hooks-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-hooks-client.ts
create mode 100644 src/renderer/src/runtime/runtime-linear-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-linear-client.ts
create mode 100644 src/renderer/src/runtime/runtime-notes-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-notes-client.ts
create mode 100644 src/renderer/src/runtime/runtime-protocol-compat.ts
create mode 100644 src/renderer/src/runtime/runtime-repo-client.ts
create mode 100644 src/renderer/src/runtime/runtime-rpc-client.test.ts
create mode 100644 src/renderer/src/runtime/runtime-rpc-client.ts
create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.test.ts
create mode 100644 src/renderer/src/runtime/runtime-terminal-inspection.ts
create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.test.ts
create mode 100644 src/renderer/src/runtime/runtime-terminal-stream.ts
create mode 100644 src/renderer/src/store/slices/repos.test.ts
create mode 100644 src/renderer/src/store/slices/settings.test.ts
create mode 100644 src/shared/cross-platform-path.test.ts
create mode 100644 src/shared/cross-platform-path.ts
create mode 100644 src/shared/e2ee-crypto.ts
create mode 100644 src/shared/remote-runtime-client.test.ts
create mode 100644 src/shared/remote-runtime-client.ts
create mode 100644 src/shared/remote-runtime-request-connection-stale.test.ts
create mode 100644 src/shared/remote-runtime-request-connection.test.ts
create mode 100644 src/shared/remote-runtime-request-connection.ts
create mode 100644 src/shared/remote-runtime-request-frames.ts
create mode 100644 src/shared/remote-runtime-request-websocket.ts
create mode 100644 src/shared/runtime-environment-store.test.ts
create mode 100644 src/shared/runtime-environment-store.ts
create mode 100644 src/shared/runtime-environments.ts
create mode 100644 src/shared/runtime-rpc-envelope.ts
create mode 100644 src/shared/secure-file.ts
diff --git a/.gitignore b/.gitignore
index 2afd28d2a43..92b3d28c436 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,9 @@ design-docs/
.context/
.atl/
+# Machine-local agent hook endpoint files may contain auth tokens.
+/agent-hooks/
+
# Local-only design/planning docs (not checked in)
docs/*.md
!docs/README*.md
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 30fc98ae895..e1a63218fe7 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -39,6 +39,8 @@ module.exports = {
// integration — dependencies inside the asar archive are invisible to
// require(). Unpack CLI runtime deps so they resolve from
// app.asar.unpacked/node_modules/.
+ // Why: remote runtime connections use WebSocket + E2EE from the packaged CLI
+ // before the GUI process starts, so those deps need the same treatment.
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
@@ -49,6 +51,8 @@ module.exports = {
'out/main/computer-sidecar.js',
'out/main/chunks/**',
'resources/**',
+ 'node_modules/ws/**',
+ 'node_modules/tweetnacl/**',
'node_modules/zod/**',
'node_modules/sherpa-onnx*/**'
],
diff --git a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift
index f079c89b7d3..ac259e3794c 100644
--- a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift
+++ b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift
@@ -7,41 +7,41 @@ class AudioEngine {
private var engineConfigChangeObserver: Any?
private var sessionInterruptionObserver: Any?
private var mediaServicesResetObserver: Any?
-
+
public private(set) var voiceIOFormat: AVAudioFormat
public private(set) var isRecording = false
private var wasRecordingBeforeInterruption = false
-
+
public var onMicDataCallback: ((Data) -> Void)?
public var onInputVolumeCallback: ((Float) -> Void)?
public var onOutputVolumeCallback: ((Float) -> Void)?
public var onAudioInterruptionCallback: ((String) -> Void)?
-
+
private var inputLevelTimer: Timer?
private var outputLevelTimer: Timer?
-
+
private var inputBuffer = [Float](repeating: 0, count: 2048)
private var outputBuffer = [Float](repeating: 0, count: 2048)
private var inputBufferIndex = 0
private var outputBufferIndex = 0
-
+
private var hasFirstInputBeenDiscarded = false
private var discardRecording = false
private var discardFirstInputMillis = 2000
-
+
enum AudioEngineError: Error {
case audioFormatError
}
-
+
init() throws {
avAudioEngine.attach(speechPlayer)
-
+
guard let format = AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1) else {
throw AudioEngineError.audioFormatError
}
voiceIOFormat = format
print("Voice IO format: \(String(describing: voiceIOFormat))")
-
+
engineConfigChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: avAudioEngine,
@@ -60,12 +60,12 @@ class AudioEngine {
queue: .main) { [weak self] _ in
self?.handleMediaServicesWereReset()
}
-
+
self.setupAudioSession()
self.setup()
self.start()
}
-
+
deinit {
if let observer = engineConfigChangeObserver {
NotificationCenter.default.removeObserver(observer)
@@ -77,29 +77,29 @@ class AudioEngine {
NotificationCenter.default.removeObserver(observer)
}
}
-
+
func setupAudioSession() {
let session = AVAudioSession.sharedInstance()
-
+
do {
try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])
} catch {
print("Could not set the audio category: \(error.localizedDescription)")
}
-
+
do {
try session.setPreferredSampleRate(voiceIOFormat.sampleRate)
} catch {
print("Could not set the preferred sample rate: \(error.localizedDescription)")
}
-
+
do {
try session.setActive(true)
} catch {
print("Could not set the audio session as active")
}
}
-
+
func setup() {
let input = avAudioEngine.inputNode
do {
@@ -108,15 +108,15 @@ class AudioEngine {
print("Could not enable voice processing \(error)")
return
}
-
+
avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording
-
+
let output = avAudioEngine.outputNode
let mainMixer = avAudioEngine.mainMixerNode
-
+
avAudioEngine.connect(speechPlayer, to: mainMixer, format: voiceIOFormat)
avAudioEngine.connect(mainMixer, to: output, format: voiceIOFormat)
-
+
input.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
// We don't do any input processing (no volume calculation or passing mic data to the callback) if discardRecording == true
// See comment in the playPCMData function
@@ -125,48 +125,48 @@ class AudioEngine {
self?.updateInputVolume()
}
}
-
+
mainMixer.installTap(onBus: 0, bufferSize: 2048, format: voiceIOFormat) { [weak self] buffer, when in
self?.processOutputBuffer(buffer)
self?.updateOutputVolume()
}
-
+
avAudioEngine.prepare()
}
-
+
func processMicrophoneBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
-
+
let frameCount = Int(buffer.frameLength)
var int16Samples = [Int16](repeating: 0, count: frameCount)
-
+
// Convert float samples to Int16 and update input buffer for volume calculation
for i in 0...size)
-
+
// Send the data to the callback
onMicDataCallback?(data)
}
-
+
func processOutputBuffer(_ buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData?[0] else {
print("Error: Could not access channel data")
return
}
-
+
let frameCount = Int(buffer.frameLength)
-
+
// Update output buffer for volume calculation
for i in 0.. AVAudioPCMBuffer? {
let frameCount = UInt32(data.count) / 2 // 16-bit input = 2 bytes per frame
-
+
let format = AVAudioFormat(commonFormat: .pcmFormatFloat32,
sampleRate: 16000,
channels: 1,
interleaved: false)!
-
+
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else {
return nil
}
-
+
buffer.frameLength = frameCount
-
+
data.withUnsafeBytes { (rawBufferPointer: UnsafeRawBufferPointer) in
if let sourcePtr = rawBufferPointer.baseAddress?.assumingMemoryBound(to: Int16.self),
let destPtr = buffer.floatChannelData?[0] {
@@ -231,15 +231,15 @@ class AudioEngine {
}
}
}
-
+
return buffer
}
-
+
func bypassVoiceProcessing(_ bypass: Bool) {
let input = avAudioEngine.inputNode
input.isVoiceProcessingBypassed = bypass
}
-
+
func toggleRecording(_ val: Bool) -> Bool {
isRecording = val
if !isRecording {
@@ -251,10 +251,10 @@ class AudioEngine {
avAudioEngine.inputNode.isVoiceProcessingInputMuted = false
}
print("Recording \(isRecording ? "started" : "stopped")")
-
+
return isRecording
}
-
+
func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){
if clearInterruptionResume {
wasRecordingBeforeInterruption = false
@@ -268,7 +268,7 @@ class AudioEngine {
speechPlayer.stop()
updateOutputVolume()
}
-
+
func resumeRecordingAndPlayer(){
do {
try AVAudioSession.sharedInstance().setActive(true)
@@ -279,12 +279,12 @@ class AudioEngine {
isRecording = toggleRecording(true)
speechPlayer.play()
}
-
+
func tearDown() {
stopRecordingAndPlayer()
avAudioEngine.stop()
}
-
+
var isPlaying: Bool {
return speechPlayer.isPlaying
}
@@ -308,13 +308,13 @@ class AudioEngine {
print("Playback resumed")
}
}
-
+
private func checkEngineIsRunning() {
if !avAudioEngine.isRunning {
start()
}
}
-
+
private func handleAudioSessionInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
@@ -343,39 +343,39 @@ class AudioEngine {
fatalError("Unknown type: \(type)")
}
}
-
+
private func handleMediaServicesWereReset() {
self.avAudioEngine.stop()
self.setup()
self.start()
}
-
+
private func updateInputVolume() {
let volume = calculateRMSLevel(from: inputBuffer)
onInputVolumeCallback?(volume)
}
-
+
private func updateOutputVolume() {
let volume = calculateRMSLevel(from: outputBuffer)
onOutputVolumeCallback?(volume)
}
-
+
private func calculateRMSLevel(from buffer: [Float]) -> Float {
let epsilon: Float = 1e-5 // To avoid log(0)
let rmsValue = sqrt(buffer.reduce(0) { $0 + $1 * $1 } / Float(buffer.count))
-
+
// Convert to decibels
let dbValue = 20 * log10(max(rmsValue, epsilon))
-
+
// Normalize decibel value to 0-1 range
// Assuming minimum audible is -60dB and maximum is 0dB
let minDb: Float = -80.0
let normalizedValue = max(0.0, min(1.0, (dbValue - minDb) / abs(minDb)))
-
+
// Optional: Apply exponential factor to push smaller values down
let expFactor: Float = 2.0 // Adjust this value to change the curve
let adjustedValue = pow(normalizedValue, expFactor)
-
+
return adjustedValue
}
}
diff --git a/mobile/scripts/test-subscribe.ts b/mobile/scripts/test-subscribe.ts
index 6fa8ffe4b42..a33ddd0c22b 100644
--- a/mobile/scripts/test-subscribe.ts
+++ b/mobile/scripts/test-subscribe.ts
@@ -267,7 +267,14 @@ ws.on('open', () => {
})
ws.on('message', (data) => {
- const plaintext = decrypt(data.toString())
+ let plaintext: string | null = null
+ try {
+ plaintext = decrypt(data.toString())
+ } catch {
+ // Plaintext handshake/control frames such as e2ee_ready are handled by the
+ // connect flow above. The global listener only cares about encrypted RPC.
+ return
+ }
if (!plaintext) return
const response = JSON.parse(plaintext) as RpcResponse
if (response._meta?.runtimeId) {
diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts
index 3ba4ede3878..4c1cf137738 100644
--- a/mobile/src/transport/protocol-version.ts
+++ b/mobile/src/transport/protocol-version.ts
@@ -1,20 +1,20 @@
-// Why: declares the mobile's pairing protocol version and the minimum
-// desktop version it can talk to. Duplicates the desktop's
+// Why: declares the mobile client's runtime protocol version and the minimum
+// server protocol it can talk to. Duplicates the desktop's
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
// outside `mobile/`. Manual sync is acceptable — these constants are
// expected to bump less than once a quarter.
//
// Bump MOBILE_PROTOCOL_VERSION when:
// - You change the meaning of an RPC mobile sends.
-// - You stop relying on a desktop-side feature in a way old desktops
+// - You stop relying on a server-side feature in a way old servers
// would notice.
// Do NOT bump for:
// - Adding new optional fields to outbound requests.
// - Reading new optional fields on incoming responses.
//
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
-// desktop feature added at a specific desktop protocol version. This
-// triggers a hard-block screen for users paired to older desktops.
+// server feature added at a specific runtime protocol version. This
+// triggers a hard-block screen for users paired to older servers.
export const MOBILE_PROTOCOL_VERSION = 2
export const MIN_COMPATIBLE_DESKTOP_VERSION = 2
diff --git a/package.json b/package.json
index 39e335bc753..5da12543545 100644
--- a/package.json
+++ b/package.json
@@ -88,8 +88,8 @@
"cmdk": "^1.1.1",
"dompurify": "^3.4.2",
"electron-updater": "^6.8.3",
- "entities": "^6.0.1",
"emoji-picker-react": "^4.19.1",
+ "entities": "^6.0.1",
"github-slugger": "^2.0.0",
"hosted-git-info": "^9.0.3",
"html-to-image": "^1.11.13",
diff --git a/src/cli/args.ts b/src/cli/args.ts
index ece4215d8f5..9af56d69065 100644
--- a/src/cli/args.ts
+++ b/src/cli/args.ts
@@ -14,7 +14,7 @@ export type CommandSpec = {
notes?: string[]
}
-export const GLOBAL_FLAGS = ['help', 'json']
+export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = []
@@ -93,6 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
'storage',
'orchestration',
'computer',
+ 'environment',
'note'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index 1937a4593ad..00896007067 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
import { COMPUTER_HANDLERS } from './handlers/computer'
+import { ENVIRONMENT_HANDLERS } from './handlers/environment'
import { NOTE_HANDLERS } from './handlers/note'
export type HandlerContext = {
@@ -42,6 +43,7 @@ function buildHandlers(): Map {
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
+ ENVIRONMENT_HANDLERS,
NOTE_HANDLERS
]
for (const group of groups) {
diff --git a/src/cli/format.ts b/src/cli/format.ts
index b8cce4771af..0b99b8bcae4 100644
--- a/src/cli/format.ts
+++ b/src/cli/format.ts
@@ -33,6 +33,7 @@ import type {
RuntimeWorktreePsResult,
RuntimeWorktreeRecord
} from '../shared/runtime-types'
+import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
@@ -101,6 +102,33 @@ export function formatStatus(status: CliStatusResult): string {
return formatCliStatus(status)
}
+export function formatEnvironmentList(result: {
+ environments: PublicKnownRuntimeEnvironment[]
+}): string {
+ if (result.environments.length === 0) {
+ return 'No saved environments.'
+ }
+ return result.environments
+ .map(
+ (environment) =>
+ `${environment.id} ${environment.name} ${environment.endpoints[0]?.endpoint ?? 'no-endpoint'}`
+ )
+ .join('\n')
+}
+
+export function formatEnvironment(environment: PublicKnownRuntimeEnvironment): string {
+ return [
+ `id: ${environment.id}`,
+ `name: ${environment.name}`,
+ `runtimeId: ${environment.runtimeId ?? 'unknown'}`,
+ `lastUsedAt: ${environment.lastUsedAt ?? 'never'}`,
+ `preferredEndpointId: ${environment.preferredEndpointId}`,
+ ...environment.endpoints.map(
+ (endpoint) => `endpoint: ${endpoint.id} ${endpoint.kind} ${endpoint.endpoint}`
+ )
+ ].join('\n')
+}
+
export function formatTerminalList(result: RuntimeTerminalListResult): string {
if (result.terminals.length === 0) {
return 'No live terminals.'
diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts
index f96a941daf9..895aa7abca8 100644
--- a/src/cli/handlers/core.ts
+++ b/src/cli/handlers/core.ts
@@ -1,11 +1,38 @@
import type { CommandHandler } from '../dispatch'
import { formatCliStatus, formatStatus, printResult } from '../format'
+import { RuntimeClientError, serveOrcaApp } from '../runtime-client'
export const CORE_HANDLERS: Record = {
open: async ({ client, json }) => {
const result = await client.openOrca()
printResult(result, json, formatCliStatus)
},
+ serve: async ({ flags, json }) => {
+ if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Use either --mobile-pairing or --no-pairing, not both.'
+ )
+ }
+ const rawPort = flags.get('port')
+ if (typeof rawPort === 'string') {
+ const port = Number(rawPort)
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ throw new RuntimeClientError('invalid_argument', `Invalid --port value: ${rawPort}`)
+ }
+ }
+ const exitCode = await serveOrcaApp({
+ json,
+ port: typeof rawPort === 'string' ? rawPort : null,
+ pairingAddress:
+ typeof flags.get('pairing-address') === 'string'
+ ? (flags.get('pairing-address') as string)
+ : null,
+ noPairing: flags.get('no-pairing') === true,
+ mobilePairing: flags.get('mobile-pairing') === true
+ })
+ process.exitCode = exitCode
+ },
status: async ({ client, json }) => {
const result = await client.getCliStatus()
if (!json && !result.result.runtime.reachable) {
diff --git a/src/cli/handlers/environment.ts b/src/cli/handlers/environment.ts
new file mode 100644
index 00000000000..08530ea29c3
--- /dev/null
+++ b/src/cli/handlers/environment.ts
@@ -0,0 +1,75 @@
+import type { CommandHandler } from '../dispatch'
+import { formatEnvironment, formatEnvironmentList, printResult } from '../format'
+import { getDefaultUserDataPath } from '../runtime-client'
+import type { RuntimeRpcSuccess } from '../runtime-client'
+import { RuntimeClientError } from '../runtime-client'
+import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
+import {
+ addEnvironmentFromPairingCode,
+ listEnvironments,
+ removeEnvironment,
+ resolveEnvironment,
+ type EnvironmentAddResult,
+ type EnvironmentRemoveResult
+} from '../runtime/environments'
+
+export const ENVIRONMENT_HANDLERS: Record = {
+ 'environment add': async ({ flags, json }) => {
+ const name = getRequiredStringFlag(flags, 'name')
+ const pairingCode = getRequiredStringFlag(flags, 'pairing-code')
+ const environment = redactRuntimeEnvironment(
+ addEnvironmentFromPairingCode(getDefaultUserDataPath(), {
+ name,
+ pairingCode
+ })
+ )
+ printResult(
+ localSuccess({ environment }),
+ json,
+ (result: EnvironmentAddResult) =>
+ `Saved environment ${result.environment.name} (${result.environment.id}).`
+ )
+ },
+ 'environment list': async ({ json }) => {
+ const environments = listEnvironments(getDefaultUserDataPath()).map(redactRuntimeEnvironment)
+ printResult(localSuccess({ environments }), json, formatEnvironmentList)
+ },
+ 'environment show': async ({ flags, json }) => {
+ const selector = getRequiredStringFlag(flags, 'environment')
+ const environment = redactRuntimeEnvironment(
+ resolveEnvironment(getDefaultUserDataPath(), selector)
+ )
+ printResult(localSuccess({ environment }), json, ({ environment: value }) =>
+ formatEnvironment(value)
+ )
+ },
+ 'environment rm': async ({ flags, json }) => {
+ const selector = getRequiredStringFlag(flags, 'environment')
+ const removed = redactRuntimeEnvironment(removeEnvironment(getDefaultUserDataPath(), selector))
+ printResult(
+ localSuccess({ removed }),
+ json,
+ (result: EnvironmentRemoveResult) =>
+ `Removed environment ${result.removed.name} (${result.removed.id}).`
+ )
+ }
+}
+
+function getRequiredStringFlag(flags: Map, name: string): string {
+ const value = flags.get(name)
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
+ }
+ return value
+}
+
+function localSuccess(result: TResult): RuntimeRpcSuccess {
+ return {
+ id: 'local',
+ ok: true,
+ result,
+ _meta: {
+ runtimeId: 'local'
+ }
+ }
+}
diff --git a/src/cli/handlers/repo.ts b/src/cli/handlers/repo.ts
index a1061ee271b..fc4c9f22809 100644
--- a/src/cli/handlers/repo.ts
+++ b/src/cli/handlers/repo.ts
@@ -1,16 +1,43 @@
+import { resolve as resolvePath } from 'path'
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
+import { RuntimeClientError } from '../runtime-client'
+
+function isAbsoluteServerPath(value: string): boolean {
+ return (
+ value.startsWith('/') ||
+ /^[A-Za-z]:[\\/]/.test(value) ||
+ value.startsWith('\\\\') ||
+ value.startsWith('//')
+ )
+}
+
+function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
+ if (!isRemote) {
+ return resolvePath(cwd, inputPath)
+ }
+ // Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
+ // Relative remote paths would silently target the wrong machine.
+ if (!isAbsoluteServerPath(inputPath)) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Remote repo add requires --path to be an absolute path on the remote server.'
+ )
+ }
+ return inputPath
+}
export const REPO_HANDLERS: Record = {
'repo list': async ({ client, json }) => {
const result = await client.call('repo.list')
printResult(result, json, formatRepoList)
},
- 'repo add': async ({ flags, client, json }) => {
+ 'repo add': async ({ flags, client, cwd, json }) => {
+ const repoPath = getRequiredStringFlag(flags, 'path')
const result = await client.call<{ repo: Record }>('repo.add', {
- path: getRequiredStringFlag(flags, 'path')
+ path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
})
printResult(result, json, formatRepoShow)
},
diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts
index e59ad46e712..831796a488f 100644
--- a/src/cli/handlers/terminal.ts
+++ b/src/cli/handlers/terminal.ts
@@ -116,6 +116,12 @@ export const TERMINAL_HANDLERS: Record = {
printResult(result, json, formatTerminalRename)
},
'terminal create': async ({ flags, client, cwd, json }) => {
+ if (client.isRemote && !flags.has('worktree')) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Remote terminal create requires --worktree because the client cwd cannot identify a server worktree.'
+ )
+ }
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command: getOptionalStringFlag(flags, 'command'),
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 411ddd98715..b129306953f 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -8,8 +8,15 @@ Usage: orca [options]
Startup:
open Launch Orca and wait for the runtime to be reachable
+ serve Start a headless Orca runtime server
status Show app/runtime/graph readiness
+Environments:
+ environment add Save a remote Orca runtime from a pairing code
+ environment list List saved remote Orca runtimes
+ environment show Show one saved remote Orca runtime
+ environment rm Remove a saved remote Orca runtime
+
Repos:
repo list List repos registered in Orca
repo add Add a project to Orca by filesystem path
@@ -138,7 +145,12 @@ Browser Automation:
Common Commands:
orca open [--json]
+ orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json]
orca status [--json]
+ orca environment add --name --pairing-code [--json]
+ orca environment list [--json]
+ orca environment show --environment [--json]
+ orca environment rm --environment [--json]
orca worktree list [--repo ] [--limit ] [--json]
orca worktree create --repo --name [--base-branch [] [--issue ]] [--comment ] [--run-hooks] [--activate] [--json]
orca worktree show --worktree [--json]
@@ -178,10 +190,13 @@ Wait Options:
Output Options:
--json Emit machine-readable JSON instead of human text
+ --pairing-code Connect to a remote Orca runtime using an orca://pair#... code
+ --environment Connect using a saved environment id or name
--help Show this help message
Behavior:
Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first.
+ Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.
Use selectors for discovery and handles for repeated live terminal operations.
Browser Workflow:
diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts
index 668354a2607..6758745bd0d 100644
--- a/src/cli/index.test.ts
+++ b/src/cli/index.test.ts
@@ -2,13 +2,47 @@
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-const callMock = vi.fn()
+const {
+ callMock,
+ serveOrcaAppMock,
+ getDefaultUserDataPathMock,
+ addEnvironmentFromPairingCodeMock,
+ listEnvironmentsMock
+} = vi.hoisted(() => ({
+ callMock: vi.fn(),
+ serveOrcaAppMock: vi.fn(),
+ getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
+ addEnvironmentFromPairingCodeMock: vi.fn(),
+ listEnvironmentsMock: vi.fn()
+}))
vi.mock('./runtime-client', () => {
class RuntimeClient {
+ readonly isRemote: boolean
call = callMock
getCliStatus = vi.fn()
openOrca = vi.fn()
+
+ constructor(
+ _userDataPath?: string,
+ _requestTimeoutMs?: number,
+ remotePairingCode?: string | null,
+ environmentSelector?: string | null
+ ) {
+ const effectivePairingCode =
+ remotePairingCode === undefined
+ ? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
+ : remotePairingCode
+ const effectiveEnvironment =
+ environmentSelector === undefined ? process.env.ORCA_ENVIRONMENT : environmentSelector
+ if (effectivePairingCode && effectiveEnvironment) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Use either --pairing-code or --environment, not both.'
+ )
+ }
+ this.isRemote = Boolean(effectivePairingCode || effectiveEnvironment)
+ }
}
class RuntimeClientError extends Error {
@@ -32,10 +66,19 @@ vi.mock('./runtime-client', () => {
return {
RuntimeClient,
RuntimeClientError,
- RuntimeRpcFailureError
+ RuntimeRpcFailureError,
+ serveOrcaApp: serveOrcaAppMock,
+ getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
+vi.mock('./runtime/environments', () => ({
+ addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
+ listEnvironments: listEnvironmentsMock,
+ removeEnvironment: vi.fn(),
+ resolveEnvironment: vi.fn()
+}))
+
import {
buildCurrentWorktreeSelector,
COMMAND_SPECS,
@@ -58,9 +101,36 @@ describe('COMMAND_SPECS collision check', () => {
describe('orca cli worktree awareness', () => {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
+ const originalPairingCode = process.env.ORCA_PAIRING_CODE
+ const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING
+ const originalEnvironment = process.env.ORCA_ENVIRONMENT
beforeEach(() => {
callMock.mockReset()
+ serveOrcaAppMock.mockReset()
+ getDefaultUserDataPathMock.mockClear()
+ addEnvironmentFromPairingCodeMock.mockReset()
+ listEnvironmentsMock.mockReset()
+ addEnvironmentFromPairingCodeMock.mockReturnValue({
+ id: 'env-1',
+ name: 'desk',
+ createdAt: 100,
+ updatedAt: 100,
+ lastUsedAt: null,
+ runtimeId: null,
+ endpoints: [
+ {
+ id: 'ws-env-1',
+ kind: 'websocket',
+ label: 'WebSocket',
+ endpoint: 'ws://127.0.0.1:6768',
+ deviceToken: 'token',
+ publicKeyB64: 'pk'
+ }
+ ],
+ preferredEndpointId: 'ws-env-1'
+ })
+ listEnvironmentsMock.mockReturnValue([])
})
afterEach(() => {
@@ -75,6 +145,21 @@ describe('orca cli worktree awareness', () => {
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
+ if (originalPairingCode === undefined) {
+ delete process.env.ORCA_PAIRING_CODE
+ } else {
+ process.env.ORCA_PAIRING_CODE = originalPairingCode
+ }
+ if (originalRemotePairing === undefined) {
+ delete process.env.ORCA_REMOTE_PAIRING
+ } else {
+ process.env.ORCA_REMOTE_PAIRING = originalRemotePairing
+ }
+ if (originalEnvironment === undefined) {
+ delete process.env.ORCA_ENVIRONMENT
+ } else {
+ process.env.ORCA_ENVIRONMENT = originalEnvironment
+ }
})
it('builds the current worktree selector from cwd', () => {
@@ -115,6 +200,25 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
+ it('rejects remote `worktree current` without listing worktrees from client cwd', async () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+
+ await main(
+ ['worktree', 'current', '--pairing-code', 'remote-runtime', '--json'],
+ '/tmp/repo/src'
+ )
+
+ expect(callMock).not.toHaveBeenCalled()
+ expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+ 'current is a local cwd shortcut and cannot be resolved against a remote runtime.'
+ )
+ expect(process.exitCode).toBe(1)
+
+ process.exitCode = priorExitCode
+ })
+
it('uses cwd when active is passed to worktree.set', async () => {
queueFixtures(
callMock,
@@ -171,6 +275,192 @@ describe('orca cli worktree awareness', () => {
})
})
+ it('starts a foreground headless server through `serve`', async () => {
+ serveOrcaAppMock.mockResolvedValue(0)
+ process.env.ORCA_ENVIRONMENT = 'stale-env'
+
+ await main(
+ ['serve', '--json', '--port', '6768', '--pairing-address', '100.64.1.20', '--no-pairing'],
+ '/tmp/repo'
+ )
+
+ expect(serveOrcaAppMock).toHaveBeenCalledWith({
+ json: true,
+ port: '6768',
+ pairingAddress: '100.64.1.20',
+ noPairing: true,
+ mobilePairing: false
+ })
+ })
+
+ it('starts a foreground headless server with mobile pairing enabled', async () => {
+ serveOrcaAppMock.mockResolvedValue(0)
+
+ await main(
+ ['serve', '--pairing-address', '100.64.1.20', '--mobile-pairing', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(serveOrcaAppMock).toHaveBeenCalledWith({
+ json: true,
+ port: null,
+ pairingAddress: '100.64.1.20',
+ noPairing: false,
+ mobilePairing: true
+ })
+ })
+
+ it('rejects contradictory serve pairing flags', async () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+
+ await main(['serve', '--mobile-pairing', '--no-pairing', '--json'], '/tmp/repo')
+
+ expect(serveOrcaAppMock).not.toHaveBeenCalled()
+ expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+ 'Use either --mobile-pairing or --no-pairing, not both.'
+ )
+ expect(process.exitCode).toBe(1)
+
+ process.exitCode = priorExitCode
+ })
+
+ it('rejects invalid serve ports before launching the app', async () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+
+ await main(['serve', '--port', 'not-a-port', '--json'], '/tmp/repo')
+
+ expect(serveOrcaAppMock).not.toHaveBeenCalled()
+ expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+ 'Invalid --port value: not-a-port'
+ )
+ expect(process.exitCode).toBe(1)
+
+ process.exitCode = priorExitCode
+ })
+
+ it('lists saved environments even when ORCA_ENVIRONMENT is set', async () => {
+ process.env.ORCA_ENVIRONMENT = 'stale-env'
+ listEnvironmentsMock.mockReturnValue([addEnvironmentFromPairingCodeMock()])
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(['environment', 'list', '--json'], '/tmp/repo')
+
+ expect(listEnvironmentsMock).toHaveBeenCalledWith('/tmp/orca-user-data')
+ expect(callMock).not.toHaveBeenCalled()
+ expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
+ expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
+ })
+
+ it('adds saved environments even when ORCA_ENVIRONMENT is set', async () => {
+ process.env.ORCA_ENVIRONMENT = 'stale-env'
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(
+ ['environment', 'add', '--name', 'desk', '--pairing-code', 'orca://pair#abc', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(addEnvironmentFromPairingCodeMock).toHaveBeenCalledWith('/tmp/orca-user-data', {
+ name: 'desk',
+ pairingCode: 'orca://pair#abc'
+ })
+ expect(callMock).not.toHaveBeenCalled()
+ expect(logSpy.mock.calls[0]?.[0]).not.toContain('token')
+ expect(logSpy.mock.calls[0]?.[0]).not.toContain('publicKeyB64')
+ })
+
+ it('resolves repo.add paths against the invoking cli cwd', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_repo_add', {
+ repo: {
+ id: 'repo-1',
+ path: path.resolve('/tmp/repo/apps/web'),
+ displayName: 'web'
+ }
+ })
+ )
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(['repo', 'add', '--path', './apps/web', '--json'], '/tmp/repo')
+
+ expect(callMock).toHaveBeenCalledWith('repo.add', {
+ path: path.resolve('/tmp/repo/apps/web')
+ })
+ })
+
+ it('rejects remote repo.add relative paths instead of resolving against client cwd', async () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+
+ await main(
+ ['repo', 'add', '--path', './apps/web', '--pairing-code', 'remote-runtime', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(callMock).not.toHaveBeenCalled()
+ expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+ 'Remote repo add requires --path to be an absolute path on the remote server.'
+ )
+ expect(process.exitCode).toBe(1)
+
+ process.exitCode = priorExitCode
+ })
+
+ it('sends remote repo.add absolute paths unchanged', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_repo_add', {
+ repo: {
+ id: 'repo-1',
+ path: '/srv/orca/web',
+ displayName: 'web'
+ }
+ })
+ )
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(
+ ['repo', 'add', '--path', '/srv/orca/web', '--pairing-code', 'remote-runtime', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(callMock).toHaveBeenCalledWith('repo.add', {
+ path: '/srv/orca/web'
+ })
+ })
+
+ it.each(['C:\\repo', 'C:/repo', '\\\\server\\share\\repo', '//server/share/repo'])(
+ 'sends remote repo.add server absolute path %s unchanged',
+ async (serverPath) => {
+ queueFixtures(
+ callMock,
+ okFixture('req_repo_add', {
+ repo: {
+ id: 'repo-1',
+ path: serverPath,
+ displayName: 'web'
+ }
+ })
+ )
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(
+ ['repo', 'add', '--path', serverPath, '--pairing-code', 'remote-runtime', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(callMock).toHaveBeenCalledWith('repo.add', {
+ path: serverPath
+ })
+ }
+ )
+
it('opts into setup and activation when worktree.create runs hooks', async () => {
queueFixtures(
callMock,
@@ -352,4 +642,79 @@ describe('orca cli worktree awareness', () => {
limit: undefined
})
})
+
+ it('rejects implicit remote terminal create instead of resolving from client cwd', async () => {
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const priorExitCode = process.exitCode
+
+ await main(
+ ['terminal', 'create', '--pairing-code', 'remote-runtime', '--json'],
+ '/tmp/client/repo/src'
+ )
+
+ expect(callMock).not.toHaveBeenCalled()
+ expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
+ 'Remote terminal create requires --worktree'
+ )
+ expect(process.exitCode).toBe(1)
+
+ process.exitCode = priorExitCode
+ })
+
+ it('sends explicit remote terminal create worktree selectors unchanged', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_terminal_create', {
+ terminal: {
+ handle: 'term_1',
+ worktreeId: 'repo-1::/srv/orca/feature',
+ title: 'Server terminal'
+ }
+ })
+ )
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(
+ [
+ 'terminal',
+ 'create',
+ '--worktree',
+ 'id:repo-1::/srv/orca/feature',
+ '--pairing-code',
+ 'remote-runtime',
+ '--json'
+ ],
+ '/tmp/client/repo/src'
+ )
+
+ expect(callMock).toHaveBeenCalledWith('terminal.create', {
+ worktree: 'id:repo-1::/srv/orca/feature',
+ command: undefined,
+ title: undefined,
+ focus: false
+ })
+ })
+
+ it('does not resolve implicit remote browser targets from client cwd', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_tab_current', {
+ tab: {
+ browserPageId: 'page-1',
+ index: 0,
+ url: 'https://example.com',
+ title: 'Example',
+ active: true,
+ worktreeId: 'repo-1::/srv/orca/feature'
+ }
+ })
+ )
+ vi.spyOn(console, 'log').mockImplementation(() => {})
+
+ await main(['tab', 'current', '--pairing-code', 'remote-runtime', '--json'], '/tmp/client/src')
+
+ expect(callMock).toHaveBeenCalledTimes(1)
+ expect(callMock).toHaveBeenCalledWith('browser.tabCurrent', { worktree: undefined })
+ })
})
diff --git a/src/cli/index.ts b/src/cli/index.ts
index ed015d1185d..00a96e9043f 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -15,6 +15,10 @@ import { COMMAND_SPECS } from './specs'
export { COMMAND_SPECS } from './specs'
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
+function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
+ return commandPath[0] === 'environment' || commandPath[0] === 'serve'
+}
+
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise {
const parsed = parseArgs(argv)
const helpPath = resolveHelpPath(parsed)
@@ -40,7 +44,15 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()): P
// lookup so users do not get misleading "Orca is not running" failures for
// simple command typos or unsupported flags.
validateCommandAndFlags(COMMAND_SPECS, parsed)
- const client = new RuntimeClient()
+ const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
+ const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
+ const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
+ const client = new RuntimeClient(
+ undefined,
+ undefined,
+ typeof pairingCode === 'string' ? pairingCode : undefined,
+ typeof environmentSelector === 'string' ? environmentSelector : undefined
+ )
await dispatch(parsed.commandPath, {
flags: parsed.flags,
client,
diff --git a/src/cli/runtime-client.ts b/src/cli/runtime-client.ts
index 480637847eb..f88b116981b 100644
--- a/src/cli/runtime-client.ts
+++ b/src/cli/runtime-client.ts
@@ -6,6 +6,7 @@ export {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
+ serveOrcaApp,
getDefaultUserDataPath,
type RuntimeRpcFailure,
type RuntimeRpcResponse,
diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts
index ae65cd4f34c..ab082aa16ad 100644
--- a/src/cli/runtime/client.ts
+++ b/src/cli/runtime/client.ts
@@ -1,9 +1,17 @@
-import type { CliStatusResult } from '../../shared/runtime-types'
+import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
+import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
import { launchOrcaApp } from './launch'
import { getDefaultUserDataPath, readMetadata } from './metadata'
import { getCliStatus } from './status'
import { sendRequest } from './transport'
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
+import { sendWebSocketRequest } from './websocket-transport'
+import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
+import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
+import {
+ MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
+ RUNTIME_PROTOCOL_VERSION
+} from '../../shared/protocol-version'
// Why: for `orchestration.check --wait` the caller's method-level
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
@@ -16,13 +24,27 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
export class RuntimeClient {
private readonly userDataPath: string
private readonly requestTimeoutMs: number
+ private readonly remotePairing: PairingOffer | null
+ private readonly environmentSelector: string | null
+ private remoteCompatChecked = false
// Why: browser commands trigger first-time session init (agent-browser connect +
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
// being so large that genuine hangs go unnoticed.
- constructor(userDataPath = getDefaultUserDataPath(), requestTimeoutMs = 60_000) {
+ constructor(
+ userDataPath = getDefaultUserDataPath(),
+ requestTimeoutMs = 60_000,
+ remotePairingCode = process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING ?? null,
+ environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
+ ) {
this.userDataPath = userDataPath
this.requestTimeoutMs = requestTimeoutMs
+ this.environmentSelector = environmentSelector
+ this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
+ }
+
+ get isRemote(): boolean {
+ return this.remotePairing !== null
}
async call(
@@ -32,10 +54,30 @@ export class RuntimeClient {
timeoutMs?: number
}
): Promise> {
- const metadata = readMetadata(this.userDataPath)
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
+ if (this.remotePairing) {
+ if (method !== 'status.get') {
+ await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
+ }
+ const response = await sendWebSocketRequest(
+ this.remotePairing,
+ method,
+ params,
+ effectiveTimeoutMs
+ )
+ if (response.ok === false) {
+ throw new RuntimeRpcFailureError(response)
+ }
+ if (this.environmentSelector) {
+ markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ return response
+ }
+ const metadata = readMetadata(this.userDataPath)
const response = await sendRequest(metadata, method, params, effectiveTimeoutMs)
- if (!response.ok) {
+ if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
return response
@@ -58,9 +100,69 @@ export class RuntimeClient {
}
async getCliStatus(): Promise> {
+ if (this.remotePairing) {
+ const response = await this.call('status.get')
+ this.assertRemoteRuntimeStatusCompatible(response.result)
+ this.remoteCompatChecked = true
+ const graphState = response.result.graphStatus
+ return {
+ id: response.id,
+ ok: true,
+ result: {
+ app: {
+ running: true,
+ pid: null
+ },
+ runtime: {
+ state: graphState === 'ready' ? 'ready' : 'graph_not_ready',
+ reachable: true,
+ runtimeId: response.result.runtimeId
+ },
+ graph: {
+ state: graphState
+ }
+ },
+ _meta: response._meta
+ }
+ }
return getCliStatus(this.userDataPath)
}
+ private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise {
+ if (!this.remotePairing || this.remoteCompatChecked) {
+ return
+ }
+ const response = await sendWebSocketRequest(
+ this.remotePairing,
+ 'status.get',
+ undefined,
+ timeoutMs
+ )
+ if (response.ok === false) {
+ throw new RuntimeRpcFailureError(response)
+ }
+ this.assertRemoteRuntimeStatusCompatible(response.result)
+ this.remoteCompatChecked = true
+ if (this.environmentSelector) {
+ markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ }
+
+ private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
+ const verdict = evaluateRuntimeCompat({
+ clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
+ minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
+ serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
+ serverMinCompatibleClientProtocolVersion:
+ status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
+ })
+ if (verdict.kind === 'blocked') {
+ throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
+ }
+ }
+
async openOrca(timeoutMs = 15_000): Promise> {
const initial = await this.getCliStatus()
if (initial.result.runtime.reachable) {
@@ -84,6 +186,33 @@ export class RuntimeClient {
}
}
+function resolveRemotePairing(
+ userDataPath: string,
+ pairingCode: string | null,
+ environmentSelector: string | null
+): PairingOffer | null {
+ if (pairingCode && environmentSelector) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Use either --pairing-code or --environment, not both.'
+ )
+ }
+ if (environmentSelector) {
+ return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
+ }
+ if (!pairingCode) {
+ return null
+ }
+ const pairing = parsePairingCode(pairingCode)
+ if (!pairing) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ 'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
+ )
+ }
+ return pairing
+}
+
function delay(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms))
}
diff --git a/src/cli/runtime/envelope-schema.ts b/src/cli/runtime/envelope-schema.ts
index f674aab11af..3447c007769 100644
--- a/src/cli/runtime/envelope-schema.ts
+++ b/src/cli/runtime/envelope-schema.ts
@@ -1,62 +1,5 @@
-// Why: the Orca runtime is a separate process and may drift in version from
-// the CLI (older CLI talking to newer app, or vice versa during dev HMR). A
-// Zod schema at the decode boundary means a malformed frame surfaces as a
-// single legible error instead of a silent mis-typed access downstream.
-//
-// The envelope shape mirrors src/main/runtime/rpc/core.ts. `result` is left
-// unknown here — method-level types are checked by the caller via generics —
-// so only the frame is validated, not the payload.
-import { z } from 'zod'
-
-const MetaSuccess = z.object({
- runtimeId: z.string()
-})
-
-const MetaFailure = z
- .object({
- runtimeId: z.union([z.string(), z.null()])
- })
- .optional()
-
-const Success = z.object({
- id: z.string(),
- ok: z.literal(true),
- result: z.unknown(),
- _meta: MetaSuccess
-})
-
-const Failure = z.object({
- id: z.string(),
- ok: z.literal(false),
- error: z.object({
- code: z.string(),
- message: z.string(),
- data: z.unknown().optional()
- }),
- _meta: MetaFailure
-})
-
-// Why: transport-layer keepalive frame (server→client only). Not a terminal
-// frame — the client reads past it and keeps waiting for the real
-// success/failure. `id` and `_meta` are deliberately absent: keepalives carry
-// no method-level semantics and aren't tied to a particular request (one
-// connection handles one request today). See design doc §3.1.
-const Keepalive = z.object({
- _keepalive: z.literal(true)
-})
-
-// Why: switched from z.discriminatedUnion('ok', …) to z.union because
-// keepalives have no `ok` field. Client code must branch on
-// `'_keepalive' in frame` before treating the frame as Success/Failure.
-export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive])
-
-export type RuntimeRpcKeepaliveFrame = z.infer
-
-export function isKeepaliveFrame(frame: unknown): frame is RuntimeRpcKeepaliveFrame {
- return (
- typeof frame === 'object' &&
- frame !== null &&
- '_keepalive' in frame &&
- (frame as { _keepalive: unknown })._keepalive === true
- )
-}
+export {
+ isKeepaliveFrame,
+ RuntimeRpcEnvelopeSchema,
+ type RuntimeRpcKeepaliveFrame
+} from '../../shared/runtime-rpc-envelope'
diff --git a/src/cli/runtime/environments.test.ts b/src/cli/runtime/environments.test.ts
new file mode 100644
index 00000000000..8c27060cdba
--- /dev/null
+++ b/src/cli/runtime/environments.test.ts
@@ -0,0 +1,68 @@
+import { mkdtempSync, statSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { describe, expect, it } from 'vitest'
+import { encodePairingOffer } from '../../shared/pairing'
+import {
+ addEnvironmentFromPairingCode,
+ getEnvironmentStorePath,
+ listEnvironments,
+ removeEnvironment,
+ resolveEnvironmentPairingOffer
+} from './environments'
+
+function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
+ return encodePairingOffer({
+ v: 2,
+ endpoint,
+ deviceToken: 'device-token',
+ publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
+ })
+}
+
+describe('CLI runtime environments', () => {
+ it('saves, resolves, and removes a paired environment', () => {
+ const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
+ const saved = addEnvironmentFromPairingCode(userDataPath, {
+ name: 'workstation',
+ pairingCode: pairingCode(),
+ now: 100
+ })
+
+ expect(listEnvironments(userDataPath)).toHaveLength(1)
+ expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation')).toMatchObject({
+ endpoint: 'ws://127.0.0.1:6768',
+ deviceToken: 'device-token'
+ })
+ expect(resolveEnvironmentPairingOffer(userDataPath, saved.id)).toMatchObject({
+ endpoint: 'ws://127.0.0.1:6768'
+ })
+ expect((statSync(getEnvironmentStorePath(userDataPath)).mode & 0o777).toString(8)).toBe('600')
+
+ const removed = removeEnvironment(userDataPath, 'workstation')
+ expect(removed.id).toBe(saved.id)
+ expect(listEnvironments(userDataPath)).toEqual([])
+ })
+
+ it('rejects an environment with the same name', () => {
+ const userDataPath = mkdtempSync(join(tmpdir(), 'orca-env-store-'))
+ const first = addEnvironmentFromPairingCode(userDataPath, {
+ name: 'workstation',
+ pairingCode: pairingCode('ws://127.0.0.1:1111'),
+ now: 100
+ })
+
+ expect(() =>
+ addEnvironmentFromPairingCode(userDataPath, {
+ name: 'workstation',
+ pairingCode: pairingCode('ws://127.0.0.1:2222'),
+ now: 200
+ })
+ ).toThrow('A server named "workstation" already exists.')
+ expect(listEnvironments(userDataPath)).toHaveLength(1)
+ expect(resolveEnvironmentPairingOffer(userDataPath, 'workstation').endpoint).toBe(
+ 'ws://127.0.0.1:1111'
+ )
+ expect(listEnvironments(userDataPath)[0]?.id).toBe(first.id)
+ })
+})
diff --git a/src/cli/runtime/environments.ts b/src/cli/runtime/environments.ts
new file mode 100644
index 00000000000..3009b11c9cb
--- /dev/null
+++ b/src/cli/runtime/environments.ts
@@ -0,0 +1,81 @@
+import {
+ addEnvironmentFromPairingCode as addEnvironmentFromPairingCodeInStore,
+ getEnvironmentStorePath,
+ listEnvironments,
+ markEnvironmentUsed as markEnvironmentUsedInStore,
+ removeEnvironment as removeEnvironmentFromStore,
+ resolveEnvironment as resolveEnvironmentFromStore,
+ resolveEnvironmentPairingOffer as resolveEnvironmentPairingOfferFromStore,
+ RuntimeEnvironmentStoreError,
+ type RuntimeEnvironmentStoreErrorCode
+} from '../../shared/runtime-environment-store'
+import type {
+ KnownRuntimeEnvironment,
+ PublicKnownRuntimeEnvironment
+} from '../../shared/runtime-environments'
+import type { PairingOffer } from '../../shared/pairing'
+import { RuntimeClientError } from './types'
+
+export type EnvironmentAddResult = {
+ environment: PublicKnownRuntimeEnvironment
+}
+
+export type EnvironmentListResult = {
+ environments: PublicKnownRuntimeEnvironment[]
+}
+
+export type EnvironmentRemoveResult = {
+ removed: PublicKnownRuntimeEnvironment
+}
+
+export { getEnvironmentStorePath, listEnvironments }
+
+export function addEnvironmentFromPairingCode(
+ userDataPath: string,
+ args: { name: string; pairingCode: string; now?: number }
+): KnownRuntimeEnvironment {
+ return translateStoreError(() => addEnvironmentFromPairingCodeInStore(userDataPath, args))
+}
+
+export function removeEnvironment(userDataPath: string, selector: string): KnownRuntimeEnvironment {
+ return translateStoreError(() => removeEnvironmentFromStore(userDataPath, selector))
+}
+
+export function resolveEnvironment(
+ userDataPath: string,
+ selector: string
+): KnownRuntimeEnvironment {
+ return translateStoreError(() => resolveEnvironmentFromStore(userDataPath, selector))
+}
+
+export function resolveEnvironmentPairingOffer(
+ userDataPath: string,
+ selector: string
+): PairingOffer {
+ return translateStoreError(() => resolveEnvironmentPairingOfferFromStore(userDataPath, selector))
+}
+
+export function markEnvironmentUsed(
+ userDataPath: string,
+ selector: string,
+ args: { runtimeId?: string | null; now?: number } = {}
+): void {
+ translateStoreError(() => markEnvironmentUsedInStore(userDataPath, selector, args))
+}
+
+function translateStoreError(fn: () => TResult): TResult {
+ try {
+ return fn()
+ } catch (error) {
+ if (error instanceof RuntimeEnvironmentStoreError) {
+ throw new RuntimeClientError(toRuntimeClientErrorCode(error.code), error.message)
+ }
+ throw error
+ }
+}
+
+function toRuntimeClientErrorCode(
+ code: RuntimeEnvironmentStoreErrorCode
+): 'invalid_argument' | 'runtime_error' {
+ return code
+}
diff --git a/src/cli/runtime/index.ts b/src/cli/runtime/index.ts
index b061c042b21..81d9fdf16c6 100644
--- a/src/cli/runtime/index.ts
+++ b/src/cli/runtime/index.ts
@@ -1,4 +1,5 @@
export { RuntimeClient } from './client'
+export { serveOrcaApp } from './launch'
export { getDefaultUserDataPath } from './metadata'
export {
RuntimeClientError,
diff --git a/src/cli/runtime/launch.test.ts b/src/cli/runtime/launch.test.ts
new file mode 100644
index 00000000000..807df9dc721
--- /dev/null
+++ b/src/cli/runtime/launch.test.ts
@@ -0,0 +1,88 @@
+import { resolve } from 'path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { spawnMock } = vi.hoisted(() => ({
+ spawnMock: vi.fn()
+}))
+
+vi.mock('child_process', () => ({
+ spawn: spawnMock
+}))
+
+import { serveOrcaApp } from './launch'
+
+describe('serveOrcaApp', () => {
+ beforeEach(() => {
+ spawnMock.mockReset()
+ process.env.ORCA_APP_EXECUTABLE = '/Applications/Orca.app/Contents/MacOS/Orca'
+ })
+
+ afterEach(() => {
+ delete process.env.ORCA_APP_EXECUTABLE
+ })
+
+ it('pins the Electron child cwd to the app root instead of the caller cwd', async () => {
+ const child = {
+ kill: vi.fn(),
+ once: vi.fn(
+ (event: string, handler: (code: number | null, signal: string | null) => void) => {
+ if (event === 'exit') {
+ queueMicrotask(() => handler(0, null))
+ }
+ return child
+ }
+ )
+ }
+ spawnMock.mockReturnValue(child)
+
+ await expect(serveOrcaApp({ json: true })).resolves.toBe(0)
+
+ expect(spawnMock).toHaveBeenCalledWith(
+ '/Applications/Orca.app/Contents/MacOS/Orca',
+ ['--serve', '--serve-json'],
+ expect.objectContaining({
+ cwd: resolve(__dirname, '../../..')
+ })
+ )
+ })
+
+ it('passes mobile pairing through to the foreground server child', async () => {
+ const child = {
+ kill: vi.fn(),
+ once: vi.fn(
+ (event: string, handler: (code: number | null, signal: string | null) => void) => {
+ if (event === 'exit') {
+ queueMicrotask(() => handler(0, null))
+ }
+ return child
+ }
+ )
+ }
+ spawnMock.mockReturnValue(child)
+
+ await expect(
+ serveOrcaApp({
+ json: true,
+ port: '6768',
+ pairingAddress: '100.64.1.20',
+ mobilePairing: true
+ })
+ ).resolves.toBe(0)
+
+ expect(spawnMock).toHaveBeenCalledWith(
+ '/Applications/Orca.app/Contents/MacOS/Orca',
+ [
+ '--serve',
+ '--serve-json',
+ '--serve-port',
+ '6768',
+ '--serve-pairing-address',
+ '100.64.1.20',
+ '--serve-mobile-pairing'
+ ],
+ expect.objectContaining({
+ cwd: resolve(__dirname, '../../..')
+ })
+ )
+ })
+})
diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts
index b86cfb9b4d1..71de311177b 100644
--- a/src/cli/runtime/launch.ts
+++ b/src/cli/runtime/launch.ts
@@ -1,5 +1,5 @@
import { spawn as spawnProcess } from 'child_process'
-import { dirname } from 'path'
+import { dirname, resolve } from 'path'
import { RuntimeClientError } from './types'
export function launchOrcaApp(): void {
@@ -53,6 +53,93 @@ export function launchOrcaApp(): void {
)
}
+export function serveOrcaApp(
+ args: {
+ json?: boolean
+ port?: string | null
+ pairingAddress?: string | null
+ noPairing?: boolean
+ mobilePairing?: boolean
+ } = {}
+): Promise {
+ const executable = resolveForegroundOrcaExecutable()
+ const childArgs = ['--serve']
+ if (args.json) {
+ childArgs.push('--serve-json')
+ }
+ if (args.port) {
+ childArgs.push('--serve-port', args.port)
+ }
+ if (args.pairingAddress) {
+ childArgs.push('--serve-pairing-address', args.pairingAddress)
+ }
+ if (args.noPairing) {
+ childArgs.push('--serve-no-pairing')
+ }
+ if (args.mobilePairing) {
+ childArgs.push('--serve-mobile-pairing')
+ }
+
+ const child = spawnProcess(executable, childArgs, {
+ cwd: resolveAppRoot(),
+ stdio: 'inherit',
+ env: stripElectronRunAsNode(process.env)
+ })
+
+ return new Promise((resolve, reject) => {
+ let forceKillTimer: ReturnType | null = null
+ const forwardSignal = (signal: NodeJS.Signals): void => {
+ child.kill(signal)
+ forceKillTimer ??= setTimeout(() => {
+ child.kill('SIGKILL')
+ }, 5000)
+ }
+ const cleanup = (): void => {
+ process.off('SIGINT', forwardSignal)
+ process.off('SIGTERM', forwardSignal)
+ if (forceKillTimer) {
+ clearTimeout(forceKillTimer)
+ forceKillTimer = null
+ }
+ }
+ process.on('SIGINT', forwardSignal)
+ process.on('SIGTERM', forwardSignal)
+ child.once('error', (error) => {
+ cleanup()
+ reject(error)
+ })
+ child.once('exit', (code, signal) => {
+ cleanup()
+ if (typeof code === 'number') {
+ resolve(code)
+ return
+ }
+ reject(new RuntimeClientError('runtime_serve_failed', `Orca serve exited via ${signal}`))
+ })
+ })
+}
+
+function resolveAppRoot(): string {
+ // Why: dev-mode resource resolution in the Electron child may consult
+ // process.cwd(). Pin it to the app root so `orca serve` behaves the same
+ // regardless of the shell directory it was launched from.
+ return resolve(__dirname, '../../..')
+}
+
+function resolveForegroundOrcaExecutable(): string {
+ const overrideExecutable = process.env.ORCA_APP_EXECUTABLE
+ if (typeof overrideExecutable === 'string' && overrideExecutable.trim().length > 0) {
+ return overrideExecutable
+ }
+ if (process.env.ELECTRON_RUN_AS_NODE === '1') {
+ return process.execPath
+ }
+ throw new RuntimeClientError(
+ 'runtime_serve_failed',
+ 'Could not determine how to start Orca server. Set ORCA_APP_EXECUTABLE to the Orca executable.'
+ )
+}
+
function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts
index 3a0f445a40f..bbf5e7e272f 100644
--- a/src/cli/runtime/status.ts
+++ b/src/cli/runtime/status.ts
@@ -29,7 +29,7 @@ export async function getCliStatus(
try {
const response = await sendRequest(metadata, 'status.get', undefined, 1000)
- if (!response.ok) {
+ if (response.ok === false) {
throw new RuntimeRpcFailureError(response)
}
const graphState = response.result.graphStatus
diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts
index ca8ca746274..ee80e484a6d 100644
--- a/src/cli/runtime/transport.ts
+++ b/src/cli/runtime/transport.ts
@@ -49,10 +49,10 @@ export async function sendRequest(
settled = true
clearTimeout(timeout)
socket.end()
- if (result.ok) {
- resolve(result.response)
- } else {
+ if (result.ok === false) {
reject(result.error)
+ } else {
+ resolve(result.response)
}
}
diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts
index e427c87b5de..220d3e3aba5 100644
--- a/src/cli/runtime/types.ts
+++ b/src/cli/runtime/types.ts
@@ -1,31 +1,10 @@
-// Why: the RPC envelope shape is the contract the CLI shares with the main
-// runtime. Keeping the types and error classes in one leaf module lets every
-// other runtime module depend on them without pulling in transport or launch
-// code.
+import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
-export type RuntimeRpcSuccess = {
- id: string
- ok: true
- result: TResult
- _meta: {
- runtimeId: string
- }
-}
-
-export type RuntimeRpcFailure = {
- id: string
- ok: false
- error: {
- code: string
- message: string
- data?: unknown
- }
- _meta?: {
- runtimeId: string | null
- }
-}
-
-export type RuntimeRpcResponse = RuntimeRpcSuccess | RuntimeRpcFailure
+export type {
+ RuntimeRpcFailure,
+ RuntimeRpcResponse,
+ RuntimeRpcSuccess
+} from '../../shared/runtime-rpc-envelope'
export class RuntimeClientError extends Error {
readonly code: string
diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts
new file mode 100644
index 00000000000..38ccf06f956
--- /dev/null
+++ b/src/cli/runtime/websocket-transport.test.ts
@@ -0,0 +1,237 @@
+import { createServer, type Server } from 'http'
+import { mkdtempSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { WebSocketServer } from 'ws'
+import { encodePairingOffer, type PairingOffer } from '../../shared/pairing'
+import {
+ decrypt,
+ deriveSharedKey,
+ encrypt,
+ generateKeyPair,
+ publicKeyToBase64
+} from '../../shared/e2ee-crypto'
+import { RuntimeClient } from './client'
+import { addEnvironmentFromPairingCode } from './environments'
+import { RuntimeClientError } from './types'
+import {
+ MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
+ RUNTIME_PROTOCOL_VERSION
+} from '../../shared/protocol-version'
+
+type TestRuntime = {
+ endpoint: string
+ publicKeyB64: string
+ deviceToken: string
+ close: () => Promise
+}
+
+describe('CLI remote WebSocket transport', () => {
+ const servers: TestRuntime[] = []
+
+ afterEach(async () => {
+ await Promise.all(servers.splice(0).map((server) => server.close()))
+ })
+
+ it('calls a remote runtime through a mobile pairing offer', async () => {
+ const runtime = await startTestRuntime('runtime-ws-1')
+ servers.push(runtime)
+
+ const pairingUrl = encodePairingOffer({
+ v: 2,
+ endpoint: runtime.endpoint,
+ deviceToken: runtime.deviceToken,
+ publicKeyB64: runtime.publicKeyB64
+ })
+ const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl)
+ const response = await client.call<{ runtimeId: string }>('status.get')
+
+ expect(response.ok).toBe(true)
+ expect(response.result.runtimeId).toBe('runtime-ws-1')
+ })
+
+ it('rejects malformed remote pairing codes before local runtime lookup', () => {
+ expect(() => new RuntimeClient('/tmp/unused', 5_000, 'not-a-pairing-code')).toThrow(
+ RuntimeClientError
+ )
+ })
+
+ it('accepts a bare pairing payload as well as the orca URL wrapper', async () => {
+ const runtime = await startTestRuntime('runtime-ws-2')
+ servers.push(runtime)
+ const offer: PairingOffer = {
+ v: 2,
+ endpoint: runtime.endpoint,
+ deviceToken: runtime.deviceToken,
+ publicKeyB64: runtime.publicKeyB64
+ }
+ const barePayload = encodePairingOffer(offer).split('#')[1]!
+
+ const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
+ const status = await client.getCliStatus()
+
+ expect(status.result.runtime.reachable).toBe(true)
+ expect(status.result.runtime.runtimeId).toBe('runtime-ws-2')
+ })
+
+ it('connects through a saved environment selector', async () => {
+ const runtime = await startTestRuntime('runtime-env-1')
+ servers.push(runtime)
+ const userDataPath = mkdtempSync(join(tmpdir(), 'orca-cli-env-'))
+ addEnvironmentFromPairingCode(userDataPath, {
+ name: 'remote-dev',
+ pairingCode: encodePairingOffer({
+ v: 2,
+ endpoint: runtime.endpoint,
+ deviceToken: runtime.deviceToken,
+ publicKeyB64: runtime.publicKeyB64
+ })
+ })
+
+ const client = new RuntimeClient(userDataPath, 5_000, null, 'remote-dev')
+ const status = await client.getCliStatus()
+
+ expect(status.result.runtime.reachable).toBe(true)
+ expect(status.result.runtime.runtimeId).toBe('runtime-env-1')
+ })
+
+ it('blocks remote RPCs when the server protocol is too old', async () => {
+ const runtime = await startTestRuntime('runtime-old', { runtimeProtocolVersion: 1 })
+ servers.push(runtime)
+
+ const client = new RuntimeClient(
+ '/tmp/unused',
+ 5_000,
+ encodePairingOffer({
+ v: 2,
+ endpoint: runtime.endpoint,
+ deviceToken: runtime.deviceToken,
+ publicKeyB64: runtime.publicKeyB64
+ })
+ )
+
+ await expect(client.call('repo.list')).rejects.toMatchObject({
+ code: 'incompatible_runtime',
+ message: expect.stringContaining('server is too old')
+ })
+ })
+})
+
+async function startTestRuntime(
+ runtimeId: string,
+ statusOverrides: {
+ runtimeProtocolVersion?: number
+ minCompatibleRuntimeClientVersion?: number
+ } = {}
+): Promise {
+ const serverKeyPair = generateKeyPair()
+ const deviceToken = `token-${runtimeId}`
+ const httpServer = createServer()
+ const wss = new WebSocketServer({ server: httpServer })
+
+ wss.on('connection', (ws) => {
+ let sharedKey: Uint8Array | null = null
+ let authenticated = false
+
+ ws.on('message', (data) => {
+ const frame = data.toString()
+ if (!sharedKey) {
+ const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string }
+ const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64')
+ sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey)
+ ws.send(JSON.stringify({ type: 'e2ee_ready' }))
+ return
+ }
+
+ const plaintext = decrypt(frame, sharedKey)
+ if (!plaintext) {
+ ws.close(4003, 'decrypt failed')
+ return
+ }
+ if (!authenticated) {
+ const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string }
+ if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) {
+ ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey))
+ ws.close(4001, 'auth failed')
+ return
+ }
+ authenticated = true
+ ws.send(encrypt(JSON.stringify({ type: 'e2ee_authenticated' }), sharedKey))
+ return
+ }
+
+ const request = JSON.parse(plaintext) as { id: string; method: string }
+ const response =
+ request.method === 'status.get'
+ ? {
+ id: request.id,
+ ok: true,
+ result: {
+ runtimeId,
+ rendererGraphEpoch: 1,
+ graphStatus: 'ready',
+ authoritativeWindowId: null,
+ liveTabCount: 0,
+ liveLeafCount: 0,
+ runtimeProtocolVersion:
+ statusOverrides.runtimeProtocolVersion ?? RUNTIME_PROTOCOL_VERSION,
+ minCompatibleRuntimeClientVersion:
+ statusOverrides.minCompatibleRuntimeClientVersion ??
+ MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
+ },
+ _meta: { runtimeId }
+ }
+ : {
+ id: request.id,
+ ok: false,
+ error: { code: 'method_not_found', message: 'Unknown method' },
+ _meta: { runtimeId }
+ }
+ ws.send(encrypt(JSON.stringify(response), sharedKey))
+ })
+ })
+
+ await listen(httpServer)
+ const address = httpServer.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('Expected TCP test server')
+ }
+
+ return {
+ endpoint: `ws://127.0.0.1:${address.port}`,
+ publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
+ deviceToken,
+ close: async () => {
+ await new Promise((resolve) => {
+ wss.close(() => resolve())
+ for (const client of wss.clients) {
+ client.close()
+ }
+ })
+ await closeHttpServer(httpServer)
+ }
+ }
+}
+
+async function listen(server: Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ server.off('error', reject)
+ resolve()
+ })
+ })
+}
+
+async function closeHttpServer(server: Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) {
+ reject(error)
+ return
+ }
+ resolve()
+ })
+ })
+}
diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts
new file mode 100644
index 00000000000..ac5fc677919
--- /dev/null
+++ b/src/cli/runtime/websocket-transport.ts
@@ -0,0 +1,22 @@
+import type { PairingOffer } from '../../shared/pairing'
+import {
+ RemoteRuntimeClientError,
+ sendRemoteRuntimeRequest
+} from '../../shared/remote-runtime-client'
+import { RuntimeClientError, type RuntimeRpcResponse } from './types'
+
+export async function sendWebSocketRequest(
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number
+): Promise> {
+ try {
+ return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs)
+ } catch (error) {
+ if (error instanceof RemoteRuntimeClientError) {
+ throw new RuntimeClientError(error.code, error.message)
+ }
+ throw error
+ }
+}
diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts
index 14420cebb69..c56d4f152e0 100644
--- a/src/cli/selectors.ts
+++ b/src/cli/selectors.ts
@@ -1,5 +1,6 @@
import { isAbsolute, relative, resolve as resolvePath } from 'path'
import type { ComputerAppQuery, RuntimeWorktreeListResult } from '../shared/runtime-types'
+import { isPathInsideOrEqual } from '../shared/cross-platform-path'
import type { RuntimeClient } from './runtime-client'
import { RuntimeClientError } from './runtime-client'
import { getOptionalStringFlag, getRequiredStringFlag } from './flags'
@@ -26,7 +27,22 @@ export function normalizeWorktreeSelector(selector: string, cwd: string): string
return selector
}
+function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient): void {
+ if (!client.isRemote) {
+ return
+ }
+ // Why: a paired CLI's cwd belongs to the client machine, not the runtime
+ // server, so cwd-derived worktree selectors are only valid locally.
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `${selector} is a local cwd shortcut and cannot be resolved against a remote runtime. Pass an explicit server-side worktree selector such as id:, branch:, issue:, or path:.`
+ )
+}
+
function isWithinPath(parentPath: string, childPath: string): boolean {
+ if (isPathInsideOrEqual(parentPath, childPath)) {
+ return true
+ }
const relativePath = relative(parentPath, childPath)
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath))
}
@@ -35,6 +51,8 @@ export async function resolveCurrentWorktreeSelector(
cwd: string,
client: RuntimeClient
): Promise {
+ assertLocalCwdWorktreeSelector('current', client)
+
const currentPath = resolvePath(cwd)
const worktrees = await client.call('worktree.list', {
limit: 10_000
@@ -68,6 +86,7 @@ export async function getOptionalWorktreeSelector(
return undefined
}
if (value === 'active' || value === 'current') {
+ assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
@@ -81,13 +100,14 @@ export async function getRequiredWorktreeSelector(
): Promise {
const value = getRequiredStringFlag(flags, name)
if (value === 'active' || value === 'current') {
+ assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
-// Why: browser commands default to the current worktree (auto-resolve from cwd).
-// --worktree all bypasses filtering. Omitting --worktree auto-resolves.
+// Why: local browser commands default to the current worktree by auto-resolving
+// from cwd. Remote commands omit worktree so the runtime uses server-side focus.
export async function getBrowserWorktreeSelector(
flags: Map,
cwd: string,
@@ -99,10 +119,14 @@ export async function getBrowserWorktreeSelector(
}
if (value) {
if (value === 'active' || value === 'current') {
+ assertLocalCwdWorktreeSelector(value, client)
return await resolveCurrentWorktreeSelector(cwd, client)
}
return normalizeWorktreeSelector(value, cwd)
}
+ if (client.isRemote) {
+ return undefined
+ }
// Default: auto-resolve from cwd
try {
return await resolveCurrentWorktreeSelector(cwd, client)
@@ -146,6 +170,7 @@ export async function getBrowserCommandTarget(
return { page }
}
if (explicitWorktree === 'active' || explicitWorktree === 'current') {
+ assertLocalCwdWorktreeSelector(explicitWorktree, client)
return {
page,
worktree: await resolveCurrentWorktreeSelector(cwd, client)
diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts
index b85424e8ac0..f8f6d8ffb00 100644
--- a/src/cli/specs/core.ts
+++ b/src/cli/specs/core.ts
@@ -9,6 +9,24 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
examples: ['orca open', 'orca open --json']
},
+ {
+ path: ['serve'],
+ summary: 'Start an Orca runtime server without opening a desktop window',
+ usage:
+ 'orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'port', 'pairing-address', 'mobile-pairing', 'no-pairing'],
+ notes: [
+ 'Runs in the foreground and prints the runtime endpoint. Stop it with Ctrl+C.',
+ 'Use --pairing-address when clients should connect through a LAN, Tailscale, SSH-forward, or public tunnel address.',
+ 'Use --mobile-pairing to print a mobile-scoped pairing QR/link instead of the default runtime-environment pairing link.'
+ ],
+ examples: [
+ 'orca serve',
+ 'orca serve --json',
+ 'orca serve --port 6768 --pairing-address 100.64.1.20',
+ 'orca serve --pairing-address 100.64.1.20 --mobile-pairing'
+ ]
+ },
{
path: ['status'],
summary: 'Show app/runtime/graph readiness',
diff --git a/src/cli/specs/environment.ts b/src/cli/specs/environment.ts
new file mode 100644
index 00000000000..6bfe1940b65
--- /dev/null
+++ b/src/cli/specs/environment.ts
@@ -0,0 +1,30 @@
+import type { CommandSpec } from '../args'
+import { GLOBAL_FLAGS } from '../args'
+
+export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
+ {
+ path: ['environment', 'add'],
+ summary: 'Save a remote Orca runtime environment from a pairing code',
+ usage: 'orca environment add --name --pairing-code [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'name'],
+ examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
+ },
+ {
+ path: ['environment', 'list'],
+ summary: 'List saved Orca runtime environments',
+ usage: 'orca environment list [--json]',
+ allowedFlags: [...GLOBAL_FLAGS]
+ },
+ {
+ path: ['environment', 'show'],
+ summary: 'Show one saved Orca runtime environment',
+ usage: 'orca environment show --environment [--json]',
+ allowedFlags: [...GLOBAL_FLAGS]
+ },
+ {
+ path: ['environment', 'rm'],
+ summary: 'Remove one saved Orca runtime environment',
+ usage: 'orca environment rm --environment [--json]',
+ allowedFlags: [...GLOBAL_FLAGS]
+ }
+]
diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts
index 0828ef9b557..ebcbb022fcd 100644
--- a/src/cli/specs/index.ts
+++ b/src/cli/specs/index.ts
@@ -4,6 +4,7 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
import { COMPUTER_COMMAND_SPECS } from './computer'
+import { ENVIRONMENT_COMMAND_SPECS } from './environment'
import { NOTE_COMMAND_SPECS } from './note'
export const COMMAND_SPECS: CommandSpec[] = [
@@ -12,5 +13,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS,
...COMPUTER_COMMAND_SPECS,
+ ...ENVIRONMENT_COMMAND_SPECS,
...NOTE_COMMAND_SPECS
]
diff --git a/src/main/cli/packaged-cli-assets.test.ts b/src/main/cli/packaged-cli-assets.test.ts
new file mode 100644
index 00000000000..615f382cf18
--- /dev/null
+++ b/src/main/cli/packaged-cli-assets.test.ts
@@ -0,0 +1,19 @@
+import { createRequire } from 'node:module'
+import { describe, expect, it } from 'vitest'
+
+const require = createRequire(import.meta.url)
+const builderConfig = require('../../../config/electron-builder.config.cjs') as {
+ asarUnpack?: string[]
+}
+
+describe('packaged CLI assets', () => {
+ it('unpacks runtime dependencies used before Electron asar integration is available', () => {
+ expect(builderConfig.asarUnpack).toEqual(
+ expect.arrayContaining([
+ 'node_modules/ws/**',
+ 'node_modules/tweetnacl/**',
+ 'node_modules/zod/**'
+ ])
+ )
+ })
+})
diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts
index c9fc0ecc357..3a815558c18 100644
--- a/src/main/git/status.test.ts
+++ b/src/main/git/status.test.ts
@@ -220,10 +220,14 @@ describe('getDiff', () => {
const result = await getDiff('/repo', 'src/file.ts', false)
- expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(2, ['show', 'HEAD:src/file.ts'], {
- cwd: '/repo',
- maxBuffer: 10 * 1024 * 1024
- })
+ expect(gitExecFileAsyncBufferMock).toHaveBeenNthCalledWith(
+ 2,
+ ['show', '--end-of-options', 'HEAD:src/file.ts'],
+ {
+ cwd: '/repo',
+ maxBuffer: 10 * 1024 * 1024
+ }
+ )
expect(result.originalContent).toBe('head-content\n')
expect(result.modifiedContent).toBe('working-tree-content')
})
diff --git a/src/main/git/status.ts b/src/main/git/status.ts
index 0934e6d1013..b24535d4454 100644
--- a/src/main/git/status.ts
+++ b/src/main/git/status.ts
@@ -551,7 +551,7 @@ async function resolveCompareRef(worktreePath: string): Promise {
}
async function resolveRefOid(worktreePath: string, ref: string): Promise {
- const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', ref], {
+ const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', '--end-of-options', ref], {
cwd: worktreePath
})
return stdout.trim()
@@ -613,10 +613,13 @@ async function readGitBlobAtOidPath(
filePath: string
): Promise {
try {
- const { stdout } = await gitExecFileAsyncBuffer(['show', `${oid}:${filePath}`], {
- cwd: worktreePath,
- maxBuffer: MAX_GIT_SHOW_BYTES
- })
+ const { stdout } = await gitExecFileAsyncBuffer(
+ ['show', '--end-of-options', `${oid}:${filePath}`],
+ {
+ cwd: worktreePath,
+ maxBuffer: MAX_GIT_SHOW_BYTES
+ }
+ )
return { ...bufferToBlob(stdout, filePath), exists: true }
} catch {
diff --git a/src/main/index.ts b/src/main/index.ts
index 47de211cc30..19c20b07e10 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -5,6 +5,7 @@
import { grantDirAcl } from './win32-utils'
import { app, BrowserWindow, nativeImage, nativeTheme } from 'electron'
import { electronApp, is } from '@electron-toolkit/utils'
+import * as QRCode from 'qrcode'
import devIcon from '../../resources/icon-dev.png?asset'
import { Store, initDataPath } from './persistence'
import { StatsCollector, initStatsPath } from './stats/collector'
@@ -51,7 +52,12 @@ import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
import { cursorHookService } from './cursor/hook-service'
import { droidHookService } from './droid/hook-service'
-import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty'
+import {
+ getPtyIdForPaneKey,
+ registerPaneKeyTeardownListener,
+ getLocalPtyProvider,
+ registerHeadlessPtyRuntime
+} from './ipc/pty'
import { AgentBrowserBridge } from './browser/agent-browser-bridge'
import { browserManager } from './browser/browser-manager'
import { setUnreadDockBadgeCount } from './dock/unread-badge'
@@ -79,6 +85,7 @@ let disposeFeatureWallFirstAgentTour: (() => void) | null = null
let watcherShutdownPromise: Promise | null = null
let watcherShutdownDone = false
let automations: AutomationService | null = null
+const isServeMode = process.argv.includes('--serve')
installUncaughtPipeErrorGuard()
// Why: propagate the Orca app version into `process.env` so PTY-env
@@ -141,7 +148,8 @@ function focusExistingWindow(): void {
// agent work, so that routing ambiguity is acceptable. Packaged Orca keeps
// the lock to protect against the corruption documented in PR #1326 /
// issue #1312.
-const hasSingleInstanceLock = is.dev ? true : acquireSingleInstanceLock(app, focusExistingWindow)
+const hasSingleInstanceLock =
+ is.dev && !isServeMode ? true : acquireSingleInstanceLock(app, focusExistingWindow)
if (!hasSingleInstanceLock) {
if (is.dev) {
// Why: packaged runs have no attached console, but dev runs do. Emit a
@@ -160,8 +168,12 @@ if (!hasSingleInstanceLock) {
// below happen — those handlers only fire after whenReady, which app.quit()
// prevents from ever dispatching.
if (hasSingleInstanceLock) {
- installDevParentDisconnectQuit(is.dev)
- installDevParentWatchdog(is.dev)
+ // Why: dev parent shutdown coupling is only for electron-vite desktop runs.
+ // `orca serve` may be launched through a CLI shim or background shell whose
+ // parent lifetime is not the intended server lifetime.
+ const shouldCoupleToDevParent = is.dev && !isServeMode
+ installDevParentDisconnectQuit(shouldCoupleToDevParent)
+ installDevParentWatchdog(shouldCoupleToDevParent)
// Why: must run after configureDevUserDataPath (which redirects userData to
// orca-dev in dev mode) but before app.setName('Orca') inside whenReady
// (which would change the resolved path on case-sensitive filesystems).
@@ -392,6 +404,108 @@ const syntheticTitleSpinnerByPaneKey = new Map<
{ timer: ReturnType; frame: number; profile: SyntheticTitleProfile }
>()
+type ServeOptions = {
+ json: boolean
+ wsPort?: number
+ pairingAddress: string | null
+ noPairing: boolean
+ mobilePairing: boolean
+}
+
+function getServeOptions(argv = process.argv): ServeOptions {
+ const valueAfter = (flag: string): string | null => {
+ const index = argv.indexOf(flag)
+ if (index === -1) {
+ return null
+ }
+ const value = argv[index + 1]
+ return value && !value.startsWith('--') ? value : null
+ }
+ const rawPort = valueAfter('--serve-port')
+ let wsPort: number | undefined
+ if (rawPort) {
+ const parsedPort = Number(rawPort)
+ if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) {
+ throw new Error(`Invalid --serve-port value: ${rawPort}`)
+ }
+ wsPort = parsedPort
+ }
+ return {
+ json: argv.includes('--serve-json'),
+ ...(wsPort !== undefined ? { wsPort } : {}),
+ pairingAddress: valueAfter('--serve-pairing-address'),
+ noPairing: argv.includes('--serve-no-pairing'),
+ mobilePairing: argv.includes('--serve-mobile-pairing')
+ }
+}
+
+async function renderTerminalPairingQr(pairingUrl: string): Promise {
+ try {
+ return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
+ } catch {
+ try {
+ return await QRCode.toString(pairingUrl, { type: 'utf8' })
+ } catch {
+ return null
+ }
+ }
+}
+
+async function printServeReady(options: ServeOptions): Promise {
+ if (!runtime || !runtimeRpc) {
+ throw new Error('Runtime server must be initialized before printing serve readiness')
+ }
+ const endpoint = runtimeRpc.getWebSocketEndpoint()
+ const pairing = options.noPairing
+ ? ({ available: false } as const)
+ : runtimeRpc.createPairingOffer({
+ address: options.pairingAddress,
+ name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
+ scope: options.mobilePairing ? 'mobile' : 'runtime'
+ })
+ const pairingQr =
+ pairing.available && options.mobilePairing
+ ? await renderTerminalPairingQr(pairing.pairingUrl)
+ : null
+ if (options.json) {
+ console.log(
+ JSON.stringify({
+ type: 'orca_server_ready',
+ runtimeId: runtime.getRuntimeId(),
+ endpoint,
+ pairing: pairing.available
+ ? {
+ url: pairing.pairingUrl,
+ endpoint: pairing.endpoint,
+ deviceId: pairing.deviceId,
+ scope: options.mobilePairing ? 'mobile' : 'runtime',
+ qr: pairingQr
+ }
+ : null
+ })
+ )
+ return
+ }
+ console.log(`Orca server ready: ${endpoint ?? 'websocket unavailable'}`)
+ if (pairing.available) {
+ if (options.mobilePairing && pairingQr) {
+ console.log(`Mobile pairing QR:\n${pairingQr}`)
+ }
+ console.log(`Pairing URL: ${pairing.pairingUrl}`)
+ }
+}
+
+function installServeSignalHandlers(): void {
+ const quit = (): void => {
+ // Why: foreground `orca serve` is controlled by the parent CLI/terminal,
+ // so POSIX termination signals should follow Electron's normal quit path
+ // and flush runtime metadata, daemon checkpoints, and telemetry.
+ app.quit()
+ }
+ process.once('SIGINT', quit)
+ process.once('SIGTERM', quit)
+}
+
// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner
// interval would keep firing but sendSyntheticTitle would no-op forever.
// Stop the interval explicitly so the process doesn't carry a timer per dead
@@ -622,38 +736,70 @@ app.whenReady().then(async () => {
// ws://127.0.0.1:6769 is stable; a second dev instance still falls back via
// ws-transport's EADDRINUSE handler.
const devWsPort = is.dev && !isE2E ? 6769 : undefined
+ let serveOptions: ServeOptions | null = null
+ try {
+ serveOptions = isServeMode ? getServeOptions() : null
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error))
+ app.exit(1)
+ return
+ }
runtimeRpc = new OrcaRuntimeRpcServer({
runtime,
userDataPath: app.getPath('userData'),
enableWebSocket: true,
...(isE2E ? { wsPort: 0 } : {}),
- ...(devWsPort !== undefined ? { wsPort: devWsPort } : {})
+ ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
+ ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {})
})
registerMobileHandlers(runtimeRpc)
- await startFirstWindowStartupServices({
- // Why: the persistent-terminal daemon is always started. If it fails, the
- // LocalPtyProvider remains as the implicit fallback — terminals work, just
- // without cross-restart persistence.
- startDaemonPtyProvider: () => initDaemonPtyProvider(),
- // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
- // so the hook server must start before restored terminals can mount.
- startAgentHookServer: () =>
- agentHookServer.start({
- env: app.isPackaged ? 'production' : 'development',
- // Why: hooks source this endpoint file at invocation time, so old PTY
- // env still reaches the current Orca process after an app restart.
- userDataPath: app.getPath('userData')
- }),
- onDaemonError: (error) => {
- console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
- },
- onAgentHookServerError: (error) => {
- // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
- // enrichment only. Orca must still boot if the loopback receiver fails.
- console.error('[agent-hooks] Failed to start local hook server:', error)
- }
- })
+ if (!isServeMode) {
+ await startFirstWindowStartupServices({
+ // Why: the persistent-terminal daemon is desktop-only. Headless
+ // `orca serve` registers its PTY runtime below and must not spawn the
+ // desktop daemon or hook loopback listener.
+ startDaemonPtyProvider: () => initDaemonPtyProvider(),
+ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state,
+ // so the hook server must start before restored terminals can mount.
+ startAgentHookServer: () =>
+ agentHookServer.start({
+ env: app.isPackaged ? 'production' : 'development',
+ // Why: hooks source this endpoint file at invocation time, so old PTY
+ // env still reaches the current Orca process after an app restart.
+ userDataPath: app.getPath('userData')
+ }),
+ onDaemonError: (error) => {
+ console.error('[daemon] Failed to start daemon PTY provider, falling back to local:', error)
+ },
+ onAgentHookServerError: (error) => {
+ // Why: Claude/Codex/Gemini/OpenCode/Cursor hook callbacks are sidebar
+ // enrichment only. Orca must still boot if the loopback receiver fails.
+ console.error('[agent-hooks] Failed to start local hook server:', error)
+ }
+ })
+ }
+
+ if (serveOptions) {
+ registerHeadlessPtyRuntime(
+ runtime,
+ () => codexRuntimeHome!.prepareForCodexLaunch(),
+ () => store!.getSettings(),
+ () => claudeRuntimeAuth!.prepareForClaudeLaunch(),
+ store
+ )
+ // Why: headless servers have no renderer graph publisher. Publish an
+ // explicit empty graph so status clients see a ready server while
+ // renderer-only operations still fail at their own window boundary.
+ runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
+ await runtimeRpc.start().catch((error) => {
+ console.error('[runtime] Failed to start headless RPC transport:', error)
+ throw error
+ })
+ installServeSignalHandlers()
+ await printServeReady(serveOptions)
+ return
+ }
// Why: once the hook server is ready (or has already failed open), window
// creation and runtime RPC startup are independent.
diff --git a/src/main/ipc/filesystem-import-ssh-ops.test.ts b/src/main/ipc/filesystem-import-ssh-ops.test.ts
index a67e6d8a787..376e867da43 100644
--- a/src/main/ipc/filesystem-import-ssh-ops.test.ts
+++ b/src/main/ipc/filesystem-import-ssh-ops.test.ts
@@ -179,7 +179,16 @@ describe('fs:importExternalPaths — SSH operations', () => {
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'directory' })
- expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`)
+ expect(mkdirSftpMock).toHaveBeenCalledWith(mockSftp, `${destDir}/assets`, {
+ allowExisting: false
+ })
+ expect(uploadDirMock).toHaveBeenCalledWith(
+ mockSftp,
+ path.resolve('/tmp/dropped/assets'),
+ `${destDir}/assets`,
+ path.resolve('/tmp/dropped/assets'),
+ { exclusive: true }
+ )
})
it('reports per-item failure when deconfliction throws', async () => {
diff --git a/src/main/ipc/filesystem-import-ssh.test.ts b/src/main/ipc/filesystem-import-ssh.test.ts
index 210fbfe1105..e348f2f2979 100644
--- a/src/main/ipc/filesystem-import-ssh.test.ts
+++ b/src/main/ipc/filesystem-import-ssh.test.ts
@@ -1,4 +1,6 @@
import path from 'path'
+import { constants } from 'fs'
+import { Readable, Writable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map Promise>()
@@ -8,7 +10,9 @@ const {
mkdirMock,
realpathMock,
copyFileMock,
+ openMock,
readdirMock,
+ unlinkMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
@@ -20,7 +24,9 @@ const {
mkdirMock: vi.fn(),
realpathMock: vi.fn(),
copyFileMock: vi.fn(),
+ openMock: vi.fn(),
readdirMock: vi.fn(),
+ unlinkMock: vi.fn(),
sftpExistsMock: vi.fn(),
uploadFileMock: vi.fn(),
uploadDirMock: vi.fn(),
@@ -36,7 +42,10 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
- readdir: readdirMock
+ open: openMock,
+ readdir: readdirMock,
+ unlink: unlinkMock,
+ rm: vi.fn()
}))
vi.mock('../ssh/sftp-upload', () => ({
sftpPathExists: sftpExistsMock,
@@ -92,7 +101,9 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
mkdirMock,
realpathMock,
copyFileMock,
+ openMock,
readdirMock,
+ unlinkMock,
sftpExistsMock,
uploadFileMock,
uploadDirMock,
@@ -105,6 +116,30 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
})
realpathMock.mockImplementation(async (p: string) => p)
lstatMock.mockRejectedValue(enoent())
+ openMock.mockImplementation(async (_p: string, flags: unknown) => {
+ if (flags === 'wx') {
+ return {
+ createWriteStream: () =>
+ new Writable({
+ write(_chunk, _encoding, callback) {
+ callback()
+ }
+ }),
+ close: vi.fn().mockResolvedValue(undefined)
+ }
+ }
+ return {
+ stat: vi.fn().mockResolvedValue({
+ size: 12,
+ ino: 1,
+ dev: 1,
+ isFile: () => true
+ }),
+ createReadStream: () => Readable.from([Buffer.from('file-content')]),
+ close: vi.fn().mockResolvedValue(undefined)
+ }
+ })
+ unlinkMock.mockResolvedValue(undefined)
sftpExistsMock.mockResolvedValue(false)
uploadFileMock.mockResolvedValue(undefined)
uploadDirMock.mockResolvedValue(undefined)
@@ -121,7 +156,12 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
connectionId: connId
})
expect(results[0]).toMatchObject({ status: 'imported', kind: 'file' })
- expect(uploadFileMock).toHaveBeenCalled()
+ expect(uploadFileMock).toHaveBeenCalledWith(
+ mockSftp,
+ path.resolve('/tmp/dropped/file.txt'),
+ `${destDir}/file.txt`,
+ { exclusive: true }
+ )
expect(copyFileMock).not.toHaveBeenCalled()
})
@@ -132,7 +172,10 @@ describe('fs:importExternalPaths — SSH routing & connection', () => {
destDir: path.resolve('/workspace/repo/src')
})
expect(results[0]).toMatchObject({ status: 'imported' })
- expect(copyFileMock).toHaveBeenCalled()
+ expect(openMock).toHaveBeenCalledWith(
+ path.resolve('/tmp/dropped/file.txt'),
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
+ )
})
it('returns empty results without opening SFTP', async () => {
diff --git a/src/main/ipc/filesystem-import-ssh.ts b/src/main/ipc/filesystem-import-ssh.ts
index 8554b466547..c29dbd6ee04 100644
--- a/src/main/ipc/filesystem-import-ssh.ts
+++ b/src/main/ipc/filesystem-import-ssh.ts
@@ -1,4 +1,4 @@
-import { lstat, readdir } from 'fs/promises'
+import { lstat, readdir, realpath } from 'fs/promises'
import { basename, join, posix, resolve } from 'path'
import type { SFTPWrapper } from 'ssh2'
import { authorizeExternalPath, isENOENT } from './filesystem-auth'
@@ -124,10 +124,12 @@ async function importOneSourceSsh(
const renamed = finalName !== originalName
if (isDir) {
- await mkdirSftp(sftp, destPath)
- await uploadDirectory(sftp, resolvedSource, destPath)
+ await mkdirSftp(sftp, destPath, { allowExisting: false })
+ await uploadDirectory(sftp, resolvedSource, destPath, await realpath(resolvedSource), {
+ exclusive: true
+ })
} else {
- await uploadFile(sftp, resolvedSource, destPath)
+ await uploadFile(sftp, resolvedSource, destPath, { exclusive: true })
}
return {
diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts
index a6dc5df44b1..318e2d120fc 100644
--- a/src/main/ipc/filesystem-import.test.ts
+++ b/src/main/ipc/filesystem-import.test.ts
@@ -1,17 +1,34 @@
+/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing,
+symlink safety, and runtime-upload staging against one shared IPC fixture. */
import path from 'path'
+import { constants } from 'fs'
+import { Readable, Writable } from 'stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map Promise>()
-const { handleMock, lstatMock, mkdirMock, realpathMock, copyFileMock, readdirMock } = vi.hoisted(
- () => ({
- handleMock: vi.fn(),
- lstatMock: vi.fn(),
- mkdirMock: vi.fn(),
- realpathMock: vi.fn(),
- copyFileMock: vi.fn(),
- readdirMock: vi.fn()
- })
-)
+const {
+ handleMock,
+ lstatMock,
+ mkdirMock,
+ realpathMock,
+ copyFileMock,
+ openMock,
+ readFileMock,
+ readdirMock,
+ rmMock,
+ unlinkMock
+} = vi.hoisted(() => ({
+ handleMock: vi.fn(),
+ lstatMock: vi.fn(),
+ mkdirMock: vi.fn(),
+ realpathMock: vi.fn(),
+ copyFileMock: vi.fn(),
+ openMock: vi.fn(),
+ readFileMock: vi.fn(),
+ readdirMock: vi.fn(),
+ rmMock: vi.fn(),
+ unlinkMock: vi.fn()
+}))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
@@ -20,11 +37,15 @@ vi.mock('electron', () => ({
vi.mock('fs/promises', () => ({
lstat: lstatMock,
mkdir: mkdirMock,
+ open: openMock,
rename: vi.fn(),
writeFile: vi.fn(),
realpath: realpathMock,
copyFile: copyFileMock,
- readdir: readdirMock
+ readFile: readFileMock,
+ readdir: readdirMock,
+ rm: rmMock,
+ unlink: unlinkMock
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
@@ -50,7 +71,14 @@ describe('fs:importExternalPaths', () => {
const resolvedPath = path.resolve(filePath)
lstatMock.mockImplementation(async (p: string) => {
if (p === resolvedPath) {
- return { isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false }
+ return {
+ size: 12,
+ ino: 1,
+ dev: 1,
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }
}
throw enoent()
})
@@ -62,6 +90,17 @@ describe('fs:importExternalPaths', () => {
if (p === resolvedDir) {
return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
}
+ const entry = entries.find((e) => path.join(resolvedDir, e.name) === p)
+ if (entry) {
+ return {
+ size: entry.isDir ? 0 : 12,
+ ino: entry.isDir ? 2 : 3,
+ dev: 1,
+ isFile: () => !entry.isDir,
+ isDirectory: () => entry.isDir,
+ isSymbolicLink: () => false
+ }
+ }
throw enoent()
})
readdirMock.mockImplementation(async () => {
@@ -84,6 +123,35 @@ describe('fs:importExternalPaths', () => {
})
}
+ function mockLocalCopyOpenSuccess(content = Buffer.from('file-content')): void {
+ openMock.mockImplementation(async (_p: string, flags: unknown) => {
+ if (flags === 'wx') {
+ const written: Buffer[] = []
+ return {
+ createWriteStream: () =>
+ new Writable({
+ write(chunk, _encoding, callback) {
+ written.push(Buffer.from(chunk))
+ callback()
+ }
+ }),
+ close: vi.fn().mockResolvedValue(undefined),
+ written
+ }
+ }
+ return {
+ stat: vi.fn().mockResolvedValue({
+ size: content.byteLength,
+ ino: 1,
+ dev: 1,
+ isFile: () => true
+ }),
+ createReadStream: () => Readable.from([content]),
+ close: vi.fn().mockResolvedValue(undefined)
+ }
+ })
+ }
+
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
@@ -91,7 +159,11 @@ describe('fs:importExternalPaths', () => {
mkdirMock.mockReset()
realpathMock.mockReset()
copyFileMock.mockReset()
+ openMock.mockReset()
+ readFileMock.mockReset()
readdirMock.mockReset()
+ rmMock.mockReset()
+ unlinkMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
@@ -101,7 +173,11 @@ describe('fs:importExternalPaths', () => {
lstatMock.mockRejectedValue(enoent())
mkdirMock.mockResolvedValue(undefined)
copyFileMock.mockResolvedValue(undefined)
+ mockLocalCopyOpenSuccess()
+ readFileMock.mockResolvedValue(Buffer.from('file-content'))
readdirMock.mockResolvedValue([])
+ rmMock.mockResolvedValue(undefined)
+ unlinkMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
@@ -122,10 +198,44 @@ describe('fs:importExternalPaths', () => {
renamed: false,
destPath: path.join(destDir, 'logo.png')
})
- expect(copyFileMock).toHaveBeenCalledWith(
+ expect(openMock).toHaveBeenCalledWith(
path.resolve(sourcePath),
- path.join(destDir, 'logo.png')
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
)
+ expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
+ expect(copyFileMock).not.toHaveBeenCalled()
+ })
+
+ it('fails local import instead of clobbering when the chosen destination appears late', async () => {
+ const sourcePath = '/tmp/dropped/logo.png'
+ mockSourceFile(sourcePath)
+ openMock.mockImplementation(async (_p: string, flags: unknown) => {
+ if (flags === 'wx') {
+ throw Object.assign(new Error('EEXIST'), { code: 'EEXIST' })
+ }
+ return {
+ stat: vi.fn().mockResolvedValue({
+ size: 12,
+ ino: 1,
+ dev: 1,
+ isFile: () => true
+ }),
+ createReadStream: () => Readable.from([Buffer.from('file-content')]),
+ close: vi.fn().mockResolvedValue(undefined)
+ }
+ })
+
+ const result = (await handlers.get('fs:importExternalPaths')!(null, {
+ sourcePaths: [sourcePath],
+ destDir
+ })) as { results: { status: string; reason?: string }[] }
+
+ expect(result.results[0]).toMatchObject({ status: 'failed', reason: 'EEXIST' })
+ expect(openMock).toHaveBeenCalledWith(
+ path.resolve(sourcePath),
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)
+ )
+ expect(openMock).toHaveBeenCalledWith(path.join(destDir, 'logo.png'), 'wx')
})
it('imports multiple files in one batch', async () => {
@@ -276,6 +386,44 @@ describe('fs:importExternalPaths', () => {
expect(copyFileMock).not.toHaveBeenCalled()
})
+ it('fails and removes output if a local directory entry becomes a symlink after pre-scan', async () => {
+ const sourcePath = '/tmp/dropped/mixeddir'
+ const resolvedSource = path.resolve(sourcePath)
+ const childPath = path.join(resolvedSource, 'normal.txt')
+ lstatMock.mockImplementation(async (p: string) => {
+ if (p === resolvedSource) {
+ return { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false }
+ }
+ if (p === childPath) {
+ return { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true }
+ }
+ throw enoent()
+ })
+ readdirMock.mockResolvedValue([
+ {
+ name: 'normal.txt',
+ isDirectory: () => false,
+ isSymbolicLink: () => false,
+ isFile: () => true
+ }
+ ])
+
+ const result = (await handlers.get('fs:importExternalPaths')!(null, {
+ sourcePaths: [sourcePath],
+ destDir
+ })) as { results: { status: string; reason?: string }[] }
+
+ expect(result.results[0]).toMatchObject({
+ status: 'failed',
+ reason: "Symlink not allowed in 'normal.txt'"
+ })
+ expect(openMock).not.toHaveBeenCalledWith(childPath, expect.anything())
+ expect(rmMock).toHaveBeenCalledWith(path.join(destDir, 'mixeddir'), {
+ recursive: true,
+ force: true
+ })
+ })
+
it('rejects unauthorized destinations', async () => {
const sourcePath = '/tmp/dropped/file.txt'
mockSourceFile(sourcePath)
@@ -328,4 +476,118 @@ describe('fs:importExternalPaths', () => {
reason: 'missing'
})
})
+
+ it('stages external files for runtime upload without copying into the local worktree', async () => {
+ const sourcePath = '/tmp/dropped/logo.png'
+ const resolvedPath = path.resolve(sourcePath)
+ lstatMock.mockImplementation(async (p: string) => {
+ if (p === resolvedPath) {
+ return {
+ size: 4,
+ ino: 1,
+ dev: 1,
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }
+ }
+ throw enoent()
+ })
+ const closeMock = vi.fn().mockResolvedValue(undefined)
+ const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
+ openMock.mockResolvedValue({
+ stat: vi.fn().mockResolvedValue({
+ size: 4,
+ ino: 1,
+ dev: 1,
+ isFile: () => true
+ }),
+ readFile: readFileHandleMock,
+ close: closeMock
+ })
+
+ const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
+ sourcePaths: [sourcePath]
+ })) as { sources: unknown[] }
+
+ expect(result.sources).toEqual([
+ {
+ sourcePath,
+ status: 'staged',
+ name: 'logo.png',
+ kind: 'file',
+ entries: [{ relativePath: '', kind: 'file', contentBase64: 'cG5n' }]
+ }
+ ])
+ expect(copyFileMock).not.toHaveBeenCalled()
+ expect(readFileHandleMock).toHaveBeenCalled()
+ expect(closeMock).toHaveBeenCalled()
+ })
+
+ it('fails runtime upload staging when a file changes between lstat and open', async () => {
+ const sourcePath = '/tmp/dropped/logo.png'
+ const resolvedPath = path.resolve(sourcePath)
+ lstatMock.mockImplementation(async (p: string) => {
+ if (p === resolvedPath) {
+ return {
+ size: 4,
+ ino: 1,
+ dev: 1,
+ isFile: () => true,
+ isDirectory: () => false,
+ isSymbolicLink: () => false
+ }
+ }
+ throw enoent()
+ })
+ const readFileHandleMock = vi.fn().mockResolvedValue(Buffer.from('png'))
+ openMock.mockResolvedValue({
+ stat: vi.fn().mockResolvedValue({
+ size: 4,
+ ino: 2,
+ dev: 1,
+ isFile: () => true
+ }),
+ readFile: readFileHandleMock,
+ close: vi.fn().mockResolvedValue(undefined)
+ })
+
+ const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
+ sourcePaths: [sourcePath]
+ })) as { sources: { status: string; reason?: string }[] }
+
+ expect(result.sources[0]).toMatchObject({
+ status: 'failed',
+ reason: "File changed during upload staging: ''"
+ })
+ expect(readFileHandleMock).not.toHaveBeenCalled()
+ })
+
+ it('fails runtime upload staging when a checked directory resolves outside the upload root', async () => {
+ const sourcePath = '/tmp/dropped/assets'
+ const resolvedPath = path.resolve(sourcePath)
+ lstatMock.mockImplementation(async (p: string) => {
+ if (p === resolvedPath) {
+ return {
+ isFile: () => false,
+ isDirectory: () => true,
+ isSymbolicLink: () => false
+ }
+ }
+ throw enoent()
+ })
+ readdirMock.mockResolvedValue([])
+ realpathMock
+ .mockResolvedValueOnce(resolvedPath)
+ .mockResolvedValueOnce(path.resolve('/private/assets'))
+
+ const result = (await handlers.get('fs:stageExternalPathsForRuntimeUpload')!(null, {
+ sourcePaths: [sourcePath]
+ })) as { sources: { status: string; reason?: string }[] }
+
+ expect(result.sources[0]).toMatchObject({
+ status: 'failed',
+ reason: "Path escaped upload root during staging: ''"
+ })
+ })
})
diff --git a/src/main/ipc/filesystem-mutations.test.ts b/src/main/ipc/filesystem-mutations.test.ts
index 3865fc1ef37..13c4a648acc 100644
--- a/src/main/ipc/filesystem-mutations.test.ts
+++ b/src/main/ipc/filesystem-mutations.test.ts
@@ -2,16 +2,16 @@ import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const handlers = new Map Promise>()
-const { handleMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } = vi.hoisted(
- () => ({
+const { handleMock, copyFileMock, lstatMock, mkdirMock, renameMock, writeFileMock, realpathMock } =
+ vi.hoisted(() => ({
handleMock: vi.fn(),
+ copyFileMock: vi.fn(),
lstatMock: vi.fn(),
mkdirMock: vi.fn(),
renameMock: vi.fn(),
writeFileMock: vi.fn(),
realpathMock: vi.fn()
- })
-)
+ }))
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
@@ -23,11 +23,15 @@ vi.mock('fs/promises', () => ({
rename: renameMock,
writeFile: writeFileMock,
realpath: realpathMock,
- copyFile: vi.fn(),
+ copyFile: copyFileMock,
readdir: vi.fn()
}))
import { registerFilesystemMutationHandlers } from './filesystem-mutations'
+import {
+ registerSshFilesystemProvider,
+ unregisterSshFilesystemProvider
+} from '../providers/ssh-filesystem-dispatch'
// Why: paths are resolved via path.resolve() in production code, so test
// data must use resolved paths to avoid Unix-vs-Windows mismatches.
@@ -58,6 +62,7 @@ describe('registerFilesystemMutationHandlers', () => {
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
+ copyFileMock.mockReset()
lstatMock.mockReset()
mkdirMock.mockReset()
renameMock.mockReset()
@@ -74,6 +79,7 @@ describe('registerFilesystemMutationHandlers', () => {
mkdirMock.mockResolvedValue(undefined)
writeFileMock.mockResolvedValue(undefined)
renameMock.mockResolvedValue(undefined)
+ copyFileMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
@@ -210,6 +216,36 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
+ // ── fs:copy ────────────────────────────────────────────────────
+
+ it('copies a file without overwriting an existing destination', async () => {
+ const sourcePath = path.resolve('/workspace/repo/source.ts')
+ const destinationPath = path.resolve('/workspace/repo/source copy.ts')
+
+ await handlers.get('fs:copy')!(null, { sourcePath, destinationPath })
+
+ expect(mkdirMock).toHaveBeenCalledWith(path.resolve('/workspace/repo'), { recursive: true })
+ expect(copyFileMock).toHaveBeenCalledWith(sourcePath, destinationPath, expect.any(Number))
+ })
+
+ it('routes copy through the SSH filesystem provider when a connection is present', async () => {
+ const copy = vi.fn().mockResolvedValue(undefined)
+ registerSshFilesystemProvider('ssh-1', { copy } as never)
+
+ try {
+ await handlers.get('fs:copy')!(null, {
+ sourcePath: '/home/me/repo/source.ts',
+ destinationPath: '/home/me/repo/source copy.ts',
+ connectionId: 'ssh-1'
+ })
+ } finally {
+ unregisterSshFilesystemProvider('ssh-1')
+ }
+
+ expect(copy).toHaveBeenCalledWith('/home/me/repo/source.ts', '/home/me/repo/source copy.ts')
+ expect(copyFileMock).not.toHaveBeenCalled()
+ })
+
// ── Edge cases ─────────────────────────────────────────────────
it('propagates non-ENOENT lstat errors in assertNotExists', async () => {
diff --git a/src/main/ipc/filesystem-mutations.ts b/src/main/ipc/filesystem-mutations.ts
index b9bcd91bedd..528baa1737c 100644
--- a/src/main/ipc/filesystem-mutations.ts
+++ b/src/main/ipc/filesystem-mutations.ts
@@ -1,6 +1,21 @@
+/* eslint-disable max-lines -- Why: filesystem mutation IPC handlers stay centralized so
+authorization, SSH routing, and external import behavior remain audited together. */
import { ipcMain } from 'electron'
-import { copyFile, lstat, mkdir, readdir, rename, writeFile } from 'fs/promises'
-import { basename, dirname, join, resolve } from 'path'
+import { constants } from 'fs'
+import {
+ copyFile,
+ lstat,
+ mkdir,
+ open,
+ readdir,
+ realpath,
+ rename,
+ rm,
+ unlink,
+ writeFile
+} from 'fs/promises'
+import { basename, dirname, isAbsolute, join, relative, resolve } from 'path'
+import { pipeline } from 'stream/promises'
import type { Store } from '../persistence'
import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesystem-auth'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
@@ -117,14 +132,42 @@ export function registerFilesystemMutationHandlers(store: Store): void {
}
)
+ ipcMain.handle(
+ 'fs:copy',
+ async (
+ _event,
+ args: { sourcePath: string; destinationPath: string; connectionId?: string }
+ ): Promise => {
+ if (args.connectionId) {
+ const provider = getSshFilesystemProvider(args.connectionId)
+ if (!provider) {
+ throw new Error(`No filesystem provider for connection "${args.connectionId}"`)
+ }
+ return provider.copy(args.sourcePath, args.destinationPath)
+ }
+ const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, {
+ preserveSymlink: true
+ })
+ const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, {
+ preserveSymlink: true
+ })
+ await mkdir(dirname(destinationPath), { recursive: true })
+ // Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL
+ // keeps a late race from silently overwriting an existing file.
+ await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
+ }
+ )
+
ipcMain.handle(
'fs:importExternalPaths',
async (
_event,
- args: { sourcePaths: string[]; destDir: string; connectionId?: string }
+ args: { sourcePaths: string[]; destDir: string; connectionId?: string; ensureDir?: boolean }
): Promise<{ results: ImportItemResult[] }> => {
if (args.connectionId) {
- return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId)
+ return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
+ ensureDir: args.ensureDir
+ })
}
// Why: destDir must be authorized before any copy work begins. If the
@@ -148,6 +191,20 @@ export function registerFilesystemMutationHandlers(store: Store): void {
}
)
+ ipcMain.handle(
+ 'fs:stageExternalPathsForRuntimeUpload',
+ async (
+ _event,
+ args: { sourcePaths: string[] }
+ ): Promise<{ sources: StagedExternalImportSource[] }> => {
+ const sources: StagedExternalImportSource[] = []
+ for (const sourcePath of args.sourcePaths) {
+ sources.push(await stageOneSourceForRuntimeUpload(sourcePath))
+ }
+ return { sources }
+ }
+ )
+
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
// upload each path into `${worktreePath}/.orca/drops/` and return remote
@@ -217,6 +274,32 @@ export type ImportItemResult =
reason: string
}
+export type StagedExternalImportSource =
+ | {
+ sourcePath: string
+ status: 'staged'
+ name: string
+ kind: 'file' | 'directory'
+ entries: StagedExternalImportEntry[]
+ }
+ | {
+ sourcePath: string
+ status: 'skipped'
+ reason: ImportSkipReason
+ }
+ | {
+ sourcePath: string
+ status: 'failed'
+ reason: string
+ }
+
+export type StagedExternalImportEntry =
+ | { relativePath: string; kind: 'directory' }
+ | { relativePath: string; kind: 'file'; contentBase64: string }
+
+const REMOTE_IMPORT_MAX_FILE_BYTES = 25 * 1024 * 1024
+const REMOTE_IMPORT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
+
// ─── External Import Implementation ─────────────────────────────────
/**
@@ -288,8 +371,13 @@ async function importOneSource(
const renamed = finalName !== originalName
try {
- await (isDir ? recursiveCopyDir(resolvedSource, destPath) : copyFile(resolvedSource, destPath))
+ await (isDir
+ ? recursiveCopyDir(resolvedSource, destPath)
+ : copyLocalFileNoFollow(resolvedSource, destPath))
} catch (error) {
+ if (isDir) {
+ await rm(destPath, { recursive: true, force: true }).catch(() => {})
+ }
return {
sourcePath,
status: 'failed',
@@ -306,6 +394,194 @@ async function importOneSource(
}
}
+async function stageOneSourceForRuntimeUpload(
+ sourcePath: string
+): Promise {
+ const resolvedSource = resolve(sourcePath)
+
+ // Why: runtime uploads read client-local paths in the client main process;
+ // authorize before lstat/readFile just like local copy imports.
+ authorizeExternalPath(resolvedSource)
+
+ let sourceStat: Awaited>
+ try {
+ sourceStat = await lstat(resolvedSource)
+ } catch (error) {
+ if (isENOENT(error)) {
+ return { sourcePath, status: 'skipped', reason: 'missing' }
+ }
+ if (
+ error instanceof Error &&
+ 'code' in error &&
+ ((error as NodeJS.ErrnoException).code === 'EACCES' ||
+ (error as NodeJS.ErrnoException).code === 'EPERM')
+ ) {
+ return { sourcePath, status: 'skipped', reason: 'permission-denied' }
+ }
+ return {
+ sourcePath,
+ status: 'failed',
+ reason: error instanceof Error ? error.message : String(error)
+ }
+ }
+
+ if (sourceStat.isSymbolicLink()) {
+ return { sourcePath, status: 'skipped', reason: 'symlink' }
+ }
+ if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
+ return { sourcePath, status: 'skipped', reason: 'unsupported' }
+ }
+ if (sourceStat.isDirectory() && (await preScanForSymlinks(resolvedSource))) {
+ return { sourcePath, status: 'skipped', reason: 'symlink' }
+ }
+
+ try {
+ const entries = sourceStat.isDirectory()
+ ? await stageDirectoryEntries(resolvedSource)
+ : [await stageFileEntry(resolvedSource, '')]
+ return {
+ sourcePath,
+ status: 'staged',
+ name: basename(resolvedSource),
+ kind: sourceStat.isDirectory() ? 'directory' : 'file',
+ entries
+ }
+ } catch (error) {
+ return {
+ sourcePath,
+ status: 'failed',
+ reason: error instanceof Error ? error.message : String(error)
+ }
+ }
+}
+
+async function stageDirectoryEntries(rootPath: string): Promise {
+ const entries: StagedExternalImportEntry[] = [{ relativePath: '', kind: 'directory' }]
+ let totalBytes = 0
+ const rootRealPath = await realpath(rootPath)
+
+ async function visit(dirPath: string): Promise {
+ const dirStat = await lstat(dirPath)
+ if (dirStat.isSymbolicLink()) {
+ throw new Error(
+ `Symlink not allowed in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
+ )
+ }
+ if (!dirStat.isDirectory()) {
+ throw new Error(
+ `Unsupported file type in '${normalizeRelativeUploadPath(relative(rootPath, dirPath))}'`
+ )
+ }
+ await assertRealPathInsideRoot(
+ rootRealPath,
+ dirPath,
+ normalizeRelativeUploadPath(relative(rootPath, dirPath))
+ )
+ const dirEntries = await readdir(dirPath, { withFileTypes: true })
+ for (const entry of dirEntries) {
+ const childPath = join(dirPath, entry.name)
+ const childRelativePath = normalizeRelativeUploadPath(relative(rootPath, childPath))
+ if (entry.isDirectory()) {
+ const childStat = await lstat(childPath)
+ if (childStat.isSymbolicLink()) {
+ throw new Error(`Symlink not allowed in '${childRelativePath}'`)
+ }
+ if (!childStat.isDirectory()) {
+ throw new Error(`Unsupported file type in '${childRelativePath}'`)
+ }
+ entries.push({ relativePath: childRelativePath, kind: 'directory' })
+ await visit(childPath)
+ continue
+ }
+ if (!entry.isFile()) {
+ throw new Error(`Unsupported file type in '${childRelativePath}'`)
+ }
+ const statResult = await lstat(childPath)
+ totalBytes += statResult.size
+ assertRemoteUploadBudget(childRelativePath, statResult.size, totalBytes)
+ entries.push(await stageFileEntry(childPath, childRelativePath, rootRealPath))
+ }
+ }
+
+ await visit(rootPath)
+ return entries
+}
+
+async function stageFileEntry(
+ filePath: string,
+ relativePath: string,
+ rootRealPath?: string
+): Promise {
+ const statResult = await lstat(filePath)
+ const displayPath = normalizeRelativeUploadPath(relativePath)
+ if (statResult.isSymbolicLink()) {
+ throw new Error(`Symlink not allowed in '${displayPath}'`)
+ }
+ if (!statResult.isFile()) {
+ throw new Error(`Unsupported file type in '${displayPath}'`)
+ }
+ if (rootRealPath) {
+ await assertRealPathInsideRoot(rootRealPath, filePath, displayPath)
+ }
+ assertRemoteUploadBudget(relativePath, statResult.size, statResult.size)
+ const fileHandle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
+ try {
+ const openedStat = await fileHandle.stat()
+ if (!openedStat.isFile()) {
+ throw new Error(`Unsupported file type in '${displayPath}'`)
+ }
+ if (
+ openedStat.size !== statResult.size ||
+ (statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
+ (statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
+ ) {
+ throw new Error(`File changed during upload staging: '${displayPath}'`)
+ }
+ assertRemoteUploadBudget(relativePath, openedStat.size, openedStat.size)
+ const buffer = await fileHandle.readFile()
+ const afterReadStat = await fileHandle.stat()
+ if (afterReadStat.size !== openedStat.size) {
+ throw new Error(`File changed during upload staging: '${displayPath}'`)
+ }
+ return {
+ relativePath: displayPath,
+ kind: 'file',
+ contentBase64: buffer.toString('base64')
+ }
+ } finally {
+ await fileHandle.close()
+ }
+}
+
+async function assertRealPathInsideRoot(
+ rootRealPath: string,
+ candidatePath: string,
+ displayPath: string
+): Promise {
+ const candidateRealPath = await realpath(candidatePath)
+ const relativeToRoot = relative(rootRealPath, candidateRealPath)
+ if (relativeToRoot !== '' && (relativeToRoot.startsWith('..') || isAbsolute(relativeToRoot))) {
+ throw new Error(`Path escaped upload root during staging: '${displayPath}'`)
+ }
+}
+
+function assertRemoteUploadBudget(
+ relativePath: string,
+ fileBytes: number,
+ totalBytes: number
+): void {
+ if (fileBytes > REMOTE_IMPORT_MAX_FILE_BYTES) {
+ throw new Error(`'${relativePath}' is too large for remote import`)
+ }
+ if (totalBytes > REMOTE_IMPORT_MAX_TOTAL_BYTES) {
+ throw new Error('Remote import is too large')
+ }
+}
+
+function normalizeRelativeUploadPath(path: string): string {
+ return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
+}
+
/**
* Pre-scan a directory tree for symlinks. Returns true if any symlink
* is found anywhere in the subtree.
@@ -332,12 +608,72 @@ async function preScanForSymlinks(dirPath: string): Promise {
* buffering entire files into memory.
*/
async function recursiveCopyDir(srcDir: string, destDir: string): Promise {
- await mkdir(destDir, { recursive: true })
+ await mkdir(destDir, { recursive: false })
const entries = await readdir(srcDir, { withFileTypes: true })
for (const entry of entries) {
const srcPath = join(srcDir, entry.name)
const dstPath = join(destDir, entry.name)
- await (entry.isDirectory() ? recursiveCopyDir(srcPath, dstPath) : copyFile(srcPath, dstPath))
+ const statResult = await lstat(srcPath)
+ if (statResult.isSymbolicLink()) {
+ throw new Error(`Symlink not allowed in '${entry.name}'`)
+ }
+ if (statResult.isDirectory()) {
+ await recursiveCopyDir(srcPath, dstPath)
+ continue
+ }
+ if (!statResult.isFile()) {
+ throw new Error(`Unsupported file type in '${entry.name}'`)
+ }
+ await copyLocalFileNoFollow(srcPath, dstPath, statResult)
+ }
+}
+
+async function copyLocalFileNoFollow(
+ srcPath: string,
+ dstPath: string,
+ statResult?: Awaited>
+): Promise {
+ const beforeOpenStat = statResult ?? (await lstat(srcPath))
+ if (beforeOpenStat.isSymbolicLink()) {
+ throw new Error(`Symlink not allowed in '${basename(srcPath)}'`)
+ }
+ if (!beforeOpenStat.isFile()) {
+ throw new Error(`Unsupported file type in '${basename(srcPath)}'`)
+ }
+
+ let destinationCreated = false
+ const sourceHandle = await open(srcPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
+ let destinationHandle: Awaited> | null = null
+ try {
+ const openedStat = await sourceHandle.stat()
+ if (
+ !openedStat.isFile() ||
+ (typeof beforeOpenStat.size === 'number' && openedStat.size !== beforeOpenStat.size) ||
+ (typeof beforeOpenStat.ino === 'number' &&
+ beforeOpenStat.ino !== 0 &&
+ openedStat.ino !== 0 &&
+ openedStat.ino !== beforeOpenStat.ino) ||
+ (typeof beforeOpenStat.dev === 'number' &&
+ beforeOpenStat.dev !== 0 &&
+ openedStat.dev !== 0 &&
+ openedStat.dev !== beforeOpenStat.dev)
+ ) {
+ throw new Error(`File changed during import: '${basename(srcPath)}'`)
+ }
+ // Why: copyFile(path, path) would follow a source symlink if the source is
+ // swapped after validation. Streaming from an O_NOFOLLOW handle keeps the
+ // authorized file identity pinned for the copy.
+ destinationHandle = await open(dstPath, 'wx')
+ destinationCreated = true
+ await pipeline(sourceHandle.createReadStream(), destinationHandle.createWriteStream())
+ } catch (error) {
+ if (destinationCreated) {
+ await unlink(dstPath).catch(() => {})
+ }
+ throw error
+ } finally {
+ await sourceHandle.close().catch(() => {})
+ await destinationHandle?.close().catch(() => {})
}
}
diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts
index d1e3bafdebb..b6727bcf31f 100644
--- a/src/main/ipc/filesystem-watcher.test.ts
+++ b/src/main/ipc/filesystem-watcher.test.ts
@@ -28,16 +28,25 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
}))
import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher'
+import { stat } from 'fs/promises'
+import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
type HandlerMap = Record Promise | unknown>
describe('registerFilesystemWatcherHandlers', () => {
const handlers: HandlerMap = {}
+ const originalPlatform = process.platform
beforeEach(() => {
vi.useRealTimers()
handleMock.mockReset()
getSshFilesystemProviderMock.mockReset()
+ vi.mocked(stat).mockReset()
+ vi.mocked(subscribeParcelWatcher).mockReset()
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: originalPlatform
+ })
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
@@ -47,6 +56,28 @@ describe('registerFilesystemWatcherHandlers', () => {
registerFilesystemWatcherHandlers()
})
+ it('pins Parcel to the Windows backend for local Windows watches', async () => {
+ Object.defineProperty(process, 'platform', {
+ configurable: true,
+ value: 'win32'
+ })
+ vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
+ vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: vi.fn() } as never)
+
+ await handlers['fs:watchWorktree'](
+ { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } },
+ { worktreePath: 'C:\\repo' }
+ )
+
+ expect(subscribeParcelWatcher).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Function),
+ expect.objectContaining({ backend: 'windows' })
+ )
+
+ await closeAllWatchers()
+ })
+
it('quietly skips SSH worktree watches while the filesystem provider is unavailable', async () => {
vi.useFakeTimers()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts
index 7ffbf3ae778..d6064b4696d 100644
--- a/src/main/ipc/filesystem-watcher.ts
+++ b/src/main/ipc/filesystem-watcher.ts
@@ -239,6 +239,14 @@ async function createWatcher(rootKey: string, rootPath: string): Promise {
@@ -280,9 +288,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise ({
+ handleMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ ipcMain: { handle: handleMock }
+}))
+
+vi.mock('qrcode', () => ({
+ default: {
+ toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,qr')
+ }
+}))
+
+import { registerMobileHandlers } from './mobile'
+
+describe('registerMobileHandlers', () => {
+ const handlers = new Map unknown>()
+
+ beforeEach(() => {
+ handlers.clear()
+ handleMock.mockReset()
+ handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
+ handlers.set(channel, handler)
+ })
+ })
+
+ it('lists only paired mobile-scoped devices', () => {
+ const rpcServer = {
+ getDeviceRegistry: () => ({
+ listDevices: () => [
+ {
+ deviceId: 'mobile-1',
+ name: 'Phone',
+ scope: 'mobile',
+ pairedAt: 1,
+ lastSeenAt: 2
+ },
+ {
+ deviceId: 'runtime-1',
+ name: 'CLI',
+ scope: 'runtime',
+ pairedAt: 1,
+ lastSeenAt: 2
+ },
+ {
+ deviceId: 'pending-mobile',
+ name: 'Pending',
+ scope: 'mobile',
+ pairedAt: 1,
+ lastSeenAt: 0
+ }
+ ]
+ })
+ }
+
+ registerMobileHandlers(rpcServer as never)
+
+ expect(handlers.get('mobile:listDevices')?.()).toEqual({
+ devices: [
+ {
+ deviceId: 'mobile-1',
+ name: 'Phone',
+ pairedAt: 1,
+ lastSeenAt: 2
+ }
+ ]
+ })
+ })
+})
diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts
index 9b1d8be3502..110f16d2699 100644
--- a/src/main/ipc/mobile.ts
+++ b/src/main/ipc/mobile.ts
@@ -2,7 +2,6 @@ import { ipcMain } from 'electron'
import { networkInterfaces } from 'os'
import QRCode from 'qrcode'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
-import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
export type NetworkInterface = {
name: string
@@ -46,12 +45,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
ipcMain.handle(
'mobile:getPairingQR',
async (_event, args?: { address?: string; rotate?: boolean }) => {
- const rawEndpoint = rpcServer.getWebSocketEndpoint()
- const registry = rpcServer.getDeviceRegistry()
- if (!rawEndpoint || !registry) {
- return { available: false as const }
- }
-
// Why: allow the caller to specify which network interface address to
// embed in the QR code. This supports overlay networks (Tailscale,
// ZeroTier) where the default LAN IP isn't reachable from the phone.
@@ -59,7 +52,6 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
if (!ip) {
return { available: false as const }
}
- const endpoint = rawEndpoint.replace('0.0.0.0', ip)
// Why: coalesce repeated QR regenerations onto a single never-scanned
// pending token so the copy-button flow doesn't accumulate orphaned
@@ -68,24 +60,17 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
// `rotate: true` (explicit "Regenerate" intent because the prior token
// may have been exposed), we discard any pending token and mint a fresh
// one so the new QR carries a different credential.
- const name = `Mobile ${new Date().toLocaleDateString()}`
- const device = args?.rotate
- ? registry.rotatePendingDevice(name)
- : registry.getOrCreatePendingDevice(name)
-
- const publicKeyB64 = rpcServer.getE2EEPublicKey()
- if (!publicKeyB64) {
+ const offer = rpcServer.createPairingOffer({
+ address: ip,
+ rotate: args?.rotate,
+ name: `Mobile ${new Date().toLocaleDateString()}`,
+ scope: 'mobile'
+ })
+ if (!offer.available) {
return { available: false as const }
}
- const url = encodePairingOffer({
- v: PAIRING_OFFER_VERSION,
- endpoint,
- deviceToken: device.token,
- publicKeyB64
- })
-
- const qrDataUrl = await QRCode.toDataURL(url, {
+ const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, {
errorCorrectionLevel: 'M',
margin: 2,
width: 256
@@ -94,9 +79,9 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return {
available: true as const,
qrDataUrl,
- pairingUrl: url,
- endpoint,
- deviceId: device.deviceId
+ pairingUrl: offer.pairingUrl,
+ endpoint: offer.endpoint,
+ deviceId: offer.deviceId
}
}
)
@@ -112,7 +97,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
return {
devices: registry
.listDevices()
- .filter((d) => d.lastSeenAt > 0)
+ .filter((d) => d.scope === 'mobile' && d.lastSeenAt > 0)
.map((d) => ({
deviceId: d.deviceId,
name: d.name,
@@ -127,7 +112,7 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
if (!registry) {
return { revoked: false }
}
- return { revoked: registry.removeDevice(args.deviceId) }
+ return { revoked: rpcServer.revokeMobileDevice(args.deviceId) }
})
ipcMain.handle('mobile:isWebSocketReady', () => {
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 7c483712e69..2b304baea2b 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -931,6 +931,13 @@ export function registerPtyHandlers(
return null
}
},
+ hasChildProcesses: async (ptyId) => {
+ try {
+ return await getProviderForPty(ptyId).hasChildProcesses(ptyId)
+ } catch {
+ return false
+ }
+ },
clearBuffer: async (ptyId) => {
// Why: desktop xterm owns local scrollback, while daemon/SSH providers
// own their own retained buffers. Clear both surfaces so mobile
@@ -1620,6 +1627,34 @@ export function registerPtyHandlers(
)
}
+export function registerHeadlessPtyRuntime(
+ runtime: OrcaRuntimeService,
+ getSelectedCodexHomePath?: () => string | null,
+ getSettings?: () => GlobalSettings,
+ prepareClaudeAuth?: () => Promise,
+ store?: Store
+): void {
+ // Why: headless `orca serve` has no renderer window, but the runtime still
+ // needs the same PTY controller and provider listeners as desktop so remote
+ // clients can create, stream, inspect, and stop terminals.
+ const headlessWindow = {
+ isDestroyed: () => true,
+ webContents: {
+ send: () => {},
+ on: () => {},
+ removeListener: () => {}
+ }
+ } as unknown as BrowserWindow
+ registerPtyHandlers(
+ headlessWindow,
+ runtime,
+ getSelectedCodexHomePath,
+ getSettings,
+ prepareClaudeAuth,
+ store
+ )
+}
+
/**
* Kill all PTY processes. Call on app quit.
*/
diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts
index eb6fdd43d6a..aab98e262f9 100644
--- a/src/main/ipc/register-core-handlers.test.ts
+++ b/src/main/ipc/register-core-handlers.test.ts
@@ -24,6 +24,7 @@ const {
registerUIHandlersMock,
registerFilesystemHandlersMock,
registerRuntimeHandlersMock,
+ registerRuntimeEnvironmentHandlersMock,
registerCodexAccountHandlersMock,
registerAgentHookHandlersMock,
registerAgentTrustHandlersMock,
@@ -65,6 +66,7 @@ const {
registerUIHandlersMock: vi.fn(),
registerFilesystemHandlersMock: vi.fn(),
registerRuntimeHandlersMock: vi.fn(),
+ registerRuntimeEnvironmentHandlersMock: vi.fn(),
registerCodexAccountHandlersMock: vi.fn(),
registerAgentHookHandlersMock: vi.fn(),
registerAgentTrustHandlersMock: vi.fn(),
@@ -194,6 +196,10 @@ vi.mock('./runtime', () => ({
registerRuntimeHandlers: registerRuntimeHandlersMock
}))
+vi.mock('./runtime-environments', () => ({
+ registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock
+}))
+
vi.mock('./codex-accounts', () => ({
registerCodexAccountHandlers: registerCodexAccountHandlersMock
}))
@@ -262,6 +268,7 @@ describe('registerCoreHandlers', () => {
registerUIHandlersMock.mockReset()
registerFilesystemHandlersMock.mockReset()
registerRuntimeHandlersMock.mockReset()
+ registerRuntimeEnvironmentHandlersMock.mockReset()
registerCodexAccountHandlersMock.mockReset()
registerAgentHookHandlersMock.mockReset()
registerAgentTrustHandlersMock.mockReset()
@@ -328,6 +335,7 @@ describe('registerCoreHandlers', () => {
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
+ expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
expect(registerCliHandlersMock).toHaveBeenCalled()
expect(registerPreflightHandlersMock).toHaveBeenCalled()
expect(registerShellHandlersMock).toHaveBeenCalled()
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index e445a8e659f..74c08a40f69 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -18,6 +18,7 @@ import { registerStatsHandlers } from './stats'
import { registerMemoryHandlers } from './memory'
import { registerRateLimitHandlers } from './rate-limits'
import { registerRuntimeHandlers } from './runtime'
+import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { registerNotesHandlers } from './notes'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
@@ -120,6 +121,7 @@ export function registerCoreHandlers(
registerFilesystemHandlers(store)
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
+ registerRuntimeEnvironmentHandlers()
registerNotesHandlers(runtime)
registerClipboardHandlers()
registerUpdaterHandlers(store)
diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts
new file mode 100644
index 00000000000..2a06416bb94
--- /dev/null
+++ b/src/main/ipc/runtime-environment-call-queue.ts
@@ -0,0 +1,92 @@
+const REMOTE_RUNTIME_CALL_CONCURRENCY = 8
+const REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
+
+type QueuedRuntimeCall = {
+ background: boolean
+ run: () => Promise
+ resolve: (value: T) => void
+ reject: (error: unknown) => void
+}
+
+type RuntimeCallQueue = {
+ active: number
+ backgroundActive: number
+ foreground: QueuedRuntimeCall[]
+ background: QueuedRuntimeCall[]
+}
+
+const runtimeCallQueues = new Map()
+
+function isBackgroundRuntimeMethod(method: string): boolean {
+ return (
+ method === 'hostedReview.forBranch' ||
+ method === 'github.listWorkItems' ||
+ method === 'github.countWorkItems' ||
+ method === 'git.status' ||
+ method === 'git.conflictOperation' ||
+ method === 'git.branchCompare' ||
+ method === 'git.upstreamStatus'
+ )
+}
+
+function getRuntimeCallQueue(selector: string): RuntimeCallQueue {
+ let queue = runtimeCallQueues.get(selector)
+ if (!queue) {
+ queue = { active: 0, backgroundActive: 0, foreground: [], background: [] }
+ runtimeCallQueues.set(selector, queue)
+ }
+ return queue
+}
+
+export function enqueueRuntimeCall(
+ selector: string,
+ method: string,
+ run: () => Promise
+): Promise {
+ const queue = getRuntimeCallQueue(selector)
+ return new Promise((resolve, reject) => {
+ const call: QueuedRuntimeCall = {
+ background: isBackgroundRuntimeMethod(method),
+ run,
+ resolve,
+ reject
+ }
+ const targetQueue = call.background ? queue.background : queue.foreground
+ targetQueue.push(call as QueuedRuntimeCall)
+ pumpRuntimeCallQueue(selector, queue)
+ })
+}
+
+function pumpRuntimeCallQueue(selector: string, queue: RuntimeCallQueue): void {
+ while (queue.active < REMOTE_RUNTIME_CALL_CONCURRENCY) {
+ let call = queue.foreground.shift()
+ if (!call && queue.backgroundActive < REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY) {
+ call = queue.background.shift()
+ }
+ if (!call) {
+ break
+ }
+
+ queue.active += 1
+ if (call.background) {
+ queue.backgroundActive += 1
+ }
+ // Why: remote WebSocket servers have a finite connection budget and each
+ // one-shot RPC currently opens its own encrypted socket. Background PR/task
+ // refreshes must not stampede the server and starve terminal/worktree calls.
+ void call
+ .run()
+ .then(call.resolve, call.reject)
+ .finally(() => {
+ queue.active = Math.max(0, queue.active - 1)
+ if (call.background) {
+ queue.backgroundActive = Math.max(0, queue.backgroundActive - 1)
+ }
+ if (queue.active === 0 && queue.foreground.length === 0 && queue.background.length === 0) {
+ runtimeCallQueues.delete(selector)
+ return
+ }
+ pumpRuntimeCallQueue(selector, queue)
+ })
+ }
+}
diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts
new file mode 100644
index 00000000000..97cc0bf21ed
--- /dev/null
+++ b/src/main/ipc/runtime-environment-request-connections.ts
@@ -0,0 +1,46 @@
+import type { PairingOffer } from '../../shared/pairing'
+import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
+import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection'
+
+type CachedRuntimeConnection = {
+ pairingKey: string
+ connection: RemoteRuntimeRequestConnection
+}
+
+const requestConnections = new Map()
+
+export function sendRemoteRuntimeConnectionRequest(
+ environmentId: string,
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number
+): Promise> {
+ const pairingKey = getPairingKey(pairing)
+ let cached = requestConnections.get(environmentId)
+ if (!cached || cached.pairingKey !== pairingKey) {
+ cached?.connection.close()
+ cached = {
+ pairingKey,
+ connection: new RemoteRuntimeRequestConnection(pairing)
+ }
+ requestConnections.set(environmentId, cached)
+ }
+ return cached.connection.request(method, params, timeoutMs)
+}
+
+export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
+ const cached = requestConnections.get(environmentId)
+ requestConnections.delete(environmentId)
+ cached?.connection.close()
+}
+
+export function closeAllRemoteRuntimeRequestConnections(): void {
+ for (const environmentId of Array.from(requestConnections.keys())) {
+ closeRemoteRuntimeRequestConnection(environmentId)
+ }
+}
+
+function getPairingKey(pairing: PairingOffer): string {
+ return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0')
+}
diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts
new file mode 100644
index 00000000000..14d51953361
--- /dev/null
+++ b/src/main/ipc/runtime-environments.test.ts
@@ -0,0 +1,615 @@
+/* eslint-disable max-lines -- Why: this suite covers runtime environment
+ management, secret redaction, one-shot RPC, and streaming cleanup contracts. */
+import { mkdtempSync, rmSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { encodePairingOffer } from '../../shared/pairing'
+import * as environmentStore from '../../shared/runtime-environment-store'
+
+const {
+ handleMock,
+ onMock,
+ getPathMock,
+ sendRemoteRuntimeRequestMock,
+ subscribeRemoteRuntimeRequestMock,
+ sendRemoteRuntimeConnectionRequestMock,
+ closeRemoteRuntimeRequestConnectionMock
+} = vi.hoisted(() => ({
+ handleMock: vi.fn(),
+ onMock: vi.fn(),
+ getPathMock: vi.fn(),
+ sendRemoteRuntimeRequestMock: vi.fn(),
+ subscribeRemoteRuntimeRequestMock: vi.fn(),
+ sendRemoteRuntimeConnectionRequestMock: vi.fn(),
+ closeRemoteRuntimeRequestConnectionMock: vi.fn()
+}))
+
+vi.mock('electron', () => ({
+ app: { getPath: getPathMock },
+ ipcMain: { handle: handleMock, on: onMock }
+}))
+
+vi.mock('../../shared/remote-runtime-client', () => ({
+ sendRemoteRuntimeRequest: sendRemoteRuntimeRequestMock,
+ subscribeRemoteRuntimeRequest: subscribeRemoteRuntimeRequestMock
+}))
+
+vi.mock('./runtime-environment-request-connections', () => ({
+ sendRemoteRuntimeConnectionRequest: sendRemoteRuntimeConnectionRequestMock,
+ closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
+}))
+
+import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
+
+function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
+ return encodePairingOffer({
+ v: 2,
+ endpoint,
+ deviceToken: 'device-token',
+ publicKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString('base64')
+ })
+}
+
+function handler(
+ channel: string
+): (_event: unknown, args: TArgs) => TResult | Promise {
+ const match = handleMock.mock.calls.find((call) => call[0] === channel)
+ expect(match).toBeTruthy()
+ return match![1] as (_event: unknown, args: TArgs) => TResult | Promise
+}
+
+describe('registerRuntimeEnvironmentHandlers', () => {
+ let userDataPath: string
+
+ beforeEach(() => {
+ userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-env-ipc-'))
+ getPathMock.mockReset()
+ getPathMock.mockReturnValue(userDataPath)
+ handleMock.mockReset()
+ onMock.mockReset()
+ sendRemoteRuntimeRequestMock.mockReset()
+ subscribeRemoteRuntimeRequestMock.mockReset()
+ sendRemoteRuntimeConnectionRequestMock.mockReset()
+ closeRemoteRuntimeRequestConnectionMock.mockReset()
+ })
+
+ afterEach(() => {
+ rmSync(userDataPath, { recursive: true, force: true })
+ })
+
+ it('registers desktop runtime environment management handlers', () => {
+ registerRuntimeEnvironmentHandlers()
+
+ expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
+ 'runtimeEnvironments:list',
+ 'runtimeEnvironments:addFromPairingCode',
+ 'runtimeEnvironments:resolve',
+ 'runtimeEnvironments:remove',
+ 'runtimeEnvironments:getStatus',
+ 'runtimeEnvironments:call',
+ 'runtimeEnvironments:subscribe',
+ 'runtimeEnvironments:unsubscribe'
+ ])
+ expect(onMock.mock.calls.map((call) => call[0])).toEqual([
+ 'runtimeEnvironments:subscriptionBinary'
+ ])
+ })
+
+ it('stores, resolves, lists, and removes environments under Electron userData', async () => {
+ registerRuntimeEnvironmentHandlers()
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
+ expect(JSON.stringify(added)).not.toContain('device-token')
+ expect(JSON.stringify(added)).not.toContain('publicKeyB64')
+
+ const list = handler('runtimeEnvironments:list')
+ expect(await list(null, undefined)).toMatchObject([{ id: added.environment.id, name: 'desk' }])
+ expect(JSON.stringify(await list(null, undefined))).not.toContain('device-token')
+
+ const resolve = handler<{ selector: string }, { id: string; name: string }>(
+ 'runtimeEnvironments:resolve'
+ )
+ expect(await resolve(null, { selector: 'desk' })).toMatchObject({
+ id: added.environment.id,
+ name: 'desk'
+ })
+ expect(JSON.stringify(await resolve(null, { selector: 'desk' }))).not.toContain('device-token')
+
+ const remove = handler<{ selector: string }, { removed: { id: string; name: string } }>(
+ 'runtimeEnvironments:remove'
+ )
+ const removed = await remove(null, { selector: added.environment.id })
+ expect(removed).toMatchObject({
+ removed: { id: added.environment.id, name: 'desk' }
+ })
+ expect(closeRemoteRuntimeRequestConnectionMock).toHaveBeenCalledWith(added.environment.id)
+ expect(JSON.stringify(removed)).not.toContain('device-token')
+ expect(await list(null, undefined)).toEqual([])
+ })
+
+ it('checks a saved remote runtime and records the runtime id on success', async () => {
+ registerRuntimeEnvironmentHandlers()
+ sendRemoteRuntimeRequestMock.mockResolvedValue({
+ id: 'rpc-1',
+ ok: true,
+ result: { runtimeId: 'runtime-remote', graphStatus: 'ready' },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ const added = await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const getStatus = handler<
+ { selector: string; timeoutMs?: number },
+ { ok: true; result: { runtimeId: string } }
+ >('runtimeEnvironments:getStatus')
+ expect(await getStatus(null, { selector: 'desk', timeoutMs: 50 })).toMatchObject({
+ ok: true,
+ result: { runtimeId: 'runtime-remote' }
+ })
+ expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768', deviceToken: 'device-token' }),
+ 'status.get',
+ undefined,
+ 50
+ )
+
+ const resolve = handler<{ selector: string }, { id: string; runtimeId: string | null }>(
+ 'runtimeEnvironments:resolve'
+ )
+ expect(await resolve(null, { selector: added.environment.id })).toMatchObject({
+ id: added.environment.id,
+ runtimeId: 'runtime-remote'
+ })
+ })
+
+ it('proxies generic one-shot RPC calls to the saved remote runtime', async () => {
+ registerRuntimeEnvironmentHandlers()
+ sendRemoteRuntimeRequestMock.mockResolvedValue({
+ id: 'rpc-2',
+ ok: true,
+ result: { repos: [{ id: 'repo-1' }] },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const call = handler<
+ { selector: string; method: string; params?: unknown; timeoutMs?: number },
+ { ok: true; result: unknown }
+ >('runtimeEnvironments:call')
+ expect(
+ await call(null, { selector: 'desk', method: 'repo.list', timeoutMs: 75 })
+ ).toMatchObject({
+ ok: true,
+ result: { repos: [{ id: 'repo-1' }] }
+ })
+ expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
+ 'repo.list',
+ undefined,
+ 75
+ )
+ expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
+ })
+
+ it('uses the cached request connection for terminal hot path RPCs', async () => {
+ registerRuntimeEnvironmentHandlers()
+ sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
+ id: 'rpc-terminal',
+ ok: true,
+ result: { send: { accepted: true } },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const call = handler<
+ { selector: string; method: string; params?: unknown; timeoutMs?: number },
+ { ok: true; result: unknown }
+ >('runtimeEnvironments:call')
+ expect(
+ await call(null, {
+ selector: 'desk',
+ method: 'terminal.send',
+ params: { terminal: 't1', text: 'a' },
+ timeoutMs: 75
+ })
+ ).toMatchObject({
+ ok: true,
+ result: { send: { accepted: true } }
+ })
+ expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
+ 'terminal.send',
+ { terminal: 't1', text: 'a' },
+ 75
+ )
+ expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
+ })
+
+ it('limits background one-shot RPCs without blocking foreground runtime calls', async () => {
+ registerRuntimeEnvironmentHandlers()
+ const pendingBackground: ((value: unknown) => void)[] = []
+ sendRemoteRuntimeRequestMock.mockImplementation(async () => {
+ return await new Promise((resolve) => pendingBackground.push(resolve))
+ })
+ sendRemoteRuntimeConnectionRequestMock.mockResolvedValue({
+ id: 'terminal-send',
+ ok: true,
+ result: { send: { accepted: true } },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const call = handler<
+ { selector: string; method: string; params?: unknown; timeoutMs?: number },
+ { ok: true; result: unknown }
+ >('runtimeEnvironments:call')
+ const bg1 = call(null, { selector: 'desk', method: 'hostedReview.forBranch' })
+ const bg2 = call(null, { selector: 'desk', method: 'github.listWorkItems' })
+ await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(2))
+
+ const bg3 = call(null, { selector: 'desk', method: 'git.status' })
+ const foreground = call(null, {
+ selector: 'desk',
+ method: 'terminal.send',
+ params: { terminal: 'term-1', text: 'a' }
+ })
+ await vi.waitFor(() =>
+ expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
+ 'hostedReview.forBranch',
+ 'github.listWorkItems'
+ ])
+ )
+ expect(sendRemoteRuntimeConnectionRequestMock).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ 'terminal.send',
+ { terminal: 'term-1', text: 'a' },
+ 15_000
+ )
+
+ await expect(foreground).resolves.toMatchObject({
+ ok: true,
+ result: { send: { accepted: true } }
+ })
+ expect(pendingBackground).toHaveLength(2)
+
+ pendingBackground.shift()?.({
+ id: 'background-1',
+ ok: true,
+ result: null,
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ await vi.waitFor(() => expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledTimes(3))
+ expect(sendRemoteRuntimeRequestMock.mock.calls.map((call) => call[1])).toEqual([
+ 'hostedReview.forBranch',
+ 'github.listWorkItems',
+ 'git.status'
+ ])
+
+ pendingBackground.splice(0).forEach((resolve) =>
+ resolve({
+ id: 'background',
+ ok: true,
+ result: null,
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ )
+ await expect(bg1).resolves.toMatchObject({ ok: true })
+ await expect(bg2).resolves.toMatchObject({ ok: true })
+ await expect(bg3).resolves.toMatchObject({ ok: true })
+ })
+
+ it('starts and stops streaming subscriptions for a saved remote runtime', async () => {
+ registerRuntimeEnvironmentHandlers()
+ const close = vi.fn()
+ const sendBinary = vi.fn()
+ const markUsedSpy = vi.spyOn(environmentStore, 'markEnvironmentUsed')
+ subscribeRemoteRuntimeRequestMock.mockImplementation(
+ async (_pairing, _method, _params, _timeoutMs, callbacks) => {
+ callbacks.onResponse({
+ id: 'stream-1',
+ ok: true,
+ result: { type: 'subscribed' },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ callbacks.onResponse({
+ id: 'stream-1',
+ ok: true,
+ result: { type: 'data', chunk: 'hello' },
+ _meta: { runtimeId: 'runtime-remote' }
+ })
+ callbacks.onBinary(new Uint8Array([1, 2, 3]))
+ return { requestId: 'stream-1', close, sendBinary }
+ }
+ )
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const sent: unknown[] = []
+ const destroyedListenerRemoved = vi.fn()
+ const subscribe = handler<
+ {
+ selector: string
+ method: string
+ params?: unknown
+ timeoutMs?: number
+ subscriptionId?: string
+ },
+ { subscriptionId: string; requestId: string }
+ >('runtimeEnvironments:subscribe')
+ const result = await subscribe(
+ {
+ sender: {
+ id: 1,
+ isDestroyed: () => false,
+ send: (_channel: string, payload: unknown) => sent.push(payload),
+ once: vi.fn(),
+ removeListener: destroyedListenerRemoved
+ }
+ },
+ {
+ selector: 'desk',
+ method: 'terminal.subscribe',
+ params: { terminal: 't1' },
+ timeoutMs: 25,
+ subscriptionId: 'preload-sub-1'
+ }
+ )
+
+ expect(result.requestId).toBe('stream-1')
+ expect(result.subscriptionId).toBe('preload-sub-1')
+ expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
+ 'terminal.subscribe',
+ { terminal: 't1' },
+ 25,
+ expect.any(Object)
+ )
+ expect(sent).toEqual([
+ expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
+ expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'response' }),
+ expect.objectContaining({ subscriptionId: result.subscriptionId, type: 'binary' })
+ ])
+ expect(markUsedSpy).toHaveBeenCalledTimes(1)
+
+ const binaryListener = onMock.mock.calls.find(
+ (call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
+ )?.[1] as (_event: unknown, args: unknown) => void
+ const bytes = new Uint8Array([9, 8, 7])
+ binaryListener({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId, bytes })
+ expect(sendBinary).toHaveBeenCalledWith(bytes)
+
+ const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
+ 'runtimeEnvironments:unsubscribe'
+ )
+ expect(
+ await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
+ ).toEqual({
+ unsubscribed: true
+ })
+ expect(close).toHaveBeenCalled()
+ expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
+ markUsedSpy.mockRestore()
+ })
+
+ it('rejects cross-window streaming subscription control', async () => {
+ registerRuntimeEnvironmentHandlers()
+ const close = vi.fn()
+ const sendBinary = vi.fn()
+ subscribeRemoteRuntimeRequestMock.mockResolvedValue({
+ requestId: 'stream-1',
+ close,
+ sendBinary
+ })
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const subscribe = handler<
+ {
+ selector: string
+ method: string
+ params?: unknown
+ subscriptionId?: string
+ },
+ { subscriptionId: string; requestId: string }
+ >('runtimeEnvironments:subscribe')
+ const result = await subscribe(
+ {
+ sender: {
+ id: 1,
+ isDestroyed: () => false,
+ send: vi.fn(),
+ once: vi.fn(),
+ removeListener: vi.fn()
+ }
+ },
+ {
+ selector: 'desk',
+ method: 'terminal.subscribe',
+ params: { terminal: 't1' },
+ subscriptionId: 'owned-sub'
+ }
+ )
+
+ const binaryListener = onMock.mock.calls.find(
+ (call) => call[0] === 'runtimeEnvironments:subscriptionBinary'
+ )?.[1] as (_event: unknown, args: unknown) => void
+ binaryListener(
+ { sender: { id: 2 } },
+ { subscriptionId: result.subscriptionId, bytes: new Uint8Array([1]) }
+ )
+ expect(sendBinary).not.toHaveBeenCalled()
+
+ const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
+ 'runtimeEnvironments:unsubscribe'
+ )
+ expect(
+ await unsubscribe({ sender: { id: 2 } }, { subscriptionId: result.subscriptionId })
+ ).toEqual({
+ unsubscribed: false
+ })
+ expect(close).not.toHaveBeenCalled()
+
+ expect(
+ await unsubscribe({ sender: { id: 1 } }, { subscriptionId: result.subscriptionId })
+ ).toEqual({
+ unsubscribed: true
+ })
+ expect(close).toHaveBeenCalled()
+ })
+
+ it('closes a streaming subscription that resolves after the sender is destroyed', async () => {
+ registerRuntimeEnvironmentHandlers()
+ const close = vi.fn()
+ let resolveSubscribe: (value: {
+ requestId: string
+ close: () => void
+ sendBinary: (bytes: Uint8Array) => boolean
+ }) => void = () => {}
+ subscribeRemoteRuntimeRequestMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSubscribe = resolve
+ })
+ )
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ let destroyed = false
+ let destroyedHandler: unknown = null
+ const destroyedListenerRemoved = vi.fn()
+ const subscribe = handler<
+ {
+ selector: string
+ method: string
+ params?: unknown
+ subscriptionId?: string
+ },
+ { subscriptionId: string; requestId: string }
+ >('runtimeEnvironments:subscribe')
+ const resultPromise = subscribe(
+ {
+ sender: {
+ id: 1,
+ isDestroyed: () => destroyed,
+ send: vi.fn(),
+ once: vi.fn((_event: string, handler: () => void) => {
+ destroyedHandler = () => {
+ destroyed = true
+ handler()
+ }
+ }),
+ removeListener: destroyedListenerRemoved
+ }
+ },
+ {
+ selector: 'desk',
+ method: 'terminal.subscribe',
+ params: { terminal: 't1' },
+ subscriptionId: 'late-sub'
+ }
+ )
+
+ await vi.waitFor(() => {
+ expect(subscribeRemoteRuntimeRequestMock).toHaveBeenCalled()
+ })
+ expect(destroyedHandler).toBeTypeOf('function')
+ ;(destroyedHandler as () => void)()
+ resolveSubscribe({ requestId: 'stream-late', close, sendBinary: vi.fn() })
+
+ await expect(resultPromise).resolves.toEqual({
+ subscriptionId: 'late-sub',
+ requestId: 'stream-late'
+ })
+ expect(close).toHaveBeenCalledTimes(1)
+ expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
+
+ const unsubscribe = handler<{ subscriptionId: string }, { unsubscribed: boolean }>(
+ 'runtimeEnvironments:unsubscribe'
+ )
+ expect(await unsubscribe({ sender: { id: 1 } }, { subscriptionId: 'late-sub' })).toEqual({
+ unsubscribed: false
+ })
+ })
+
+ it('removes the destroyed listener when streaming subscription setup rejects', async () => {
+ registerRuntimeEnvironmentHandlers()
+ subscribeRemoteRuntimeRequestMock.mockRejectedValue(new Error('connect failed'))
+
+ const add = handler<
+ { name: string; pairingCode: string },
+ { environment: { id: string; name: string } }
+ >('runtimeEnvironments:addFromPairingCode')
+ await add(null, { name: 'desk', pairingCode: pairingCode() })
+
+ const destroyedListenerRemoved = vi.fn()
+ const subscribe = handler<
+ {
+ selector: string
+ method: string
+ params?: unknown
+ subscriptionId?: string
+ },
+ { subscriptionId: string; requestId: string }
+ >('runtimeEnvironments:subscribe')
+
+ await expect(
+ subscribe(
+ {
+ sender: {
+ id: 1,
+ isDestroyed: () => false,
+ send: vi.fn(),
+ once: vi.fn(),
+ removeListener: destroyedListenerRemoved
+ }
+ },
+ {
+ selector: 'desk',
+ method: 'terminal.subscribe',
+ params: { terminal: 't1' },
+ subscriptionId: 'failed-sub'
+ }
+ )
+ ).rejects.toThrow('connect failed')
+
+ expect(destroyedListenerRemoved).toHaveBeenCalledWith('destroyed', expect.any(Function))
+ })
+})
diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts
new file mode 100644
index 00000000000..9b278ba63ba
--- /dev/null
+++ b/src/main/ipc/runtime-environments.ts
@@ -0,0 +1,313 @@
+/* eslint-disable max-lines -- Why: runtime environment IPC is the security boundary for saved server calls and subscriptions; keeping ownership checks, lifecycle cleanup, and binary forwarding together makes the bridge auditable. */
+import { app, ipcMain } from 'electron'
+import { randomUUID } from 'crypto'
+import {
+ addEnvironmentFromPairingCode,
+ listEnvironments,
+ markEnvironmentUsed,
+ removeEnvironment,
+ resolveEnvironment,
+ resolveEnvironmentPairingOffer
+} from '../../shared/runtime-environment-store'
+import {
+ redactRuntimeEnvironment,
+ getPreferredPairingOffer,
+ type PublicKnownRuntimeEnvironment
+} from '../../shared/runtime-environments'
+import type { RuntimeStatus } from '../../shared/runtime-types'
+import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
+import {
+ sendRemoteRuntimeRequest,
+ subscribeRemoteRuntimeRequest,
+ type RemoteRuntimeSubscription
+} from '../../shared/remote-runtime-client'
+import { enqueueRuntimeCall } from './runtime-environment-call-queue'
+import {
+ closeRemoteRuntimeRequestConnection,
+ sendRemoteRuntimeConnectionRequest
+} from './runtime-environment-request-connections'
+
+const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
+type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
+ ownerWebContentsId: number
+ removeDestroyedListener: () => void
+}
+const remoteRuntimeSubscriptions = new Map()
+
+function getUserDataPath(): string {
+ return app.getPath('userData')
+}
+
+function shouldUseCachedRequestConnection(method: string): boolean {
+ return method === 'terminal.send' || method === 'terminal.updateViewport'
+}
+
+export function registerRuntimeEnvironmentHandlers(): void {
+ ipcMain.handle('runtimeEnvironments:list', (): PublicKnownRuntimeEnvironment[] =>
+ listEnvironments(getUserDataPath()).map(redactRuntimeEnvironment)
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:addFromPairingCode',
+ (
+ _event,
+ args: { name: string; pairingCode: string }
+ ): { environment: PublicKnownRuntimeEnvironment } => ({
+ environment: redactRuntimeEnvironment(addEnvironmentFromPairingCode(getUserDataPath(), args))
+ })
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:resolve',
+ (_event, args: { selector: string }): PublicKnownRuntimeEnvironment =>
+ redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:remove',
+ (_event, args: { selector: string }): { removed: PublicKnownRuntimeEnvironment } => {
+ const removed = removeEnvironment(getUserDataPath(), args.selector)
+ closeRemoteRuntimeRequestConnection(removed.id)
+ if (args.selector !== removed.id) {
+ closeRemoteRuntimeRequestConnection(args.selector)
+ }
+ return { removed: redactRuntimeEnvironment(removed) }
+ }
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:getStatus',
+ async (
+ _event,
+ args: { selector: string; timeoutMs?: number }
+ ): Promise> => {
+ const userDataPath = getUserDataPath()
+ const response = await sendRemoteRuntimeRequest(
+ resolveEnvironmentPairingOffer(userDataPath, args.selector),
+ 'status.get',
+ undefined,
+ args.timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
+ )
+ if (response.ok === true) {
+ markEnvironmentUsed(userDataPath, args.selector, { runtimeId: response._meta.runtimeId })
+ }
+ return response
+ }
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:call',
+ async (
+ _event,
+ args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
+ ): Promise> => {
+ return callRuntimeEnvironment(args.selector, args.method, args.params, args.timeoutMs)
+ }
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:subscribe',
+ async (
+ event,
+ args: {
+ selector: string
+ method: string
+ params?: unknown
+ timeoutMs?: number
+ subscriptionId?: string
+ }
+ ): Promise<{ subscriptionId: string; requestId: string }> => {
+ const subscriptionId =
+ typeof args.subscriptionId === 'string' && args.subscriptionId.length > 0
+ ? args.subscriptionId
+ : randomUUID()
+ if (remoteRuntimeSubscriptions.has(subscriptionId)) {
+ throw new Error('Runtime environment subscription id already exists')
+ }
+ const sender = event.sender
+ const ownerWebContentsId = sender.id
+ let senderDestroyed = sender.isDestroyed()
+ let subscription: RemoteRuntimeSubscription | null = null
+ let destroyedListenerAttached = false
+ const removeDestroyedListener = (): void => {
+ if (!destroyedListenerAttached) {
+ return
+ }
+ destroyedListenerAttached = false
+ sender.removeListener('destroyed', closeSubscription)
+ }
+ const closeSubscription = (): void => {
+ senderDestroyed = true
+ const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
+ remoteRuntimeSubscriptions.delete(subscriptionId)
+ if (retained) {
+ retained.close()
+ return
+ }
+ removeDestroyedListener()
+ subscription?.close()
+ }
+ sender.once('destroyed', closeSubscription)
+ destroyedListenerAttached = true
+ try {
+ subscription = await subscribeRuntimeEnvironment(
+ args.selector,
+ args.method,
+ args.params,
+ args.timeoutMs,
+ {
+ onEvent: (payload) => {
+ if (!sender.isDestroyed()) {
+ sender.send('runtimeEnvironments:subscriptionEvent', {
+ subscriptionId,
+ ...payload
+ })
+ }
+ },
+ onClose: () => {
+ const retained = remoteRuntimeSubscriptions.get(subscriptionId) ?? null
+ retained?.removeDestroyedListener()
+ remoteRuntimeSubscriptions.delete(subscriptionId)
+ }
+ }
+ )
+ } catch (error) {
+ removeDestroyedListener()
+ throw error
+ }
+ if (senderDestroyed || sender.isDestroyed()) {
+ removeDestroyedListener()
+ subscription.close()
+ return { subscriptionId, requestId: subscription.requestId }
+ }
+ remoteRuntimeSubscriptions.set(subscriptionId, {
+ requestId: subscription.requestId,
+ ownerWebContentsId,
+ removeDestroyedListener,
+ sendBinary: (bytes) => subscription?.sendBinary(bytes) ?? false,
+ close: () => {
+ removeDestroyedListener()
+ subscription?.close()
+ }
+ })
+ return { subscriptionId, requestId: subscription.requestId }
+ }
+ )
+ ipcMain.handle(
+ 'runtimeEnvironments:unsubscribe',
+ (event, args: { subscriptionId: string }): { unsubscribed: boolean } => {
+ const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
+ if (!subscription || subscription.ownerWebContentsId !== event.sender.id) {
+ return { unsubscribed: false }
+ }
+ remoteRuntimeSubscriptions.delete(args.subscriptionId)
+ subscription.close()
+ return { unsubscribed: true }
+ }
+ )
+ ipcMain.on(
+ 'runtimeEnvironments:subscriptionBinary',
+ (event, args: { subscriptionId?: unknown; bytes?: unknown }) => {
+ if (typeof args.subscriptionId !== 'string') {
+ return
+ }
+ const bytes = toBinaryPayload(args.bytes)
+ if (!bytes) {
+ return
+ }
+ const subscription = remoteRuntimeSubscriptions.get(args.subscriptionId)
+ if (subscription?.ownerWebContentsId === event.sender.id) {
+ subscription.sendBinary(bytes)
+ }
+ }
+ )
+}
+
+function toBinaryPayload(value: unknown): Uint8Array | null {
+ if (value instanceof Uint8Array) {
+ return value
+ }
+ if (value instanceof ArrayBuffer) {
+ return new Uint8Array(value)
+ }
+ if (ArrayBuffer.isView(value)) {
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
+ }
+ return null
+}
+
+async function callRuntimeEnvironment(
+ selector: string,
+ method: string,
+ params: unknown,
+ timeoutMs?: number
+): Promise> {
+ const userDataPath = getUserDataPath()
+ const environment = resolveEnvironment(userDataPath, selector)
+ return enqueueRuntimeCall(environment.id, method, async () => {
+ const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
+ const pairing = getPreferredPairingOffer(currentEnvironment)
+ const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
+ // Why: the cached request socket is only needed for terminal hot paths.
+ // Startup/control-plane RPCs use the proven one-shot path so repo hydration
+ // cannot be coupled to a stale terminal-control connection.
+ const response = shouldUseCachedRequestConnection(method)
+ ? await sendRemoteRuntimeConnectionRequest(
+ currentEnvironment.id,
+ pairing,
+ method,
+ params,
+ effectiveTimeoutMs
+ )
+ : await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
+ if (response.ok === true) {
+ markEnvironmentUsed(userDataPath, currentEnvironment.id, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ return response
+ })
+}
+
+async function subscribeRuntimeEnvironment(
+ selector: string,
+ method: string,
+ params: unknown,
+ timeoutMs: number | undefined,
+ callbacks: {
+ onEvent: (
+ payload:
+ | { type: 'response'; response: RuntimeRpcResponse }
+ | { type: 'binary'; bytes: Uint8Array }
+ | { type: 'error'; code: string; message: string }
+ | { type: 'close' }
+ ) => void
+ onClose: () => void
+ }
+): Promise {
+ const userDataPath = getUserDataPath()
+ let markedUsed = false
+ const markUsedOnce = (runtimeId: string): void => {
+ if (markedUsed) {
+ return
+ }
+ markedUsed = true
+ markEnvironmentUsed(userDataPath, selector, { runtimeId })
+ }
+ const subscription = await subscribeRemoteRuntimeRequest(
+ resolveEnvironmentPairingOffer(userDataPath, selector),
+ method,
+ params,
+ timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS,
+ {
+ onResponse: (response) => {
+ if (response.ok === true) {
+ markUsedOnce(response._meta.runtimeId)
+ }
+ callbacks.onEvent({ type: 'response', response })
+ },
+ onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }),
+ onError: (error) =>
+ callbacks.onEvent({ type: 'error', code: error.code, message: error.message }),
+ onClose: () => {
+ callbacks.onEvent({ type: 'close' })
+ callbacks.onClose()
+ }
+ }
+ )
+ return subscription
+}
diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts
index 29c96559cd8..68a570e28cd 100644
--- a/src/main/ipc/runtime.test.ts
+++ b/src/main/ipc/runtime.test.ts
@@ -28,7 +28,8 @@ describe('registerRuntimeHandlers', () => {
it('routes sync requests through the authoritative browser window id', () => {
const runtime = {
syncWindowGraph: vi.fn().mockReturnValue({ graphStatus: 'ready' }),
- getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' })
+ getStatus: vi.fn().mockReturnValue({ graphStatus: 'unavailable' }),
+ getRuntimeId: vi.fn().mockReturnValue('runtime-1')
}
registerRuntimeHandlers(runtime as never)
@@ -46,4 +47,33 @@ describe('registerRuntimeHandlers', () => {
expect(runtime.syncWindowGraph).toHaveBeenCalledWith(17, { tabs: [], leaves: [] })
expect(result).toEqual({ graphStatus: 'ready' })
})
+
+ it('routes generic local runtime RPC calls through the dispatcher', async () => {
+ const runtime = {
+ syncWindowGraph: vi.fn(),
+ getStatus: vi.fn().mockReturnValue({
+ runtimeId: 'runtime-1',
+ rendererGraphEpoch: 0,
+ graphStatus: 'ready',
+ authoritativeWindowId: null,
+ liveTabCount: 0,
+ liveLeafCount: 0
+ }),
+ getRuntimeId: vi.fn().mockReturnValue('runtime-1')
+ }
+
+ registerRuntimeHandlers(runtime as never)
+
+ const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call')
+ expect(callRegistration).toBeTruthy()
+
+ const handler = callRegistration![1]
+ const result = await handler({ sender: {} }, { method: 'status.get' })
+
+ expect(result).toMatchObject({
+ ok: true,
+ result: { runtimeId: 'runtime-1', graphStatus: 'ready' },
+ _meta: { runtimeId: 'runtime-1' }
+ })
+ })
})
diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts
index 14a4de52ffb..d6c91e9e96e 100644
--- a/src/main/ipc/runtime.ts
+++ b/src/main/ipc/runtime.ts
@@ -1,10 +1,13 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime-types'
+import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
+import { RpcDispatcher } from '../runtime/rpc/dispatcher'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
+ ipcMain.removeHandler('runtime:call')
ipcMain.handle(
'runtime:syncWindowGraph',
@@ -21,6 +24,21 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
return runtime.getStatus()
})
+ ipcMain.handle(
+ 'runtime:call',
+ async (
+ _event,
+ args: { method: string; params?: unknown }
+ ): Promise> => {
+ return (await new RpcDispatcher({ runtime }).dispatch({
+ id: 'desktop-ipc',
+ authToken: 'desktop-ipc',
+ method: args.method,
+ params: args.params
+ })) as RuntimeRpcResponse
+ }
+ )
+
ipcMain.removeHandler('runtime:getTerminalFitOverrides')
ipcMain.handle(
'runtime:getTerminalFitOverrides',
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 948297d004b..066c39d981e 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -108,7 +108,7 @@ async function ensureUniqueRemoteName(repoPath: string, preferred: string): Prom
throw new Error(`Could not find an available remote name for ${preferred}.`)
}
-async function prepareWorktreePushTarget(
+export async function prepareWorktreePushTarget(
repoPath: string,
target: GitPushTarget
): Promise {
@@ -138,7 +138,7 @@ async function prepareWorktreePushTarget(
}
}
-async function configureCreatedWorktreePushTarget(
+export async function configureCreatedWorktreePushTarget(
worktreePath: string,
branchName: string,
target: GitPushTarget
diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts
index a7219b84500..10d278088a8 100644
--- a/src/main/powershell-osc133-bootstrap.test.ts
+++ b/src/main/powershell-osc133-bootstrap.test.ts
@@ -13,10 +13,13 @@ describe('PowerShell OSC 133 bootstrap', () => {
expect(script).toContain('ORCA_PI_CODING_AGENT_DIR')
expect(script).toContain('function Global:prompt')
expect(script).toContain('function Global:PSConsoleHostReadLine')
- expect(script).toContain('`e]133;D;$fakeExitCode`a')
- expect(script).toContain('`e]133;A`a')
- expect(script).toContain('`e]133;B`a')
- expect(script).toContain('`e]133;C`a')
+ expect(script).toContain('Esc = [char]27')
+ expect(script).toContain('Bel = [char]7')
+ expect(script).toContain(')]133;D;$fakeExitCode$(')
+ expect(script).toContain(')]133;A$(')
+ expect(script).toContain(')]133;B$(')
+ expect(script).toContain(')]133;C$(')
+ expect(script).not.toContain('`e]133')
expect(script).not.toContain('$PROFILE')
expect(script).not.toContain('ExecutionPolicy')
expect(script).not.toContain('NoProfile')
diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts
index 1784020c468..8f87ed21d2d 100644
--- a/src/main/powershell-osc133-bootstrap.ts
+++ b/src/main/powershell-osc133-bootstrap.ts
@@ -28,6 +28,8 @@ $Global:__OrcaOsc133State = @{
OriginalReadLine = $function:PSConsoleHostReadLine
HasSeenPrompt = $false
HasPSReadLine = $null -ne (Get-Module -Name PSReadLine)
+ Esc = [char]27
+ Bel = [char]7
}
function Global:prompt {
@@ -39,15 +41,15 @@ function Global:prompt {
# Emit D from prompt, not readline state. Some profile setups bypass
# PSConsoleHostReadLine; the consumer only needs completion.
if ($Global:__OrcaOsc133State.HasSeenPrompt) {
- $result += "\`e]133;D;$fakeExitCode\`a"
+ $result += "$($Global:__OrcaOsc133State.Esc)]133;D;$fakeExitCode$($Global:__OrcaOsc133State.Bel)"
}
$Global:__OrcaOsc133State.HasSeenPrompt = $true
- $result += "\`e]133;A\`a"
+ $result += "$($Global:__OrcaOsc133State.Esc)]133;A$($Global:__OrcaOsc133State.Bel)"
# Preserve the previous success/failure value for prompts that inspect it.
if ($fakeExitCode -ne 0) { Write-Error "failure" -ea ignore }
$result += $Global:__OrcaOsc133State.OriginalPrompt.Invoke()
- $result += "\`e]133;B\`a"
+ $result += "$($Global:__OrcaOsc133State.Esc)]133;B$($Global:__OrcaOsc133State.Bel)"
$result
}
@@ -55,7 +57,7 @@ if ($Global:__OrcaOsc133State.HasPSReadLine -and
$null -ne $Global:__OrcaOsc133State.OriginalReadLine) {
function Global:PSConsoleHostReadLine {
$commandLine = $Global:__OrcaOsc133State.OriginalReadLine.Invoke()
- [Console]::Write("\`e]133;C\`a")
+ [Console]::Write("$($Global:__OrcaOsc133State.Esc)]133;C$($Global:__OrcaOsc133State.Bel)")
return $commandLine
}
}
diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts
index dca8cf06bc9..8fbf9551c00 100644
--- a/src/main/providers/ssh-filesystem-provider.test.ts
+++ b/src/main/providers/ssh-filesystem-provider.test.ts
@@ -1,3 +1,6 @@
+/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback,
+SFTP binary writes, watch fan-out, and provider lifecycle tests together so
+transport parity regressions are visible in one suite. */
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshFilesystemProvider } from './ssh-filesystem-provider'
@@ -90,6 +93,64 @@ describe('SshFilesystemProvider', () => {
})
})
+ describe('writeFileBase64', () => {
+ it('writes decoded bytes through SFTP', async () => {
+ const written: Buffer[] = []
+ const writeStream = {
+ on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
+ end: vi.fn((buffer: Buffer) => {
+ written.push(buffer)
+ const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
+ closeHandler?.()
+ }),
+ destroy: vi.fn()
+ }
+ const sftp = {
+ createWriteStream: vi.fn(() => writeStream),
+ end: vi.fn()
+ }
+ provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
+
+ await provider.writeFileBase64('/home/user/logo.png', 'cG5n')
+
+ expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'wx' })
+ expect(written).toEqual([Buffer.from('png')])
+ expect(sftp.end).toHaveBeenCalled()
+ expect(mux.request).not.toHaveBeenCalledWith('fs.writeFile', expect.anything())
+ })
+
+ it('can append decoded chunks through SFTP', async () => {
+ const writeStream = {
+ on: vi.fn((_event: string, _handler: (...args: unknown[]) => void) => writeStream),
+ end: vi.fn((_buffer: Buffer) => {
+ const closeHandler = writeStream.on.mock.calls.find(([event]) => event === 'close')?.[1]
+ closeHandler?.()
+ }),
+ destroy: vi.fn()
+ }
+ const sftp = {
+ createWriteStream: vi.fn(() => writeStream),
+ end: vi.fn()
+ }
+ provider = new SshFilesystemProvider('conn-1', mux as never, async () => sftp as never)
+
+ await provider.writeFileBase64Chunk('/home/user/logo.png', 'cG5n', true)
+
+ expect(sftp.createWriteStream).toHaveBeenCalledWith('/home/user/logo.png', { flags: 'a' })
+ expect(sftp.end).toHaveBeenCalled()
+ })
+ })
+
+ describe('createDirNoClobber', () => {
+ it('sends fs.createDirNoClobber request', async () => {
+ await provider.createDirNoClobber('/home/user/new-dir')
+
+ expect(mux.request).toHaveBeenCalledWith('fs.createDirNoClobber', {
+ dirPath: '/home/user/new-dir'
+ })
+ })
+ })
+
describe('stat', () => {
it('sends fs.stat request', async () => {
const statResult = { size: 1024, type: 'file', mtime: 1234567890 }
@@ -196,6 +257,112 @@ describe('SshFilesystemProvider', () => {
expect(callback).toHaveBeenCalledWith(events)
})
+ it('fans out same-root watch events and unwatches only after the last subscriber', async () => {
+ const first = vi.fn()
+ const second = vi.fn()
+ const unsubFirst = await provider.watch('/home/user/project', first)
+ const unsubSecond = await provider.watch('/home/user/project', second)
+
+ expect(mux.request).toHaveBeenCalledTimes(1)
+ expect(mux.request).toHaveBeenCalledWith('fs.watch', { rootPath: '/home/user/project' })
+
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+ const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
+ notifHandler('fs.changed', { events })
+ expect(first).toHaveBeenCalledWith(events)
+ expect(second).toHaveBeenCalledWith(events)
+
+ unsubFirst()
+ expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
+ notifHandler('fs.changed', { events })
+ expect(second).toHaveBeenCalledTimes(2)
+
+ unsubSecond()
+ expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
+ })
+
+ it('shares an in-flight same-root watch setup across concurrent subscribers', async () => {
+ let resolveWatch: () => void = () => {}
+ mux.request.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveWatch = resolve
+ })
+ )
+ const first = vi.fn()
+ const second = vi.fn()
+
+ const firstWatch = provider.watch('/home/user/project', first)
+ const secondWatch = provider.watch('/home/user/project', second)
+
+ expect(mux.request).toHaveBeenCalledTimes(1)
+ resolveWatch()
+ const [unsubFirst, unsubSecond] = await Promise.all([firstWatch, secondWatch])
+
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+ const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
+ notifHandler('fs.changed', { events })
+ expect(first).toHaveBeenCalledWith(events)
+ expect(second).toHaveBeenCalledWith(events)
+
+ unsubFirst()
+ expect(mux.notify).not.toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
+ unsubSecond()
+ expect(mux.notify).toHaveBeenCalledWith('fs.unwatch', { rootPath: '/home/user/project' })
+ })
+
+ it('does not retain a watch listener when fs.watch setup fails', async () => {
+ mux.request.mockRejectedValueOnce(new Error('watch unavailable'))
+ const first = vi.fn()
+ await expect(provider.watch('/home/user/project', first)).rejects.toThrow('watch unavailable')
+
+ const second = vi.fn()
+ await provider.watch('/home/user/project', second)
+
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+ const events = [{ kind: 'update', absolutePath: '/home/user/project/file.ts' }]
+ notifHandler('fs.changed', { events })
+ expect(first).not.toHaveBeenCalled()
+ expect(second).toHaveBeenCalledWith(events)
+ })
+
+ it('does not forward sibling paths with matching prefixes', async () => {
+ const callback = vi.fn()
+ await provider.watch('/home/user/project', callback)
+
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+ notifHandler('fs.changed', {
+ events: [
+ { kind: 'update', absolutePath: '/home/user/project-old/file.ts' },
+ { kind: 'update', absolutePath: '/home/user/project2/file.ts' }
+ ]
+ })
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('matches Windows and UNC watch roots case-insensitively', async () => {
+ const driveCallback = vi.fn()
+ const uncCallback = vi.fn()
+ await provider.watch('C:\\Repo', driveCallback)
+ await provider.watch('//Server/Share/Repo', uncCallback)
+
+ const notifHandler = mux.onNotification.mock.calls[0][0]
+ notifHandler('fs.changed', {
+ events: [
+ { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' },
+ { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
+ ]
+ })
+
+ expect(driveCallback).toHaveBeenCalledWith([
+ { kind: 'update', absolutePath: 'c:\\repo\\src\\file.ts' }
+ ])
+ expect(uncCallback).toHaveBeenCalledWith([
+ { kind: 'update', absolutePath: '//server/share/repo/docs/readme.md' }
+ ])
+ })
+
it('sends fs.unwatch when last listener unsubscribes', async () => {
const callback = vi.fn()
const unsub = await provider.watch('/home/user/project', callback)
diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts
index d0c545d6881..b33c0dbe08b 100644
--- a/src/main/providers/ssh-filesystem-provider.ts
+++ b/src/main/providers/ssh-filesystem-provider.ts
@@ -1,7 +1,16 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
+import { uploadBuffer } from '../ssh/sftp-upload'
import type { IFilesystemProvider, FileStat, FileReadResult } from './types'
import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types'
+import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
+import type { SFTPWrapper } from 'ssh2'
+
+type SftpFactory = () => Promise
+type WatchRegistration = {
+ callbacks: Set<(events: FsChangeEvent[]) => void>
+ setupPromise: Promise
+}
export class SshFilesystemProvider implements IFilesystemProvider {
private connectionId: string
@@ -9,7 +18,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// Why: each watch() call registers for a specific rootPath, but the relay
// sends all fs.changed events on one notification channel. Keying by rootPath
// prevents cross-pollination between different worktree watchers.
- private watchListeners = new Map void>()
+ private watchListeners = new Map()
// Why: store the unsubscribe handle so dispose() can detach from the
// multiplexer. Without this, notification callbacks keep firing after
// the provider is torn down on disconnect, routing events to stale state.
@@ -19,17 +28,23 @@ export class SshFilesystemProvider implements IFilesystemProvider {
// relays get diagnosed quickly without per-read log spam.
private loggedStreamFallback = false
- constructor(connectionId: string, mux: SshChannelMultiplexer) {
+ constructor(
+ connectionId: string,
+ mux: SshChannelMultiplexer,
+ private readonly createSftp?: SftpFactory
+ ) {
this.connectionId = connectionId
this.mux = mux
this.unsubscribeNotifications = mux.onNotification((method, params) => {
if (method === 'fs.changed') {
const events = params.events as FsChangeEvent[]
- for (const [rootPath, cb] of this.watchListeners) {
- const matching = events.filter((e) => e.absolutePath.startsWith(rootPath))
+ for (const [rootPath, registration] of this.watchListeners) {
+ const matching = events.filter((e) => isPathInsideOrEqual(rootPath, e.absolutePath))
if (matching.length > 0) {
- cb(matching)
+ for (const cb of registration.callbacks) {
+ cb(matching)
+ }
}
}
}
@@ -78,6 +93,31 @@ export class SshFilesystemProvider implements IFilesystemProvider {
await this.mux.request('fs.writeFile', { filePath, content })
}
+ async writeFileBase64(filePath: string, contentBase64: string): Promise {
+ await this.writeFileBase64Chunk(filePath, contentBase64, false)
+ }
+
+ async writeFileBase64Chunk(
+ filePath: string,
+ contentBase64: string,
+ append: boolean
+ ): Promise {
+ if (!this.createSftp) {
+ throw new Error('remote_binary_upload_unavailable')
+ }
+ const sftp = await this.createSftp()
+ try {
+ // Why: relay fs.writeFile is text-only. SFTP writes the decoded bytes
+ // directly so runtime uploads do not corrupt images, PDFs, or archives.
+ await uploadBuffer(sftp, Buffer.from(contentBase64, 'base64'), filePath, {
+ append,
+ exclusive: !append
+ })
+ } finally {
+ sftp.end()
+ }
+ }
+
async stat(filePath: string): Promise {
return (await this.mux.request('fs.stat', { filePath })) as FileStat
}
@@ -94,6 +134,10 @@ export class SshFilesystemProvider implements IFilesystemProvider {
await this.mux.request('fs.createDir', { dirPath })
}
+ async createDirNoClobber(dirPath: string): Promise {
+ await this.mux.request('fs.createDirNoClobber', { dirPath })
+ }
+
async rename(oldPath: string, newPath: string): Promise {
await this.mux.request('fs.rename', { oldPath, newPath })
}
@@ -122,15 +166,41 @@ export class SshFilesystemProvider implements IFilesystemProvider {
}
async watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> {
- this.watchListeners.set(rootPath, callback)
- await this.mux.request('fs.watch', { rootPath })
+ let registration = this.watchListeners.get(rootPath)
+ if (registration) {
+ registration.callbacks.add(callback)
+ await registration.setupPromise
+ return this.createWatchUnsubscribe(rootPath, registration, callback)
+ }
+ const callbacks = new Set<(events: FsChangeEvent[]) => void>([callback])
+ const setupPromise = this.mux.request('fs.watch', { rootPath }).then(
+ () => undefined,
+ (error) => {
+ if (this.watchListeners.get(rootPath) === registration) {
+ this.watchListeners.delete(rootPath)
+ }
+ throw error
+ }
+ )
+ registration = { callbacks, setupPromise }
+ this.watchListeners.set(rootPath, registration)
+ await setupPromise
+
+ return this.createWatchUnsubscribe(rootPath, registration, callback)
+ }
+
+ private createWatchUnsubscribe(
+ rootPath: string,
+ registration: WatchRegistration,
+ callback: (events: FsChangeEvent[]) => void
+ ): () => void {
return () => {
- this.watchListeners.delete(rootPath)
- // Why: each watch() starts a @parcel/watcher on the relay for this specific
- // rootPath. We must always notify the relay to stop it, not only when all
- // watchers are gone — otherwise the remote watcher leaks inotify descriptors.
- this.mux.notify('fs.unwatch', { rootPath })
+ registration.callbacks.delete(callback)
+ if (registration.callbacks.size === 0 && this.watchListeners.get(rootPath) === registration) {
+ this.watchListeners.delete(rootPath)
+ this.mux.notify('fs.unwatch', { rootPath })
+ }
}
}
}
diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts
index 024026115eb..998c22b3f73 100644
--- a/src/main/providers/types.ts
+++ b/src/main/providers/types.ts
@@ -118,10 +118,13 @@ export type IFilesystemProvider = {
readDir(dirPath: string): Promise
readFile(filePath: string): Promise
writeFile(filePath: string, content: string): Promise
+ writeFileBase64(filePath: string, contentBase64: string): Promise
+ writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise
stat(filePath: string): Promise
deletePath(targetPath: string, recursive?: boolean): Promise
createFile(filePath: string): Promise
createDir(dirPath: string): Promise
+ createDirNoClobber(dirPath: string): Promise
rename(oldPath: string, newPath: string): Promise
copy(source: string, destination: string): Promise
realpath(filePath: string): Promise
diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts
index 0c18d639340..e04fd6ca5b4 100644
--- a/src/main/providers/windows-shell-args.test.ts
+++ b/src/main/providers/windows-shell-args.test.ts
@@ -41,8 +41,11 @@ describe('resolveWindowsShellLaunchArgs', () => {
expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(promptIndex).toBeGreaterThan(piRestoreIndex)
- expect(command).toContain('`e]133;D;$fakeExitCode`a')
- expect(command).toContain('`e]133;C`a')
+ expect(command).toContain('Esc = [char]27')
+ expect(command).toContain('Bel = [char]7')
+ expect(command).toContain(')]133;D;$fakeExitCode$(')
+ expect(command).toContain(')]133;C$(')
+ expect(command).not.toContain('`e]133')
})
it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => {
diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts
index 5ccb91123ba..2469c4810df 100644
--- a/src/main/repo-worktrees.ts
+++ b/src/main/repo-worktrees.ts
@@ -1,6 +1,7 @@
import type { GitWorktreeInfo, Repo } from '../shared/types'
import { listWorktrees } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
+import { getSshGitProvider } from './providers/ssh-git-dispatch'
export function createFolderWorktree(repo: Repo): GitWorktreeInfo {
return {
@@ -19,5 +20,12 @@ export async function listRepoWorktrees(repo: Repo): Promise
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
+ if (repo.connectionId) {
+ const provider = getSshGitProvider(repo.connectionId)
+ // Why: runtime worktree resolution can run before SSH providers have
+ // reattached during startup. Return empty instead of falling back to
+ // local git against a server path.
+ return provider ? await provider.listWorktrees(repo.path) : []
+ }
return await listWorktrees(repo.path)
}
diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts
index c925bce2b9c..5a03d05a1c1 100644
--- a/src/main/runtime/device-registry.ts
+++ b/src/main/runtime/device-registry.ts
@@ -3,15 +3,19 @@
// compromising one device doesn't expose others. The registry is a simple
// JSON file with hardened permissions matching the runtime metadata pattern.
import { randomBytes, randomUUID } from 'crypto'
-import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
+import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
+import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
const DEVICE_REGISTRY_FILENAME = 'orca-devices.json'
+export type DeviceScope = 'mobile' | 'runtime'
+
export type DeviceEntry = {
deviceId: string
name: string
token: string
+ scope: DeviceScope
pairedAt: number
lastSeenAt: number
}
@@ -25,11 +29,12 @@ export class DeviceRegistry {
this.load()
}
- addDevice(name: string): DeviceEntry {
+ addDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
const entry: DeviceEntry = {
deviceId: randomUUID(),
name,
token: randomBytes(24).toString('hex'),
+ scope,
pairedAt: Date.now(),
lastSeenAt: 0
}
@@ -44,12 +49,12 @@ export class DeviceRegistry {
// copy-button flow that encourages regeneration) leaves an orphaned token
// forever. Returns an existing never-scanned entry if present; otherwise
// mints a new one and drops any stale pending entries.
- getOrCreatePendingDevice(name: string): DeviceEntry {
- const existing = this.devices.find((d) => d.lastSeenAt === 0)
+ getOrCreatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
+ const existing = this.devices.find((d) => d.lastSeenAt === 0 && d.scope === scope)
if (existing) {
return existing
}
- return this.addDevice(name)
+ return this.addDevice(name, scope)
}
// Why: explicit rotation path for "Regenerate QR" — invalidates any
@@ -58,9 +63,9 @@ export class DeviceRegistry {
// this, getOrCreatePendingDevice keeps returning the same token forever
// until a phone actually pairs, so users have no way to revoke a leaked
// pre-pairing token.
- rotatePendingDevice(name: string): DeviceEntry {
- this.devices = this.devices.filter((d) => d.lastSeenAt !== 0)
- return this.addDevice(name)
+ rotatePendingDevice(name: string, scope: DeviceScope = 'mobile'): DeviceEntry {
+ this.devices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
+ return this.addDevice(name, scope)
}
removeDevice(deviceId: string): boolean {
@@ -73,6 +78,10 @@ export class DeviceRegistry {
return false
}
+ getDevice(deviceId: string): DeviceEntry | null {
+ return this.devices.find((d) => d.deviceId === deviceId) ?? null
+ }
+
listDevices(): readonly DeviceEntry[] {
return this.devices
}
@@ -95,14 +104,20 @@ export class DeviceRegistry {
return
}
try {
- this.devices = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
+ hardenExistingSecureFile(this.registryPath)
+ const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
+ this.devices = parsed.map((device) => ({
+ ...device,
+ // Why: older registries only existed for phone pairing. Treat missing
+ // scope as mobile so legacy device tokens do not gain new CLI powers.
+ scope: device.scope === 'runtime' ? 'runtime' : 'mobile'
+ }))
} catch {
this.devices = []
}
}
private save(): void {
- writeFileSync(this.registryPath, JSON.stringify(this.devices, null, 2), { mode: 0o600 })
- chmodSync(this.registryPath, 0o600)
+ writeSecureJsonFile(this.registryPath, this.devices)
}
}
diff --git a/src/main/runtime/e2ee-keypair.ts b/src/main/runtime/e2ee-keypair.ts
index de0373ebafe..eee61363191 100644
--- a/src/main/runtime/e2ee-keypair.ts
+++ b/src/main/runtime/e2ee-keypair.ts
@@ -1,9 +1,10 @@
// Why: the E2EE keypair enables application-layer encryption between mobile
// and desktop over plain ws://. The public key is embedded in the QR pairing
// offer so the mobile client can derive a shared secret via ECDH.
-import { existsSync, readFileSync, writeFileSync, chmodSync } from 'fs'
+import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import nacl from 'tweetnacl'
+import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
const KEYPAIR_FILENAME = 'orca-e2ee-keypair.json'
const KEYPAIR_VERSION = 1
@@ -25,6 +26,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
if (existsSync(filePath)) {
try {
+ hardenExistingSecureFile(filePath)
const raw: KeypairFile = JSON.parse(readFileSync(filePath, 'utf-8'))
if (raw.v === KEYPAIR_VERSION && raw.publicKeyB64 && raw.secretKeyB64) {
const publicKey = Uint8Array.from(Buffer.from(raw.publicKeyB64, 'base64'))
@@ -43,8 +45,7 @@ export function loadOrCreateE2EEKeypair(userDataPath: string): E2EEKeypair {
const secretKeyB64 = Buffer.from(keypair.secretKey).toString('base64')
const data: KeypairFile = { v: KEYPAIR_VERSION, publicKeyB64, secretKeyB64 }
- writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
- chmodSync(filePath, 0o600)
+ writeSecureJsonFile(filePath, data)
return { publicKey: keypair.publicKey, secretKey: keypair.secretKey, publicKeyB64 }
}
diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts
index d3a84349f2c..e25fbe22674 100644
--- a/src/main/runtime/mobile-presence-lock.test.ts
+++ b/src/main/runtime/mobile-presence-lock.test.ts
@@ -295,6 +295,27 @@ describe('mobile presence lock — multi-mobile semantics', () => {
expect(driverEvents.slice(before).every((e) => e.driver.kind === 'mobile')).toBe(true)
})
+ it('updateDesktopViewport resizes the source PTY and records desktop geometry', async () => {
+ const { runtime, ptySizes, resizes } = createRuntime()
+
+ expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(true)
+
+ expect(ptySizes.get('pty-1')).toEqual({ cols: 132, rows: 44 })
+ expect(resizes.at(-1)).toEqual({ ptyId: 'pty-1', cols: 132, rows: 44 })
+ expect(runtime.getLastRendererSize('pty-1')).toEqual({ cols: 132, rows: 44 })
+ })
+
+ it('updateDesktopViewport does not resize while mobile is driving', async () => {
+ const { runtime, ptySizes, resizes } = createRuntime()
+ await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 20 })
+ resizes.length = 0
+
+ expect(await runtime.updateDesktopViewport('pty-1', { cols: 132, rows: 44 })).toBe(false)
+
+ expect(ptySizes.get('pty-1')).toEqual({ cols: 49, rows: 20 })
+ expect(resizes).toEqual([])
+ })
+
it('updateMobileViewport then disconnect restores PTY to original baseline', async () => {
const { runtime, ptySizes } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 49, rows: 38 })
diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts
new file mode 100644
index 00000000000..ea66e39da40
--- /dev/null
+++ b/src/main/runtime/orca-runtime-browser.ts
@@ -0,0 +1,1455 @@
+/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */
+import { randomUUID } from 'crypto'
+import { ipcMain, type BrowserWindow } from 'electron'
+import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
+import type {
+ BrowserBackResult,
+ BrowserCaptureStartResult,
+ BrowserCheckResult,
+ BrowserCaptureStopResult,
+ BrowserClearResult,
+ BrowserClickResult,
+ BrowserConsoleResult,
+ BrowserCookieDeleteResult,
+ BrowserCookieGetResult,
+ BrowserCookieSetResult,
+ BrowserDetectProfilesResult,
+ BrowserDragResult,
+ BrowserEvalResult,
+ BrowserFillResult,
+ BrowserFocusResult,
+ BrowserGeolocationResult,
+ BrowserGotoResult,
+ BrowserHoverResult,
+ BrowserInterceptDisableResult,
+ BrowserInterceptEnableResult,
+ BrowserKeypressResult,
+ BrowserNetworkLogResult,
+ BrowserPdfResult,
+ BrowserProfileClearDefaultCookiesResult,
+ BrowserProfileCreateResult,
+ BrowserProfileDeleteResult,
+ BrowserProfileImportFromBrowserResult,
+ BrowserProfileListResult,
+ BrowserReloadResult,
+ BrowserScreenshotResult,
+ BrowserScrollResult,
+ BrowserSelectAllResult,
+ BrowserSelectResult,
+ BrowserSnapshotResult,
+ BrowserTabCurrentResult,
+ BrowserTabListResult,
+ BrowserTabProfileCloneResult,
+ BrowserTabProfileShowResult,
+ BrowserTabSetProfileResult,
+ BrowserTabShowResult,
+ BrowserTabSwitchResult,
+ BrowserTypeResult,
+ BrowserUploadResult,
+ BrowserViewportResult,
+ BrowserWaitResult
+} from '../../shared/runtime-types'
+import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
+import { browserManager } from '../browser/browser-manager'
+import { BrowserError } from '../browser/cdp-bridge'
+import { browserSessionRegistry } from '../browser/browser-session-registry'
+import {
+ detectInstalledBrowsers,
+ importCookiesFromBrowser,
+ selectBrowserProfile
+} from '../browser/browser-cookie-import'
+import { waitForTabRegistration } from '../ipc/browser'
+
+export type BrowserCommandTargetParams = {
+ worktree?: string
+ page?: string
+}
+
+type ResolvedBrowserCommandTarget = {
+ worktreeId?: string
+ browserPageId?: string
+}
+
+export type RuntimeBrowserCommandHost = {
+ getAgentBrowserBridge(): AgentBrowserBridge | null
+ resolveWorktreeSelector(selector: string): Promise<{ id: string }>
+ getAuthoritativeWindow(): BrowserWindow
+ getAvailableAuthoritativeWindow(): BrowserWindow | null
+}
+
+export class RuntimeBrowserCommands {
+ constructor(private readonly host: RuntimeBrowserCommandHost) {}
+
+ private requireAgentBrowserBridge(): AgentBrowserBridge {
+ const bridge = this.host.getAgentBrowserBridge()
+ if (!bridge) {
+ throw new BrowserError('browser_no_tab', 'No browser session is active')
+ }
+ return bridge
+ }
+
+ // Why: the CLI sends worktree selectors (e.g. "path:/Users/...") but the
+ // bridge stores worktreeIds in "repoId::path" format (from the renderer's
+ // Zustand store). This helper resolves the selector to the store-compatible
+ // ID so the bridge can filter tabs correctly.
+ private async resolveBrowserWorktreeId(selector?: string): Promise {
+ if (!selector) {
+ // Why: after app restart, webviews only mount when the browser pane is visible.
+ // Without --worktree, we still need to activate the view so persisted tabs
+ // become operable via registerGuest.
+ const bridge = this.host.getAgentBrowserBridge()
+ if (bridge && bridge.getRegisteredTabs().size === 0) {
+ try {
+ const win = this.host.getAuthoritativeWindow()
+ win.webContents.send('browser:activateView', {})
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ } catch {
+ // Window may not exist yet (e.g. during startup or in tests)
+ }
+ }
+ return undefined
+ }
+
+ const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id
+ // Why: explicit worktree selectors are user intent, so resolution errors
+ // must surface instead of silently widening browser routing scope. Only the
+ // activation step remains best-effort because missing windows during tests
+ // or startup should not erase the validated worktree target itself.
+ const bridge = this.host.getAgentBrowserBridge()
+ if (bridge && bridge.getRegisteredTabs(worktreeId).size === 0) {
+ try {
+ await this.ensureBrowserWorktreeActive(worktreeId)
+ } catch {
+ // Fall through with the validated worktree id so downstream routing
+ // still stays scoped to the caller's explicit selector.
+ }
+ }
+ return worktreeId
+ }
+
+ private async resolveBrowserCommandTarget(
+ params: BrowserCommandTargetParams
+ ): Promise {
+ const browserPageId =
+ typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined
+ if (!browserPageId) {
+ return {
+ worktreeId: await this.resolveBrowserWorktreeId(params.worktree)
+ }
+ }
+
+ return {
+ // Why: explicit browserPageId is already a stable tab identity, so we do
+ // not auto-resolve cwd worktree scoping on top of it. Only honor an
+ // explicit --worktree when the caller asked for that extra validation.
+ worktreeId: params.worktree
+ ? await this.resolveBrowserWorktreeId(params.worktree)
+ : undefined,
+ browserPageId
+ }
+ }
+
+ // Why: browser tabs only mount (and become operable) when their worktree is
+ // the active worktree in the renderer AND activeTabType is 'browser'. If either
+ // condition is false, the webview stays in display:none and Electron won't start
+ // its guest process — dom-ready never fires, registerGuest never runs, and CLI
+ // browser commands fail with "CDP connection refused".
+ private async ensureBrowserWorktreeActive(worktreeId: string): Promise {
+ const win = this.host.getAuthoritativeWindow()
+ const repoId = getRepoIdFromWorktreeId(worktreeId)
+ if (!repoId) {
+ return
+ }
+ win.webContents.send('ui:activateWorktree', { repoId, worktreeId })
+ // Why: switching worktree alone sets activeView='terminal'. Browser webviews
+ // won't mount until activeTabType is 'browser'. Send a second IPC to flip it.
+ win.webContents.send('browser:activateView', { worktreeId })
+ // Why: give the renderer time to mount the webview after switching worktrees.
+ // The webview needs to attach and fire dom-ready before registerGuest runs.
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ }
+
+ // Why: agent-browser drives navigation via CDP, which bypasses Electron's
+ // webview event system. The renderer's did-navigate / page-title-updated
+ // listeners never fire, leaving the Zustand store (and thus the Orca UI's
+ // address bar and tab title) stale. Push updates from main → renderer after
+ // any navigation-causing command so the UI stays in sync.
+ private notifyRendererNavigation(browserPageId: string, url: string, title: string): void {
+ try {
+ const win = this.host.getAuthoritativeWindow()
+ win.webContents.send('browser:navigation-update', { browserPageId, url, title })
+ } catch {
+ // Window may not exist during shutdown
+ }
+ }
+
+ // Why: `tabSwitch` only flips the bridge's `activeWebContentsId` — it
+ // does not surface the browser pane in the renderer. Without --focus, the
+ // switch is invisible to the user. With --focus, we send a dedicated IPC
+ // so the renderer can update its per-worktree active-tab state.
+ //
+ // Why this IPC carries `worktreeId` instead of letting the renderer
+ // dispatch `setActiveWorktree`: multiple agents drive browsers in parallel
+ // worktrees. A global focus call from agent X would steal the user's
+ // screen from agent Y's worktree. The renderer-side handler
+ // (focusBrowserTabInWorktree) updates per-worktree state unconditionally
+ // and only flips globals when the user is already on the targeted
+ // worktree. Cross-worktree --focus calls pre-stage silently.
+ private notifyRendererBrowserPaneFocus(
+ worktreeId: string | undefined,
+ browserPageId: string
+ ): void {
+ try {
+ const win = this.host.getAuthoritativeWindow()
+ win.webContents.send('browser:pane-focus', {
+ worktreeId: worktreeId ?? null,
+ browserPageId
+ })
+ } catch {
+ // Window may not exist during shutdown
+ }
+ }
+
+ async browserSnapshot(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId)
+ }
+
+ async browserClick(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const bridge = this.requireAgentBrowserBridge()
+ const result = await bridge.click(params.element, target.worktreeId, target.browserPageId)
+ // Why: clicks can trigger navigation (e.g. submitting a form, clicking a link).
+ // Read the target tab's live URL/title after the click and push to the
+ // renderer so the UI updates even when automation targeted a non-active page.
+ const page = bridge.getPageInfo(target.worktreeId, target.browserPageId)
+ if (page) {
+ this.notifyRendererNavigation(page.browserPageId, page.url, page.title)
+ }
+ return result
+ }
+
+ async browserGoto(
+ params: { url: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const bridge = this.requireAgentBrowserBridge()
+ const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId)
+ const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
+ if (pageId) {
+ this.notifyRendererNavigation(pageId, result.url, result.title)
+ }
+ return result
+ }
+
+ async browserFill(
+ params: {
+ element: string
+ value: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().fill(
+ params.element,
+ params.value,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserType(
+ params: { input: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().type(
+ params.input,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSelect(
+ params: {
+ element: string
+ value: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().select(
+ params.element,
+ params.value,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserScroll(
+ params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().scroll(
+ params.direction,
+ params.amount,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserBack(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const bridge = this.requireAgentBrowserBridge()
+ const result = await bridge.back(target.worktreeId, target.browserPageId)
+ const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
+ if (pageId) {
+ this.notifyRendererNavigation(pageId, result.url, result.title)
+ }
+ return result
+ }
+
+ async browserReload(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const bridge = this.requireAgentBrowserBridge()
+ const result = await bridge.reload(target.worktreeId, target.browserPageId)
+ const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId)
+ if (pageId) {
+ this.notifyRendererNavigation(pageId, result.url, result.title)
+ }
+ return result
+ }
+
+ async browserScreenshot(
+ params: {
+ format?: 'png' | 'jpeg'
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().screenshot(
+ params.format,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserEval(
+ params: { expression: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().evaluate(
+ params.expression,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserTabList(params: { worktree?: string }): Promise {
+ const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
+ const result = this.requireAgentBrowserBridge().tabList(worktreeId)
+ return {
+ tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab))
+ }
+ }
+
+ async browserTabShow(params: { page: string; worktree?: string }): Promise {
+ const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
+ return { tab: this.describeBrowserTab(params.page, worktreeId) }
+ }
+
+ async browserTabCurrent(params: { worktree?: string }): Promise {
+ const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
+ const browserPageId = this.requireAgentBrowserBridge().getActivePageId(worktreeId)
+ if (!browserPageId) {
+ throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
+ }
+ return { tab: this.describeBrowserTab(browserPageId, worktreeId) }
+ }
+
+ async browserTabSwitch(
+ params: {
+ index?: number
+ focus?: boolean
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const bridge = this.requireAgentBrowserBridge()
+ const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId)
+ if (params.focus) {
+ // Why: prefer the explicit --worktree the caller passed; fall back to
+ // the bridge's owning-worktree map for the just-switched tab. The
+ // owning worktree is what the renderer needs to scope the focus to.
+ // The renderer NEVER yanks the user across worktrees on this signal
+ // (see focusBrowserTabInWorktree).
+ const worktreeId =
+ target.worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined
+ this.notifyRendererBrowserPaneFocus(worktreeId, result.browserPageId)
+ }
+ return result
+ }
+
+ async browserHover(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().hover(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserDrag(
+ params: {
+ from: string
+ to: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().drag(
+ params.from,
+ params.to,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserUpload(
+ params: { element: string; files: string[] } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().upload(
+ params.element,
+ params.files,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserWait(
+ params: {
+ selector?: string
+ timeout?: number
+ text?: string
+ url?: string
+ load?: string
+ fn?: string
+ state?: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ const { worktree: _, page: __, ...options } = params
+ return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId)
+ }
+
+ async browserCheck(
+ params: { element: string; checked: boolean } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().check(
+ params.element,
+ params.checked,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserFocus(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().focus(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserClear(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().clear(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSelectAll(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().selectAll(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserKeypress(
+ params: { key: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().keypress(
+ params.key,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserPdf(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId)
+ }
+
+ async browserFullScreenshot(
+ params: {
+ format?: 'png' | 'jpeg'
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().fullPageScreenshot(
+ params.format,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Cookie management ──
+
+ async browserCookieGet(
+ params: { url?: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().cookieGet(
+ params.url,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserCookieSet(
+ params: {
+ name: string
+ value: string
+ domain?: string
+ path?: string
+ secure?: boolean
+ httpOnly?: boolean
+ sameSite?: string
+ expires?: number
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().cookieSet(
+ params,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserCookieDelete(
+ params: {
+ name: string
+ domain?: string
+ url?: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().cookieDelete(
+ params.name,
+ params.domain,
+ params.url,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Viewport ──
+
+ async browserSetViewport(
+ params: {
+ width: number
+ height: number
+ deviceScaleFactor?: number
+ mobile?: boolean
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setViewport(
+ params.width,
+ params.height,
+ params.deviceScaleFactor,
+ params.mobile,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Geolocation ──
+
+ async browserSetGeolocation(
+ params: {
+ latitude: number
+ longitude: number
+ accuracy?: number
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setGeolocation(
+ params.latitude,
+ params.longitude,
+ params.accuracy,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Request interception ──
+
+ async browserInterceptEnable(
+ params: {
+ patterns?: string[]
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().interceptEnable(
+ params.patterns,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserInterceptDisable(
+ params: BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().interceptDisable(
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId)
+ }
+
+ // ── Console/network capture ──
+
+ async browserCaptureStart(
+ params: BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId)
+ }
+
+ async browserCaptureStop(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId)
+ }
+
+ async browserConsoleLog(
+ params: { limit?: number } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().consoleLog(
+ params.limit,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserNetworkLog(
+ params: { limit?: number } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().networkLog(
+ params.limit,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Additional core commands ──
+
+ async browserDblclick(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().dblclick(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserForward(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId)
+ }
+
+ async browserScrollIntoView(
+ params: { element: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().scrollIntoView(
+ params.element,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserGet(
+ params: {
+ what: string
+ selector?: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().get(
+ params.what,
+ params.selector,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserIs(
+ params: { what: string; selector: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().is(
+ params.what,
+ params.selector,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Keyboard insert text ──
+
+ async browserKeyboardInsertText(
+ params: { text: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().keyboardInsertText(
+ params.text,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Mouse commands ──
+
+ async browserMouseMove(
+ params: { x: number; y: number } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().mouseMove(
+ params.x,
+ params.y,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserMouseDown(
+ params: { button?: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().mouseDown(
+ params.button,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().mouseUp(
+ params.button,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserMouseWheel(
+ params: {
+ dy: number
+ dx?: number
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().mouseWheel(
+ params.dy,
+ params.dx,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Find (semantic locators) ──
+
+ async browserFind(
+ params: {
+ locator: string
+ value: string
+ action: string
+ text?: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().find(
+ params.locator,
+ params.value,
+ params.action,
+ params.text,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Set commands ──
+
+ async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setDevice(
+ params.name,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSetOffline(
+ params: { state?: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setOffline(
+ params.state,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSetHeaders(
+ params: { headers: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setHeaders(
+ params.headers,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSetCredentials(
+ params: {
+ user: string
+ pass: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setCredentials(
+ params.user,
+ params.pass,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserSetMedia(
+ params: {
+ colorScheme?: string
+ reducedMotion?: string
+ } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().setMedia(
+ params.colorScheme,
+ params.reducedMotion,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Clipboard commands ──
+
+ async browserClipboardRead(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId)
+ }
+
+ async browserClipboardWrite(
+ params: { text: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().clipboardWrite(
+ params.text,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ // ── Dialog commands ──
+
+ async browserDialogAccept(
+ params: { text?: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().dialogAccept(
+ params.text,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserDialogDismiss(params: BrowserCommandTargetParams): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId)
+ }
+
+ // ── Storage commands ──
+
+ async browserStorageLocalGet(
+ params: { key: string } & BrowserCommandTargetParams
+ ): Promise {
+ const target = await this.resolveBrowserCommandTarget(params)
+ return this.requireAgentBrowserBridge().storageLocalGet(
+ params.key,
+ target.worktreeId,
+ target.browserPageId
+ )
+ }
+
+ async browserStorageLocalSet(
+ params: {
+ key: string
+ value: string
+ } & BrowserCommandTargetParams
+ ): Promise