Files
orca/src/main/runtime/orca-runtime.ts
T
Jinwoo-HandOrca fd5d4374c3 feat(mobile): add Expo mobile app with QR pairing and WebSocket RPC transport
Adds the Orca Mobile companion app (Expo SDK 55 / React Native 0.83) with:
- QR code pairing flow: scan orca://pair# URL, validate auth, save host profile
- WebSocket RPC client with auto-reconnect, exponential backoff, streaming support
- Three screens: host list, worktree list, terminal session with live output
- Mock WebSocket server for development without a running Orca desktop instance
- Desktop-side: TLS certificate management, device registry, mobile IPC handlers,
  WebSocket transport layer, and pairing offer encode/decode with Zod validation
- Unit tests for WebSocket transport and pairing codec

Co-authored-by: Orca <help@stably.ai>
2026-04-27 20:02:21 -04:00

2831 lines
94 KiB
TypeScript

/* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */
/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */
/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */
import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection'
import type { AgentStatus } from '../../shared/agent-detection'
import { gitExecFileAsync, gitExecFileSync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { rm } from 'fs/promises'
import type { CreateWorktreeResult, Repo } from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
RuntimeTerminalRead,
RuntimeTerminalRename,
RuntimeTerminalSend,
RuntimeTerminalCreate,
RuntimeTerminalSplit,
RuntimeTerminalFocus,
RuntimeTerminalClose,
RuntimeTerminalListResult,
RuntimeTerminalState,
RuntimeStatus,
RuntimeTerminalWait,
RuntimeTerminalWaitCondition,
RuntimeWorktreePsSummary,
RuntimeTerminalShow,
RuntimeTerminalSummary,
RuntimeSyncedLeaf,
RuntimeSyncedTab,
RuntimeSyncWindowGraph,
RuntimeWorktreeListResult,
BrowserSnapshotResult,
BrowserClickResult,
BrowserGotoResult,
BrowserFillResult,
BrowserTypeResult,
BrowserSelectResult,
BrowserScrollResult,
BrowserBackResult,
BrowserReloadResult,
BrowserScreenshotResult,
BrowserEvalResult,
BrowserTabListResult,
BrowserTabSwitchResult,
BrowserHoverResult,
BrowserDragResult,
BrowserUploadResult,
BrowserWaitResult,
BrowserCheckResult,
BrowserFocusResult,
BrowserClearResult,
BrowserSelectAllResult,
BrowserKeypressResult,
BrowserPdfResult,
BrowserCookieGetResult,
BrowserCookieSetResult,
BrowserCookieDeleteResult,
BrowserViewportResult,
BrowserGeolocationResult,
BrowserInterceptEnableResult,
BrowserInterceptDisableResult,
BrowserCaptureStartResult,
BrowserCaptureStopResult,
BrowserConsoleResult,
BrowserNetworkLogResult
} from '../../shared/runtime-types'
import { BrowserWindow, ipcMain } from 'electron'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import { BrowserError } from '../browser/cdp-bridge'
import { waitForTabRegistration } from '../ipc/browser'
import { getPRForBranch } from '../github/client'
import {
getGitUsername,
getDefaultBaseRef,
getBranchConflictKind,
isGitRepo,
getRepoName,
searchBaseRefs
} from '../git/repo'
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
import { REPO_COLORS } from '../../shared/constants'
import { listRepoWorktrees } from '../repo-worktrees'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { AgentDetector } from '../stats/agent-detector'
import {
computeBranchName,
computeWorktreePath,
ensurePathWithinWorkspace,
formatWorktreeRemovalError,
isOrphanedWorktreeError,
mergeWorktree,
sanitizeWorktreeName,
shouldSetDisplayName,
areWorktreePathsEqual
} from '../ipc/worktree-logic'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
type RuntimeStore = {
getRepos: Store['getRepos']
getRepo: Store['getRepo']
addRepo: Store['addRepo']
updateRepo: Store['updateRepo']
getAllWorktreeMeta: Store['getAllWorktreeMeta']
getWorktreeMeta: Store['getWorktreeMeta']
setWorktreeMeta: Store['setWorktreeMeta']
removeWorktreeMeta: Store['removeWorktreeMeta']
getSettings(): {
workspaceDir: string
nestWorkspaces: boolean
refreshLocalBaseRefOnWorktreeCreate: boolean
branchPrefix: string
branchPrefixCustom: string
}
}
type RuntimeLeafRecord = RuntimeSyncedLeaf & {
ptyGeneration: number
connected: boolean
writable: boolean
lastOutputAt: number | null
lastExitCode: number | null
tailBuffer: string[]
tailPartialLine: string
tailTruncated: boolean
tailLinesTotal: number
preview: string
lastAgentStatus: AgentStatus | null
}
type RuntimePtyController = {
write(ptyId: string, data: string): boolean
kill(ptyId: string): boolean
}
type RuntimeNotifier = {
worktreesChanged(repoId: string): void
reposChanged(): void
activateWorktree(repoId: string, worktreeId: string, setup?: CreateWorktreeResult['setup']): void
createTerminal(worktreeId: string, opts: { command?: string; title?: string }): void
splitTerminal(
tabId: string,
paneRuntimeId: number,
opts: { direction: 'horizontal' | 'vertical'; command?: string }
): void
renameTerminal(tabId: string, title: string | null): void
focusTerminal(tabId: string, worktreeId: string): void
closeTerminal(tabId: string, paneRuntimeId?: number): void
}
type TerminalHandleRecord = {
handle: string
runtimeId: string
rendererGraphEpoch: number
worktreeId: string
tabId: string
leafId: string
ptyId: string | null
ptyGeneration: number
}
type TerminalWaiter = {
handle: string
condition: RuntimeTerminalWaitCondition
resolve: (result: RuntimeTerminalWait) => void
reject: (error: Error) => void
timeout: NodeJS.Timeout | null
}
type ResolvedWorktree = {
id: string
repoId: string
path: string
branch: string
linkedIssue: number | null
git: {
path: string
head: string
branch: string
isBare: boolean
isMainWorktree: boolean
}
displayName: string
comment: string
}
type BrowserCommandTargetParams = {
worktree?: string
page?: string
}
type ResolvedBrowserCommandTarget = {
worktreeId?: string
browserPageId?: string
}
type ResolvedWorktreeCache = {
expiresAt: number
worktrees: ResolvedWorktree[]
}
export class OrcaRuntimeService {
private readonly runtimeId = randomUUID()
private readonly startedAt = Date.now()
private readonly store: RuntimeStore | null
private rendererGraphEpoch = 0
private graphStatus: RuntimeGraphStatus = 'unavailable'
private authoritativeWindowId: number | null = null
private tabs = new Map<string, RuntimeSyncedTab>()
private leaves = new Map<string, RuntimeLeafRecord>()
private handles = new Map<string, TerminalHandleRecord>()
private handleByLeafKey = new Map<string, string>()
private graphSyncCallbacks: (() => void)[] = []
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
private ptyController: RuntimePtyController | null = null
private notifier: RuntimeNotifier | null = null
private agentBrowserBridge: AgentBrowserBridge | null = null
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
private agentDetector: AgentDetector | null = null
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
// These listeners fire on every onPtyData call, enabling real-time streaming
// without polling. Keyed by ptyId for O(1) lookup per data event.
private dataListeners = new Map<string, Set<(data: string) => void>>()
private subscriptionCleanups = new Map<string, () => void>()
constructor(store: RuntimeStore | null = null, stats?: StatsCollector) {
this.store = store
if (stats) {
this.agentDetector = new AgentDetector(stats)
}
}
getRuntimeId(): string {
return this.runtimeId
}
getStartedAt(): number {
return this.startedAt
}
getStatus(): RuntimeStatus {
return {
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
graphStatus: this.graphStatus,
authoritativeWindowId: this.authoritativeWindowId,
liveTabCount: this.tabs.size,
liveLeafCount: this.leaves.size
}
}
setPtyController(controller: RuntimePtyController | null): void {
// Why: CLI terminal writes must go through the main-owned PTY registry
// instead of tunneling back through renderer IPC, or live handles could
// drift from the process they are supposed to control during reloads.
this.ptyController = controller
}
setNotifier(notifier: RuntimeNotifier | null): void {
this.notifier = notifier
}
setAgentBrowserBridge(bridge: AgentBrowserBridge | null): void {
this.agentBrowserBridge = bridge
}
getAgentBrowserBridge(): AgentBrowserBridge | null {
return this.agentBrowserBridge
}
attachWindow(windowId: number): void {
if (this.authoritativeWindowId === null) {
this.authoritativeWindowId = windowId
}
}
syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeStatus {
if (this.authoritativeWindowId === null) {
this.authoritativeWindowId = windowId
}
if (windowId !== this.authoritativeWindowId) {
throw new Error('Runtime graph publisher does not match the authoritative window')
}
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
const nextLeaves = new Map<string, RuntimeLeafRecord>()
for (const leaf of graph.leaves) {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const existing = this.leaves.get(leafKey)
const ptyGeneration =
existing && existing.ptyId !== leaf.ptyId
? existing.ptyGeneration + 1
: (existing?.ptyGeneration ?? 0)
nextLeaves.set(leafKey, {
...leaf,
ptyGeneration,
connected: leaf.ptyId !== null,
writable: this.graphStatus === 'ready' && leaf.ptyId !== null,
lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null,
lastExitCode: existing?.ptyId === leaf.ptyId ? existing.lastExitCode : null,
tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [],
tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '',
tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false,
tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0,
preview: existing?.ptyId === leaf.ptyId ? existing.preview : '',
lastAgentStatus: existing?.ptyId === leaf.ptyId ? existing.lastAgentStatus : null
})
if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) {
this.invalidateLeafHandle(leafKey)
}
}
for (const oldLeafKey of this.leaves.keys()) {
if (!nextLeaves.has(oldLeafKey)) {
this.invalidateLeafHandle(oldLeafKey)
}
}
this.leaves = nextLeaves
this.graphStatus = 'ready'
this.refreshWritableFlags()
// Why: createTerminal waits for the renderer's graph sync to populate the
// new leaf so it can return a handle. Drain callbacks after leaves update.
for (const cb of [...this.graphSyncCallbacks]) {
cb()
}
return this.getStatus()
}
onPtySpawned(ptyId: string): void {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId === ptyId) {
leaf.connected = true
leaf.writable = this.graphStatus === 'ready'
}
}
}
onPtyData(ptyId: string, data: string, at: number): void {
// Agent detection runs on raw data before leaf processing, since the
// tail buffer logic normalizes away the OSC sequences we need.
this.agentDetector?.onData(ptyId, data, at)
// Why: extract OSC title from raw PTY data before tail-buffer processing
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
// announce status via OSC 0/1/2 title sequences — this is the same
// detection path the renderer uses for notifications and sidebar badges.
const oscTitle = extractLastOscTitle(data)
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
for (const leaf of this.leaves.values()) {
if (leaf.ptyId !== ptyId) {
continue
}
leaf.connected = true
leaf.writable = this.graphStatus === 'ready'
leaf.lastOutputAt = at
const nextTail = appendToTailBuffer(leaf.tailBuffer, leaf.tailPartialLine, data)
leaf.tailBuffer = nextTail.lines
leaf.tailPartialLine = nextTail.partialLine
leaf.tailTruncated = leaf.tailTruncated || nextTail.truncated
leaf.tailLinesTotal += nextTail.newCompleteLines
leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine)
if (agentStatus !== null) {
const prevStatus = leaf.lastAgentStatus
leaf.lastAgentStatus = agentStatus
// Why: resolve tui-idle waiters only on working→idle, not working→permission.
// Permission means the agent is blocked on user approval (e.g. Gemini's
// "y/n" prompt) — it hasn't finished its task. Resolving tui-idle here
// would cause the CLI consumer to proceed while the agent is still waiting.
if (prevStatus === 'working' && agentStatus === 'idle') {
this.resolveTuiIdleWaiters(leaf)
}
}
}
const listeners = this.dataListeners.get(ptyId)
if (listeners) {
for (const listener of listeners) {
listener(data)
}
}
}
subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void {
let listeners = this.dataListeners.get(ptyId)
if (!listeners) {
listeners = new Set()
this.dataListeners.set(ptyId, listeners)
}
listeners.add(listener)
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
this.dataListeners.delete(ptyId)
}
}
}
resolveLeafForHandle(handle: string): { ptyId: string | null } | null {
const record = this.handles.get(handle)
if (!record) {
return null
}
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
if (!leaf) {
return null
}
return { ptyId: leaf.ptyId }
}
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
this.subscriptionCleanups.set(subscriptionId, cleanup)
}
cleanupSubscription(subscriptionId: string): void {
const cleanup = this.subscriptionCleanups.get(subscriptionId)
if (cleanup) {
this.subscriptionCleanups.delete(subscriptionId)
cleanup()
}
}
onPtyExit(ptyId: string, exitCode: number): void {
this.agentDetector?.onExit(ptyId)
for (const leaf of this.leaves.values()) {
if (leaf.ptyId !== ptyId) {
continue
}
leaf.connected = false
leaf.writable = false
leaf.lastExitCode = exitCode
this.resolveExitWaiters(leaf)
}
}
async listTerminals(
worktreeSelector?: string,
limit = DEFAULT_TERMINAL_LIST_LIMIT
): Promise<RuntimeTerminalListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const graphEpoch = this.captureReadyGraphEpoch()
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
const terminals: RuntimeTerminalSummary[] = []
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
}
terminals.push(this.buildTerminalSummary(leaf, worktreesById))
}
return {
terminals: terminals.slice(0, limit),
totalCount: terminals.length,
truncated: terminals.length > limit
}
}
// Why: when --terminal is omitted, the CLI auto-resolves to the active
// terminal in the current worktree — matching browser's implicit active tab.
async resolveActiveTerminal(worktreeSelector?: string): Promise<string> {
this.assertGraphReady()
const targetWorktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: null
// Prefer the tab's activeLeafId — this is the pane the user last focused
for (const tab of this.tabs.values()) {
if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) {
continue
}
if (!tab.activeLeafId) {
continue
}
const leafKey = this.getLeafKey(tab.tabId, tab.activeLeafId)
const leaf = this.leaves.get(leafKey)
if (leaf) {
return this.issueHandle(leaf)
}
}
// Fallback: any leaf in the target worktree
for (const leaf of this.leaves.values()) {
if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) {
continue
}
return this.issueHandle(leaf)
}
throw new Error('no_active_terminal')
}
async showTerminal(handle: string): Promise<RuntimeTerminalShow> {
const graphEpoch = this.captureReadyGraphEpoch()
const worktreesById = await this.getResolvedWorktreeMap()
this.assertStableReadyGraph(graphEpoch)
const { leaf } = this.getLiveLeafForHandle(handle)
const summary = this.buildTerminalSummary(leaf, worktreesById)
return {
...summary,
paneRuntimeId: leaf.paneRuntimeId,
ptyId: leaf.ptyId,
rendererGraphEpoch: this.rendererGraphEpoch
}
}
async readTerminal(handle: string, opts: { cursor?: number } = {}): Promise<RuntimeTerminalRead> {
const { leaf } = this.getLiveLeafForHandle(handle)
const allLines = buildTailLines(leaf.tailBuffer, leaf.tailPartialLine)
let tail: string[]
let truncated: boolean
if (typeof opts.cursor === 'number' && opts.cursor >= 0) {
// Why: the buffer only retains the last MAX_TAIL_LINES lines. If the
// caller's cursor points to lines that were already evicted, we can only
// return what's still in memory and mark truncated=true to signal the gap.
const bufferStart = leaf.tailLinesTotal - leaf.tailBuffer.length
const sliceFrom = Math.max(0, opts.cursor - bufferStart)
// Why: cursor-based reads return only completed lines, excluding the
// trailing partial line. Including the partial would cause duplication:
// the consumer sees "hel" now, then "hello\n" on the next read after
// the line completes — same content delivered twice.
tail = leaf.tailBuffer.slice(sliceFrom)
truncated = opts.cursor < bufferStart
} else {
tail = allLines
// Why: Orca does not have a truthful main-owned screen model yet,
// especially for hidden panes. Focused v1 therefore returns the bounded
// tail lines directly instead of duplicating the same text in a fake
// screen field that would waste agent tokens.
truncated = leaf.tailTruncated
}
return {
handle,
status: getTerminalState(leaf),
tail,
truncated,
// Why: cursors advance by completed lines only. If we count the current
// partial line here, later reads can skip continued output on that same
// line because no new complete line was emitted yet.
nextCursor: String(leaf.tailLinesTotal)
}
}
async sendTerminal(
handle: string,
action: {
text?: string
enter?: boolean
interrupt?: boolean
}
): Promise<RuntimeTerminalSend> {
const { leaf } = this.getLiveLeafForHandle(handle)
if (!leaf.writable || !leaf.ptyId) {
throw new Error('terminal_not_writable')
}
const payload = buildSendPayload(action)
if (payload === null) {
throw new Error('invalid_terminal_send')
}
const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
return {
handle,
accepted: true,
bytesWritten: Buffer.byteLength(payload, 'utf8')
}
}
async waitForTerminal(
handle: string,
options?: {
condition?: RuntimeTerminalWaitCondition
timeoutMs?: number
}
): Promise<RuntimeTerminalWait> {
const condition = options?.condition ?? 'exit'
const { leaf } = this.getLiveLeafForHandle(handle)
if (condition === 'exit' && getTerminalState(leaf) === 'exited') {
return buildTerminalWaitResult(handle, condition, leaf)
}
// Why: if the agent already transitioned to idle (or permission) before the
// waiter was registered, resolve immediately. This uses the same OSC title
// detection that powers the renderer's "Task complete" notifications.
// Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the
// agent is blocked on user approval, not finished with its task.
if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') {
// Why: reset so the next `wait --for tui-idle` blocks until a fresh
// working→idle transition instead of resolving instantly from a stale
// status left over from a previous agent session.
leaf.lastAgentStatus = null
return buildTerminalWaitResult(handle, condition, leaf)
}
return await new Promise<RuntimeTerminalWait>((resolve, reject) => {
// Why: tui-idle depends on OSC title transitions from a recognized agent.
// If no agent is detected, the waiter would hang forever. Enforce a default
// timeout so unsupported CLIs fail predictably instead of silently blocking.
const effectiveTimeoutMs =
typeof options?.timeoutMs === 'number' && options.timeoutMs > 0
? options.timeoutMs
: condition === 'tui-idle'
? TUI_IDLE_DEFAULT_TIMEOUT_MS
: 0
const waiter: TerminalWaiter = {
handle,
condition,
resolve,
reject,
timeout: null
}
if (effectiveTimeoutMs > 0) {
waiter.timeout = setTimeout(() => {
this.removeWaiter(waiter)
reject(new Error('timeout'))
}, effectiveTimeoutMs)
}
let waiters = this.waitersByHandle.get(handle)
if (!waiters) {
waiters = new Set()
this.waitersByHandle.set(handle, waiters)
}
waiters.add(waiter)
// Why: the handle may go stale or exit in the small gap between the first
// validation and waiter registration. Re-checking here keeps wait --for
// exit honest instead of hanging on a terminal that already changed.
try {
const live = this.getLiveLeafForHandle(handle)
if (getTerminalState(live.leaf) === 'exited') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') {
live.leaf.lastAgentStatus = null
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
}
} catch (error) {
this.removeWaiter(waiter)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
async getWorktreePs(limit = DEFAULT_WORKTREE_PS_LIMIT): Promise<{
worktrees: RuntimeWorktreePsSummary[]
totalCount: number
truncated: boolean
}> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const resolvedWorktrees = await this.listResolvedWorktrees()
const repoById = new Map((this.store?.getRepos() ?? []).map((repo) => [repo.id, repo]))
const summaries = new Map<string, RuntimeWorktreePsSummary>()
for (const worktree of resolvedWorktrees) {
const meta =
this.store?.getWorktreeMeta?.(worktree.id) ?? this.store?.getAllWorktreeMeta()[worktree.id]
summaries.set(worktree.id, {
worktreeId: worktree.id,
repoId: worktree.repoId,
repo: repoById.get(worktree.repoId)?.displayName ?? worktree.repoId,
path: worktree.path,
branch: worktree.branch,
linkedIssue: worktree.linkedIssue,
unread: meta?.isUnread ?? false,
liveTerminalCount: 0,
hasAttachedPty: false,
lastOutputAt: null,
preview: ''
})
}
for (const leaf of this.leaves.values()) {
const summary = summaries.get(leaf.worktreeId)
if (!summary) {
continue
}
const previousLastOutputAt = summary.lastOutputAt
summary.liveTerminalCount += 1
summary.hasAttachedPty = summary.hasAttachedPty || leaf.connected
summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt)
if (
leaf.preview &&
(summary.preview.length === 0 || (leaf.lastOutputAt ?? -1) >= (previousLastOutputAt ?? -1))
) {
summary.preview = leaf.preview
}
}
const sorted = [...summaries.values()].sort(compareWorktreePs)
return {
worktrees: sorted.slice(0, limit),
totalCount: sorted.length,
truncated: sorted.length > limit
}
}
listRepos(): Repo[] {
return this.store?.getRepos() ?? []
}
async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
if (kind === 'git' && !isGitRepo(path)) {
throw new Error(`Not a valid git repository: ${path}`)
}
const existing = this.store.getRepos().find((repo) => repo.path === path)
if (existing) {
return existing
}
const repo: Repo = {
id: randomUUID(),
path,
displayName: getRepoName(path),
badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length],
addedAt: Date.now(),
kind
}
this.store.addRepo(repo)
this.invalidateResolvedWorktreeCache()
this.notifier?.reposChanged()
return this.store.getRepo(repo.id) ?? repo
}
async showRepo(repoSelector: string): Promise<Repo> {
return await this.resolveRepoSelector(repoSelector)
}
async setRepoBaseRef(repoSelector: string, baseRef: string): Promise<Repo> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const repo = await this.resolveRepoSelector(repoSelector)
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support base refs.')
}
const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef })
if (!updated) {
throw new Error('repo_not_found')
}
this.invalidateResolvedWorktreeCache()
this.notifier?.reposChanged()
return updated
}
async searchRepoRefs(
repoSelector: string,
query: string,
limit = DEFAULT_REPO_SEARCH_REFS_LIMIT
): Promise<RuntimeRepoSearchRefs> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const repo = await this.resolveRepoSelector(repoSelector)
if (isFolderRepo(repo)) {
return {
refs: [],
truncated: false
}
}
const refs = await searchBaseRefs(repo.path, query, limit + 1)
return {
refs: refs.slice(0, limit),
truncated: refs.length > limit
}
}
async listManagedWorktrees(
repoSelector?: string,
limit = DEFAULT_WORKTREE_LIST_LIMIT
): Promise<RuntimeWorktreeListResult> {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const resolved = await this.listResolvedWorktrees()
const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null
const worktrees = resolved.filter((worktree) => !repoId || worktree.repoId === repoId)
return {
worktrees: worktrees.slice(0, limit),
totalCount: worktrees.length,
truncated: worktrees.length > limit
}
}
async showManagedWorktree(worktreeSelector: string) {
return await this.resolveWorktreeSelector(worktreeSelector)
}
async createManagedWorktree(args: {
repoSelector: string
name: string
baseBranch?: string
linkedIssue?: number | null
comment?: string
}): Promise<CreateWorktreeResult> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const repo = await this.resolveRepoSelector(args.repoSelector)
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support creating worktrees.')
}
const settings = this.store.getSettings()
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const username = getGitUsername(repo.path)
const branchName = computeBranchName(sanitizedName, settings, username)
const branchConflictKind = await getBranchConflictKind(repo.path, branchName)
if (branchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}.`
)
}
let existingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
try {
existingPR = await getPRForBranch(repo.path, branchName)
} catch {
// Why: worktree creation should not hard-fail on transient GitHub reachability
// issues because git state is still the source of truth for whether the
// worktree can be created locally.
}
if (existingPR) {
throw new Error(`Branch "${branchName}" already has PR #${existingPR.number}.`)
}
let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings)
// Why: CLI-managed WSL worktrees live under ~/orca/workspaces inside the
// distro filesystem. If home lookup fails, still validate against the
// configured workspace dir so the traversal guard is never bypassed.
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
if (!baseBranch) {
// Why: getDefaultBaseRef returns null when no suitable ref exists.
// Don't fabricate 'origin/main' — passing it to addWorktree would
// produce an opaque git failure. Surface a clear error so the CLI
// caller can pick an explicit --base ref.
throw new Error(
'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.'
)
}
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
try {
gitExecFileSync(['fetch', remote], { cwd: repo.path })
} catch {
// Why: matching the editor behavior keeps CLI creation usable offline.
}
await addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
const gitWorktrees = await listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath))
if (!created) {
throw new Error('Worktree created but not found in listing')
}
const worktreeId = `${repo.id}::${created.path}`
const meta = this.store.setWorktreeMeta(worktreeId, {
lastActivityAt: Date.now(),
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.comment !== undefined ? { comment: args.comment } : {})
})
const worktree = mergeWorktree(repo.id, created, meta)
let setup: CreateWorktreeResult['setup']
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.setup) {
if (this.authoritativeWindowId !== null) {
try {
// Why: CLI-created worktrees must use the same runner-script path as the
// renderer create flow so repo-committed `orca.yaml` setup hooks run in
// the visible first terminal instead of a hidden background shell with
// different failure and prompt behavior.
setup = createSetupRunnerScript(repo, worktreePath, hooks.scripts.setup)
} catch (error) {
// Why: the git worktree is already real at this point. If runner
// generation fails, keep creation successful and surface the problem in
// logs rather than pretending the worktree was never created.
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
}
} else {
void runHook('setup', worktreePath, repo).then((result) => {
if (!result.success) {
console.error(`[hooks] setup hook failed for ${worktreePath}:`, result.output)
}
})
}
}
this.notifier?.worktreesChanged(repo.id)
// Why: the editor currently creates the first Orca-managed terminal as a
// renderer-side consequence of activating a worktree. CLI-created
// worktrees must trigger that same activation path or they will exist on
// disk without becoming the active workspace in the UI.
this.notifier?.activateWorktree(repo.id, worktree.id, setup)
this.invalidateResolvedWorktreeCache()
// Why: the filesystem-auth layer maintains a separate cache of registered
// worktree roots used by git IPC handlers (branchCompare, diff, status, etc.)
// to authorize paths. Without invalidating it here, CLI-created worktrees
// are not recognized and all git operations fail with "Access denied:
// unknown repository or worktree path".
invalidateAuthorizedRootsCache()
return {
worktree,
...(setup ? { setup } : {})
}
}
async updateManagedWorktreeMeta(
worktreeSelector: string,
updates: {
displayName?: string
linkedIssue?: number | null
comment?: string
}
) {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const meta = this.store.setWorktreeMeta(worktree.id, {
...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}),
...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}),
...(updates.comment !== undefined ? { comment: updates.comment } : {})
})
// Why: unlike renderer-initiated optimistic updates, CLI callers need an
// explicit push so the editor refreshes metadata changed outside the UI.
this.invalidateResolvedWorktreeCache()
this.notifier?.worktreesChanged(worktree.repoId)
return mergeWorktree(worktree.repoId, worktree.git, meta)
}
async removeManagedWorktree(worktreeSelector: string, force = false): Promise<void> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
const repo = this.store.getRepo(worktree.repoId)
if (!repo) {
throw new Error('repo_not_found')
}
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support deleting worktrees.')
}
const hooks = getEffectiveHooks(repo)
if (hooks?.scripts.archive) {
const result = await runHook('archive', worktree.path, repo)
if (!result.success) {
console.error(`[hooks] archive hook failed for ${worktree.path}:`, result.output)
}
}
try {
await removeWorktree(repo.path, worktree.path, force)
} catch (error) {
if (isOrphanedWorktreeError(error)) {
await rm(worktree.path, { recursive: true, force: true }).catch(() => {})
// Why: `git worktree remove` failed, so git's internal worktree tracking
// (`.git/worktrees/<name>`) is still intact. Without pruning, `git worktree
// list` continues to show the stale entry and the branch it had checked out
// remains locked — other worktrees cannot check it out.
await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {})
this.store.removeWorktreeMeta(worktree.id)
this.invalidateResolvedWorktreeCache()
invalidateAuthorizedRootsCache()
this.notifier?.worktreesChanged(repo.id)
return
}
throw new Error(formatWorktreeRemovalError(error, worktree.path, force))
}
this.store.removeWorktreeMeta(worktree.id)
this.invalidateResolvedWorktreeCache()
invalidateAuthorizedRootsCache()
this.notifier?.worktreesChanged(repo.id)
}
async renameTerminal(handle: string, title: string | null): Promise<RuntimeTerminalRename> {
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
this.notifier?.renameTerminal(leaf.tabId, title)
return { handle, tabId: leaf.tabId, title }
}
async createTerminal(
worktreeSelector?: string,
opts: { command?: string; title?: string } = {}
): Promise<RuntimeTerminalCreate> {
this.assertGraphReady()
const win = this.getAuthoritativeWindow()
// Why: mirrors browserTabCreate — when no worktree is specified, pass
// undefined so the renderer uses its current active worktree.
const worktreeId = worktreeSelector
? (await this.resolveWorktreeSelector(worktreeSelector)).id
: undefined
const requestId = randomUUID()
// Why: terminal creation is a renderer-side Zustand store operation (like
// browser tab creation). The main process sends a request, the renderer
// creates the tab and replies with the tabId so we can resolve the handle.
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('Terminal creation timed out'))
}, 10_000)
const handler = (
_event: Electron.IpcMainEvent,
r: { requestId: string; tabId?: string; title?: string; error?: string }
): void => {
if (r.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
if (r.error) {
reject(new Error(r.error))
} else {
resolve({ tabId: r.tabId!, title: r.title ?? opts.title ?? '' })
}
}
ipcMain.on('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
command: opts.command,
title: opts.title
})
})
// Why: the renderer created the tab immediately, but the graph sync that
// populates this.leaves may not have arrived yet. Wait for the leaf to
// appear so we can return a valid handle the caller can use right away.
const handle = await this.waitForTerminalHandle(reply.tabId)
return { handle, worktreeId: worktreeId ?? '', title: reply.title }
}
private waitForTerminalHandle(tabId: string, timeoutMs = 10_000): Promise<string> {
const existing = this.resolveHandleForTab(tabId)
if (existing) {
return Promise.resolve(existing)
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
reject(new Error('Timed out waiting for terminal handle after creation'))
}, timeoutMs)
const check = (): void => {
const handle = this.resolveHandleForTab(tabId)
if (handle) {
clearTimeout(timer)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
resolve(handle)
}
}
this.graphSyncCallbacks.push(check)
// Why: the graph sync may have fired between the initial check and
// callback registration. Re-check immediately to avoid a missed wake-up.
check()
})
}
// Why: a leaf appears in the graph before its PTY spawns. If we issue a
// handle while ptyId is null, the next graph sync after PTY spawn will
// change ptyId and invalidate the handle. Wait for a connected PTY so
// the handle is stable and immediately usable for send/read/wait.
private countLeavesInTab(tabId: string): number {
let count = 0
for (const leaf of this.leaves.values()) {
if (leaf.tabId === tabId) {
count++
}
}
return count
}
private resolveHandleForTab(tabId: string): string | null {
for (const leaf of this.leaves.values()) {
if (leaf.tabId === tabId && leaf.ptyId !== null) {
return this.issueHandle(leaf)
}
}
return null
}
async focusTerminal(handle: string): Promise<RuntimeTerminalFocus> {
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
this.notifier?.focusTerminal(leaf.tabId, leaf.worktreeId)
return { handle, tabId: leaf.tabId, worktreeId: leaf.worktreeId }
}
async closeTerminal(handle: string): Promise<RuntimeTerminalClose> {
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
let ptyKilled = false
if (leaf.ptyId) {
ptyKilled = this.ptyController?.kill(leaf.ptyId) ?? false
}
// Why: killing the PTY in a multi-pane tab is sufficient — the renderer's
// PTY exit handler already calls PaneManager.closePane() for split layouts.
// Sending an additional IPC close would race with the exit handler and
// incorrectly close the entire tab (the pane count drops to 1 before the
// IPC arrives, triggering the single-pane fallback path).
// We only send the notifier close when the PTY wasn't killed (e.g. PTY not
// yet spawned) or when this is the only pane in the tab.
const siblingCount = this.countLeavesInTab(leaf.tabId)
if (!ptyKilled || siblingCount <= 1) {
this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId)
}
return { handle, tabId: leaf.tabId, ptyKilled }
}
async splitTerminal(
handle: string,
opts: { direction?: 'horizontal' | 'vertical'; command?: string } = {}
): Promise<RuntimeTerminalSplit> {
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
const direction = opts.direction ?? 'horizontal'
// Why: snapshot current leaf keys for this tab so we can detect the new
// pane that appears after the split via graph sync delta.
const leafKeysBefore = new Set<string>()
for (const [key, l] of this.leaves) {
if (l.tabId === leaf.tabId) {
leafKeysBefore.add(key)
}
}
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
direction,
command: opts.command
})
const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore)
return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId }
}
private waitForNewLeafInTab(
tabId: string,
existingLeafKeys: Set<string>,
timeoutMs = 10_000
): Promise<string> {
const tryResolve = (): string | null => {
for (const [key, leaf] of this.leaves) {
if (leaf.tabId === tabId && !existingLeafKeys.has(key) && leaf.ptyId !== null) {
return this.issueHandle(leaf)
}
}
return null
}
const existing = tryResolve()
if (existing) {
return Promise.resolve(existing)
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
reject(new Error('Timed out waiting for split pane handle'))
}, timeoutMs)
const check = (): void => {
const handle = tryResolve()
if (handle) {
clearTimeout(timer)
const idx = this.graphSyncCallbacks.indexOf(check)
if (idx !== -1) {
this.graphSyncCallbacks.splice(idx, 1)
}
resolve(handle)
}
}
this.graphSyncCallbacks.push(check)
check()
})
}
async stopTerminalsForWorktree(worktreeSelector: string): Promise<{ stopped: number }> {
// Why: this mutates live PTYs, so the runtime must reject it while the
// renderer graph is reloading instead of acting on cached leaf ownership.
const graphEpoch = this.captureReadyGraphEpoch()
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
this.assertStableReadyGraph(graphEpoch)
const ptyIds = new Set<string>()
for (const leaf of this.leaves.values()) {
if (leaf.worktreeId === worktree.id && leaf.ptyId) {
ptyIds.add(leaf.ptyId)
}
}
let stopped = 0
for (const ptyId of ptyIds) {
if (this.ptyController?.kill(ptyId)) {
stopped += 1
}
}
return { stopped }
}
markRendererReloading(windowId: number): void {
if (windowId !== this.authoritativeWindowId) {
return
}
if (this.graphStatus !== 'ready') {
return
}
// Why: any renderer reload tears down the published live graph, so live
// terminal handles must become stale immediately instead of being reused
// against whatever the renderer rebuilds next.
this.rendererGraphEpoch += 1
this.graphStatus = 'reloading'
this.handles.clear()
this.handleByLeafKey.clear()
this.rejectAllWaiters('terminal_handle_stale')
this.refreshWritableFlags()
}
markGraphReady(windowId: number): void {
if (windowId !== this.authoritativeWindowId) {
return
}
this.graphStatus = 'ready'
this.refreshWritableFlags()
}
markGraphUnavailable(windowId: number): void {
if (windowId !== this.authoritativeWindowId) {
return
}
// Why: once the authoritative renderer graph disappears, Orca must fail
// closed for live-terminal operations instead of guessing from old state.
if (this.graphStatus !== 'unavailable') {
this.rendererGraphEpoch += 1
}
this.graphStatus = 'unavailable'
this.authoritativeWindowId = null
this.tabs.clear()
this.leaves.clear()
this.handles.clear()
this.handleByLeafKey.clear()
this.rejectAllWaiters('terminal_handle_stale')
}
private assertGraphReady(): void {
if (this.graphStatus !== 'ready') {
throw new Error('runtime_unavailable')
}
}
private captureReadyGraphEpoch(): number {
this.assertGraphReady()
return this.rendererGraphEpoch
}
private assertStableReadyGraph(expectedGraphEpoch: number): void {
if (this.graphStatus !== 'ready' || this.rendererGraphEpoch !== expectedGraphEpoch) {
throw new Error('runtime_unavailable')
}
}
private async resolveWorktreeSelector(selector: string): Promise<ResolvedWorktree> {
const worktrees = await this.listResolvedWorktrees()
let candidates: ResolvedWorktree[]
if (selector === 'active') {
throw new Error('selector_not_found')
}
if (selector.startsWith('id:')) {
candidates = worktrees.filter((worktree) => worktree.id === selector.slice(3))
} else if (selector.startsWith('path:')) {
candidates = worktrees.filter((worktree) => worktree.path === selector.slice(5))
} else if (selector.startsWith('branch:')) {
const branchSelector = selector.slice(7)
candidates = worktrees.filter((worktree) =>
branchSelectorMatches(worktree.branch, branchSelector)
)
} else if (selector.startsWith('issue:')) {
candidates = worktrees.filter(
(worktree) =>
worktree.linkedIssue !== null && String(worktree.linkedIssue) === selector.slice(6)
)
} else {
candidates = worktrees.filter(
(worktree) =>
worktree.id === selector ||
worktree.path === selector ||
branchSelectorMatches(worktree.branch, selector)
)
}
if (candidates.length === 1) {
return candidates[0]
}
if (candidates.length > 1) {
throw new Error('selector_ambiguous')
}
throw new Error('selector_not_found')
}
private async resolveRepoSelector(selector: string): Promise<Repo> {
if (!this.store) {
throw new Error('repo_not_found')
}
const repos = this.store.getRepos()
let candidates: Repo[]
if (selector.startsWith('id:')) {
candidates = repos.filter((repo) => repo.id === selector.slice(3))
} else if (selector.startsWith('path:')) {
candidates = repos.filter((repo) => repo.path === selector.slice(5))
} else if (selector.startsWith('name:')) {
candidates = repos.filter((repo) => repo.displayName === selector.slice(5))
} else {
candidates = repos.filter(
(repo) => repo.id === selector || repo.path === selector || repo.displayName === selector
)
}
if (candidates.length === 1) {
return candidates[0]
}
if (candidates.length > 1) {
throw new Error('selector_ambiguous')
}
throw new Error('repo_not_found')
}
private async listResolvedWorktrees(): Promise<ResolvedWorktree[]> {
if (!this.store) {
return []
}
const now = Date.now()
if (this.resolvedWorktreeCache && this.resolvedWorktreeCache.expiresAt > now) {
return this.resolvedWorktreeCache.worktrees
}
const metaById = this.store.getAllWorktreeMeta()
const worktrees: ResolvedWorktree[] = []
for (const repo of this.store.getRepos()) {
const gitWorktrees = await listRepoWorktrees(repo)
for (const gitWorktree of gitWorktrees) {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName)
worktrees.push({
id: merged.id,
repoId: repo.id,
path: merged.path,
branch: merged.branch,
linkedIssue: metaById[worktreeId]?.linkedIssue ?? null,
git: {
path: gitWorktree.path,
head: gitWorktree.head,
branch: gitWorktree.branch,
isBare: gitWorktree.isBare,
isMainWorktree: gitWorktree.isMainWorktree
},
displayName: merged.displayName,
comment: merged.comment
})
}
}
// Why: terminal polling can be frequent, but git worktree state is still
// allowed to change outside Orca. A short TTL avoids shelling out on every
// read without pretending the cache is authoritative for long.
this.resolvedWorktreeCache = {
worktrees,
expiresAt: now + RESOLVED_WORKTREE_CACHE_TTL_MS
}
return worktrees
}
private async getResolvedWorktreeMap(): Promise<Map<string, ResolvedWorktree>> {
return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree]))
}
private invalidateResolvedWorktreeCache(): void {
this.resolvedWorktreeCache = null
}
private buildTerminalSummary(
leaf: RuntimeLeafRecord,
worktreesById: Map<string, ResolvedWorktree>
): RuntimeTerminalSummary {
const worktree = worktreesById.get(leaf.worktreeId)
const tab = this.tabs.get(leaf.tabId) ?? null
return {
handle: this.issueHandle(leaf),
worktreeId: leaf.worktreeId,
worktreePath: worktree?.path ?? '',
branch: worktree?.branch ?? '',
tabId: leaf.tabId,
leafId: leaf.leafId,
title: tab?.title ?? null,
connected: leaf.connected,
writable: leaf.writable,
lastOutputAt: leaf.lastOutputAt,
preview: leaf.preview
}
}
private getLiveLeafForHandle(handle: string): {
record: TerminalHandleRecord
leaf: RuntimeLeafRecord
} {
this.assertGraphReady()
const record = this.handles.get(handle)
if (!record || record.runtimeId !== this.runtimeId) {
throw new Error('terminal_handle_stale')
}
if (record.rendererGraphEpoch !== this.rendererGraphEpoch) {
throw new Error('terminal_handle_stale')
}
const leaf = this.leaves.get(this.getLeafKey(record.tabId, record.leafId))
if (!leaf || leaf.ptyId !== record.ptyId || leaf.ptyGeneration !== record.ptyGeneration) {
throw new Error('terminal_handle_stale')
}
return { record, leaf }
}
private issueHandle(leaf: RuntimeLeafRecord): string {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const existingHandle = this.handleByLeafKey.get(leafKey)
if (existingHandle) {
const existingRecord = this.handles.get(existingHandle)
if (
existingRecord &&
existingRecord.rendererGraphEpoch === this.rendererGraphEpoch &&
existingRecord.ptyId === leaf.ptyId &&
existingRecord.ptyGeneration === leaf.ptyGeneration
) {
return existingHandle
}
}
const handle = `term_${randomUUID()}`
this.handles.set(handle, {
handle,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
ptyId: leaf.ptyId,
ptyGeneration: leaf.ptyGeneration
})
this.handleByLeafKey.set(leafKey, handle)
return handle
}
private refreshWritableFlags(): void {
for (const leaf of this.leaves.values()) {
leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null
}
}
private invalidateLeafHandle(leafKey: string): void {
const handle = this.handleByLeafKey.get(leafKey)
if (!handle) {
return
}
this.handleByLeafKey.delete(leafKey)
this.handles.delete(handle)
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
}
private resolveExitWaiters(leaf: RuntimeLeafRecord): void {
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!handle) {
return
}
const waiters = this.waitersByHandle.get(handle)
if (!waiters || waiters.size === 0) {
return
}
for (const waiter of [...waiters]) {
if (waiter.condition === 'exit') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'exit', leaf))
} else {
// Why: if the terminal exited, conditions like tui-idle can never be
// satisfied. Reject immediately instead of letting the poll interval
// spin until timeout on a dead process.
this.removeWaiter(waiter)
waiter.reject(new Error('terminal_exited'))
}
}
}
private resolveTuiIdleWaiters(leaf: RuntimeLeafRecord): void {
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!handle) {
return
}
const waiters = this.waitersByHandle.get(handle)
if (!waiters || waiters.size === 0) {
return
}
for (const waiter of [...waiters]) {
if (waiter.condition === 'tui-idle') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, 'tui-idle', leaf))
}
}
}
private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void {
this.removeWaiter(waiter)
waiter.resolve(result)
}
private rejectWaitersForHandle(handle: string, code: string): void {
const waiters = this.waitersByHandle.get(handle)
if (!waiters || waiters.size === 0) {
return
}
for (const waiter of [...waiters]) {
this.removeWaiter(waiter)
waiter.reject(new Error(code))
}
}
private rejectAllWaiters(code: string): void {
for (const handle of [...this.waitersByHandle.keys()]) {
this.rejectWaitersForHandle(handle, code)
}
}
private removeWaiter(waiter: TerminalWaiter): void {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
const waiters = this.waitersByHandle.get(waiter.handle)
if (!waiters) {
return
}
waiters.delete(waiter)
if (waiters.size === 0) {
this.waitersByHandle.delete(waiter.handle)
}
}
private getLeafKey(tabId: string, leafId: string): string {
return `${tabId}::${leafId}`
}
// ── Browser automation ──
private requireAgentBrowserBridge(): AgentBrowserBridge {
if (!this.agentBrowserBridge) {
throw new BrowserError('browser_no_tab', 'No browser session is active')
}
return this.agentBrowserBridge
}
// 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<string | undefined> {
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.agentBrowserBridge
if (bridge && bridge.getRegisteredTabs().size === 0) {
try {
const win = this.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.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.agentBrowserBridge
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<ResolvedBrowserCommandTarget> {
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<void> {
const win = this.getAuthoritativeWindow()
const repoId = worktreeId.split('::')[0]
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.getAuthoritativeWindow()
win.webContents.send('browser:navigation-update', { browserPageId, url, title })
} catch {
// Window may not exist during shutdown
}
}
async browserSnapshot(params: BrowserCommandTargetParams): Promise<BrowserSnapshotResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId)
}
async browserClick(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClickResult> {
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<BrowserGotoResult> {
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<BrowserFillResult> {
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<BrowserTypeResult> {
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<BrowserSelectResult> {
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<BrowserScrollResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().scroll(
params.direction,
params.amount,
target.worktreeId,
target.browserPageId
)
}
async browserBack(params: BrowserCommandTargetParams): Promise<BrowserBackResult> {
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<BrowserReloadResult> {
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<BrowserScreenshotResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().screenshot(
params.format,
target.worktreeId,
target.browserPageId
)
}
async browserEval(
params: { expression: string } & BrowserCommandTargetParams
): Promise<BrowserEvalResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().evaluate(
params.expression,
target.worktreeId,
target.browserPageId
)
}
async browserTabList(params: { worktree?: string }): Promise<BrowserTabListResult> {
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
return this.requireAgentBrowserBridge().tabList(worktreeId)
}
async browserTabSwitch(
params: {
index?: number
} & BrowserCommandTargetParams
): Promise<BrowserTabSwitchResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().tabSwitch(
params.index,
target.worktreeId,
target.browserPageId
)
}
async browserHover(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserHoverResult> {
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<BrowserDragResult> {
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<BrowserUploadResult> {
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<BrowserWaitResult> {
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<BrowserCheckResult> {
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<BrowserFocusResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().focus(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserClear(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserClearResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().clear(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserSelectAll(
params: { element: string } & BrowserCommandTargetParams
): Promise<BrowserSelectAllResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().selectAll(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserKeypress(
params: { key: string } & BrowserCommandTargetParams
): Promise<BrowserKeypressResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().keypress(
params.key,
target.worktreeId,
target.browserPageId
)
}
async browserPdf(params: BrowserCommandTargetParams): Promise<BrowserPdfResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId)
}
async browserFullScreenshot(
params: {
format?: 'png' | 'jpeg'
} & BrowserCommandTargetParams
): Promise<BrowserScreenshotResult> {
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<BrowserCookieGetResult> {
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<BrowserCookieSetResult> {
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<BrowserCookieDeleteResult> {
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<BrowserViewportResult> {
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<BrowserGeolocationResult> {
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<BrowserInterceptEnableResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().interceptEnable(
params.patterns,
target.worktreeId,
target.browserPageId
)
}
async browserInterceptDisable(
params: BrowserCommandTargetParams
): Promise<BrowserInterceptDisableResult> {
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<BrowserCaptureStartResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId)
}
async browserCaptureStop(params: BrowserCommandTargetParams): Promise<BrowserCaptureStopResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId)
}
async browserConsoleLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserConsoleResult> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().consoleLog(
params.limit,
target.worktreeId,
target.browserPageId
)
}
async browserNetworkLog(
params: { limit?: number } & BrowserCommandTargetParams
): Promise<BrowserNetworkLogResult> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().dblclick(
params.element,
target.worktreeId,
target.browserPageId
)
}
async browserForward(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId)
}
async browserScrollIntoView(
params: { element: string } & BrowserCommandTargetParams
): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().mouseDown(
params.button,
target.worktreeId,
target.browserPageId
)
}
async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setDevice(
params.name,
target.worktreeId,
target.browserPageId
)
}
async browserSetOffline(
params: { state?: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().setOffline(
params.state,
target.worktreeId,
target.browserPageId
)
}
async browserSetHeaders(
params: { headers: string } & BrowserCommandTargetParams
): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId)
}
async browserClipboardWrite(
params: { text: string } & BrowserCommandTargetParams
): Promise<unknown> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().dialogAccept(
params.text,
target.worktreeId,
target.browserPageId
)
}
async browserDialogDismiss(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId)
}
// ── Storage commands ──
async browserStorageLocalGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
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<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageLocalSet(
params.key,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageLocalClear(
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionGet(
params: { key: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionGet(
params.key,
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionSet(
params: {
key: string
value: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionSet(
params.key,
params.value,
target.worktreeId,
target.browserPageId
)
}
async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().storageSessionClear(
target.worktreeId,
target.browserPageId
)
}
// ── Download command ──
async browserDownload(
params: {
selector: string
path: string
} & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().download(
params.selector,
params.path,
target.worktreeId,
target.browserPageId
)
}
// ── Highlight command ──
async browserHighlight(
params: { selector: string } & BrowserCommandTargetParams
): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().highlight(
params.selector,
target.worktreeId,
target.browserPageId
)
}
// ── New: exec passthrough + tab lifecycle ──
async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise<unknown> {
const target = await this.resolveBrowserCommandTarget(params)
return this.requireAgentBrowserBridge().exec(
params.command,
target.worktreeId,
target.browserPageId
)
}
async browserTabCreate(params: {
url?: string
worktree?: string
}): Promise<{ browserPageId: string }> {
const win = this.getAuthoritativeWindow()
const requestId = randomUUID()
const url = params.url ?? 'about:blank'
// Why: the renderer's Zustand store keys browser tabs by worktreeId in
// "repoId::path" format. The CLI sends a selector (e.g. "path:/Users/...").
// Resolve it here so the renderer receives the store-compatible ID.
const worktreeId = params.worktree
? (await this.resolveWorktreeSelector(params.worktree)).id
: undefined
// Why: browser webviews only mount when their worktree is active in the UI.
// Switch to it before creating the tab so the webview attaches immediately.
if (worktreeId) {
await this.ensureBrowserWorktreeActive(worktreeId)
}
// Why: tab creation is a renderer-side Zustand store operation. The main process
// sends a request, the renderer creates the tab and replies with the workspace ID
// (which is the browserPageId used by registerGuest and the bridge).
const browserPageId = await new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCreateReply', handler)
reject(new Error('Tab creation timed out'))
}, 10_000)
const handler = (
_event: Electron.IpcMainEvent,
reply: { requestId: string; browserPageId?: string; error?: string }
): void => {
if (reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCreateReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve(reply.browserPageId!)
}
}
ipcMain.on('browser:tabCreateReply', handler)
win.webContents.send('browser:requestTabCreate', { requestId, url, worktreeId })
})
// Why: the renderer creates the Zustand tab immediately, but the webview must
// mount and fire dom-ready before registerGuest runs. Waiting here ensures the
// tab is operable by subsequent CLI commands (snapshot, click, etc.).
// If registration doesn't complete within timeout, return the ID anyway — the
// tab exists in the UI but may not be ready for automation commands yet.
try {
await waitForTabRegistration(browserPageId)
} catch {
// Tab was created in the renderer but the webview hasn't finished mounting.
// Return success since the tab exists; subsequent commands will fail with a
// clear "tab not available" error if the webview never loads.
}
// Why: newly created tabs should be auto-activated so subsequent commands
// (snapshot, click, goto) target the new tab without requiring an explicit
// tab switch. Without this, the bridge's active tab still points at the
// previously active tab and the new tab shows active: false in tab list.
const bridge = this.requireAgentBrowserBridge()
const wcId = bridge.getRegisteredTabs(worktreeId).get(browserPageId)
if (wcId != null) {
bridge.setActiveTab(wcId, worktreeId)
}
// Why: the renderer sets webview.src=url on mount, but agent-browser connects
// via CDP after the webview loads about:blank. Without an explicit goto, the
// page stays blank from agent-browser's perspective. Navigate via the bridge
// so agent-browser's CDP session tracks the correct page state.
if (url && url !== 'about:blank') {
try {
const result = await bridge.goto(url, worktreeId, browserPageId)
this.notifyRendererNavigation(browserPageId, result.url, result.title)
} catch {
// Tab exists but navigation failed — caller can retry with explicit goto
}
}
return { browserPageId }
}
async browserTabClose(params: {
index?: number
page?: string
worktree?: string
}): Promise<{ closed: boolean }> {
const bridge = this.requireAgentBrowserBridge()
const worktreeId = await this.resolveBrowserWorktreeId(params.worktree)
let tabId: string | null = null
if (typeof params.page === 'string' && params.page.length > 0) {
if (!bridge.getRegisteredTabs(worktreeId).has(params.page)) {
const scope = worktreeId ? ' in this worktree' : ''
throw new BrowserError(
'browser_tab_not_found',
`Browser page ${params.page} was not found${scope}`
)
}
tabId = params.page
} else if (params.index !== undefined) {
const tabs = bridge.getRegisteredTabs(worktreeId)
const entries = [...tabs.entries()]
if (params.index < 0 || params.index >= entries.length) {
throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`)
}
tabId = entries[params.index][0]
} else {
// Why: try the bridge first (registered tabs with webviews), then fall back
// to asking the renderer to close its active browser tab (handles cases where
// the webview hasn't mounted yet, e.g. tab was just created).
const tabs = bridge.getRegisteredTabs(worktreeId)
const entries = [...tabs.entries()]
const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId())
if (activeEntry) {
tabId = activeEntry[0]
}
}
const win = this.getAuthoritativeWindow()
const requestId = randomUUID()
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
ipcMain.removeListener('browser:tabCloseReply', handler)
reject(new Error('Tab close timed out'))
}, 10_000)
const handler = (
_event: Electron.IpcMainEvent,
reply: { requestId: string; error?: string }
): void => {
if (reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('browser:tabCloseReply', handler)
if (reply.error) {
reject(new Error(reply.error))
} else {
resolve()
}
}
ipcMain.on('browser:tabCloseReply', handler)
// Why: when main cannot resolve a concrete tab id itself (for example if a
// browser workspace exists in the renderer before its guest mounts), the
// renderer still needs the intended worktree scope. Otherwise it falls
// back to the globally active browser tab and can close a tab in the
// wrong worktree.
win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId })
})
return { closed: true }
}
private getAuthoritativeWindow(): BrowserWindow {
if (this.authoritativeWindowId === null) {
throw new Error('No renderer window available')
}
const win = BrowserWindow.fromId(this.authoritativeWindowId)
if (!win || win.isDestroyed()) {
throw new Error('No renderer window available')
}
return win
}
}
const MAX_TAIL_LINES = 120
const MAX_TAIL_CHARS = 4000
const MAX_PREVIEW_LINES = 6
const MAX_PREVIEW_CHARS = 300
const DEFAULT_REPO_SEARCH_REFS_LIMIT = 25
const DEFAULT_TERMINAL_LIST_LIMIT = 200
const DEFAULT_WORKTREE_LIST_LIMIT = 200
const DEFAULT_WORKTREE_PS_LIMIT = 200
const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000
function buildPreview(lines: string[], partialLine: string): string {
const previewLines = buildTailLines(lines, partialLine)
.map((line) => line.trim())
.filter(Boolean)
.slice(-MAX_PREVIEW_LINES)
const preview = previewLines.join('\n')
return preview.length > MAX_PREVIEW_CHARS
? preview.slice(preview.length - MAX_PREVIEW_CHARS)
: preview
}
function appendToTailBuffer(
previousLines: string[],
previousPartialLine: string,
chunk: string
): {
lines: string[]
partialLine: string
truncated: boolean
newCompleteLines: number
} {
const normalizedChunk = normalizeTerminalChunk(chunk)
if (normalizedChunk.length === 0) {
return {
lines: previousLines,
partialLine: previousPartialLine,
truncated: false,
newCompleteLines: 0
}
}
const pieces = `${previousPartialLine}${normalizedChunk}`.split('\n')
const nextPartialLine = (pieces.pop() ?? '').replace(/[ \t]+$/g, '')
const newCompleteLines = pieces.length
const nextLines = [...previousLines, ...pieces.map((line) => line.replace(/[ \t]+$/g, ''))]
let truncated = false
while (nextLines.length > MAX_TAIL_LINES) {
nextLines.shift()
truncated = true
}
let totalChars = nextLines.reduce((sum, line) => sum + line.length, 0) + nextPartialLine.length
while (nextLines.length > 0 && totalChars > MAX_TAIL_CHARS) {
totalChars -= nextLines.shift()!.length
truncated = true
}
return {
lines: nextLines,
partialLine: nextPartialLine.slice(-MAX_TAIL_CHARS),
truncated,
newCompleteLines
}
}
function buildTailLines(lines: string[], partialLine: string): string[] {
return partialLine.length > 0 ? [...lines, partialLine] : lines
}
function getTerminalState(leaf: RuntimeLeafRecord): RuntimeTerminalState {
if (leaf.connected) {
return 'running'
}
if (leaf.lastExitCode !== null) {
return 'exited'
}
return 'unknown'
}
function buildSendPayload(action: {
text?: string
enter?: boolean
interrupt?: boolean
}): string | null {
let payload = ''
if (typeof action.text === 'string' && action.text.length > 0) {
payload += action.text
}
if (action.enter) {
payload += '\r'
}
if (action.interrupt) {
payload += '\x03'
}
return payload.length > 0 ? payload : null
}
// Why: tui-idle relies on recognized agent CLIs setting OSC titles. If the
// terminal runs an unsupported CLI (or a plain shell), no title transition
// will ever fire. A 5-minute ceiling prevents indefinite hangs while still
// giving real agent tasks plenty of time to complete.
const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
function buildTerminalWaitResult(
handle: string,
condition: RuntimeTerminalWaitCondition,
leaf: RuntimeLeafRecord
): RuntimeTerminalWait {
return {
handle,
condition,
satisfied: true,
status: getTerminalState(leaf),
exitCode: leaf.lastExitCode
}
}
function branchSelectorMatches(branch: string, selector: string): boolean {
// Why: Git worktree data can report local branches as either `refs/heads/foo`
// or `foo` depending on which plumbing path produced the record. Orca's
// branch selectors should accept either form so newly created worktrees stay
// discoverable without exposing internal ref-shape differences to users.
return normalizeBranchRef(branch) === normalizeBranchRef(selector)
}
function normalizeBranchRef(branch: string): string {
return branch.startsWith('refs/heads/') ? branch.slice('refs/heads/'.length) : branch
}
function normalizeTerminalChunk(chunk: string): string {
return chunk
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\x1b[@-_]/g, '')
.replace(/\u0008/g, '')
.replace(/[^\x09\x0a\x20-\x7e]/g, '')
}
function maxTimestamp(left: number | null, right: number | null): number | null {
if (left === null) {
return right
}
if (right === null) {
return left
}
return Math.max(left, right)
}
function compareWorktreePs(
left: RuntimeWorktreePsSummary,
right: RuntimeWorktreePsSummary
): number {
const leftLast = left.lastOutputAt ?? -1
const rightLast = right.lastOutputAt ?? -1
if (leftLast !== rightLast) {
return rightLast - leftLast
}
if (left.liveTerminalCount !== right.liveTerminalCount) {
return right.liveTerminalCount - left.liveTerminalCount
}
return left.path.localeCompare(right.path)
}