orca/src/renderer/src/web/web-preload-api.ts

1903 lines
68 KiB
TypeScript

/* eslint-disable max-lines -- Why: the web preload adapter is the browser-side
replacement for Electron preload, so the compatibility surface is necessarily
centralized at this boundary. */
import type { PreloadApi, PreflightStatus, RefreshAgentsResult } from '../../../preload/api-types'
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import type {
DirEntry,
GlobalSettings,
MemorySnapshot,
OnboardingState,
PersistedUIState,
Repo,
SearchResult,
StatsSummary,
Worktree,
WorktreeLineage,
WorkspaceSessionState
} from '../../../shared/types'
import {
getDefaultOnboardingState,
getDefaultSettings,
getDefaultUIState,
getDefaultWorkspaceSession
} from '../../../shared/constants'
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
import { createE2EConfig } from '../../../shared/e2e-config'
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
import type { RateLimitState } from '../../../shared/rate-limit-types'
import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
import {
findKeybindingConflicts,
formatKeybindingList,
getKeybindingPlatform,
isKeybindingActionId,
normalizeKeybindingArrayForAction,
type KeybindingActionId,
type KeybindingFileDiagnostic,
type KeybindingFileSnapshot,
type KeybindingOverrides,
type KeybindingPlatform
} from '../../../shared/keybindings'
import {
clearStoredWebRuntimeEnvironment,
createStoredWebRuntimeEnvironment,
getPreferredWebPairingOffer,
readStoredWebRuntimeEnvironment,
redactStoredWebRuntimeEnvironment,
saveStoredWebRuntimeEnvironment,
updateStoredEnvironmentRuntimeId,
type StoredWebRuntimeEnvironment
} from './web-runtime-environment'
import { parseWebPairingInput } from './web-pairing'
import { WebRuntimeClient } from './web-runtime-client'
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
import { sanitizeWebRuntimeWorkspaceSession } from './web-workspace-session'
const SETTINGS_STORAGE_KEY = 'orca.web.settings.v1'
const UI_STORAGE_KEY = 'orca.web.ui.v1'
const SESSION_STORAGE_KEY = 'orca.web.workspaceSession.v1'
const ONBOARDING_STORAGE_KEY = 'orca.web.onboarding.v1'
const GITHUB_CACHE_STORAGE_KEY = 'orca.web.githubCache.v1'
const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1'
// Why: browser-paired clients need desktop parity for large dev sessions; the
// runtime's no-limit default remains capped for lower-level RPC callers.
const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
let activeClient: WebRuntimeClient | null = null
let activeClientEnvironmentId: string | null = null
let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
type WebSettingsApi = NonNullable<PreloadApi['settings']>
type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
type WebKeybindingDocument = {
version: 1
keybindings: KeybindingOverrides
platforms: Partial<Record<KeybindingPlatform, KeybindingOverrides>>
}
const WEB_KEYBINDING_PLATFORMS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32']
const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>()
export function installWebPreloadApi(): void {
activeEnvironment = readStoredWebRuntimeEnvironment()
const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean }
webWindow.__ORCA_WEB_CLIENT__ = true
window.electron = createFallbackProxy(['electron']) as Window['electron']
window.api = withFallback(createWebPreloadApi(), []) as PreloadApi
}
function createWebPreloadApi(): Partial<PreloadApi> {
return {
app: {
getIdentity: () =>
Promise.resolve({
name: 'Orca',
isDev: false,
devLabel: null,
devBranch: null,
devWorktreeName: null,
devRepoRoot: null,
dockBadgeLabel: null
}),
getFeatureWallAssetBaseUrl: () => Promise.resolve('/'),
relaunch: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
getKeyboardInputSourceId: () => Promise.resolve(null),
setUnreadDockBadgeCount: () => Promise.resolve(),
getFloatingTerminalCwd: () => Promise.resolve(''),
getFloatingMarkdownDirectory: () => Promise.resolve(''),
pickFloatingMarkdownDocument: () => Promise.resolve(null),
pickFloatingWorkspaceDirectory: () => Promise.resolve(null)
},
e2e: {
getConfig: () => createE2EConfig({})
},
settings: {
get: async () => getStoredSettings(),
set: async (updates) => {
if (updates.activeRuntimeEnvironmentId === null) {
disconnectActiveRuntimeEnvironment()
}
const next = mergeSettings(getStoredSettings(), updates)
writeJson(SETTINGS_STORAGE_KEY, next)
return next
},
listFonts: () => Promise.resolve([]),
onChanged: () => noopUnsubscribe
} satisfies Partial<WebSettingsApi> as unknown as WebSettingsApi,
keybindings: createWebKeybindingsApi(),
ui: createWebUiApi(),
crashReports: {
getLatestPending: () => Promise.resolve(null),
getLatestReport: () => Promise.resolve(null),
dismiss: () => Promise.resolve(null),
submit: () =>
Promise.resolve({
ok: false,
status: null,
error: 'Unavailable on web.'
}),
copyLatestDiagnostics: () => Promise.resolve({ ok: false, error: 'Unavailable on web.' })
},
diagnostics: {
getStatus: () =>
Promise.resolve({
localFileEnabled: false,
otlpEnabled: false,
bundleEnabled: false,
otlpStatus: 'Unavailable on web',
traceFilePath: '',
traceFamilySize: 0
}),
openTraceFolder: () => Promise.resolve(),
clearTraces: () => Promise.resolve(),
collectBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')),
openBundlePreview: () =>
Promise.reject(new Error('Diagnostic bundles are unavailable on web.')),
discardBundlePreview: () => Promise.resolve(),
uploadBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.')),
deleteBundle: () => Promise.reject(new Error('Diagnostic bundles are unavailable on web.'))
},
session: {
get: () => Promise.resolve(getStoredWorkspaceSession()),
set: async (session) => {
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
},
setSync: (session) => {
writeJson(SESSION_STORAGE_KEY, sanitizeWebRuntimeWorkspaceSession(session))
}
},
onboarding: {
get: () => Promise.resolve(getStoredOnboarding()),
update: async (updates) => {
const current = getStoredOnboarding()
const next: OnboardingState = {
...current,
...updates,
checklist: {
...current.checklist,
...updates.checklist
}
}
writeJson(ONBOARDING_STORAGE_KEY, next)
return next
}
},
cache: {
getGitHub: () =>
Promise.resolve(
readJson(GITHUB_CACHE_STORAGE_KEY, {
pr: {},
issue: {}
})
),
setGitHub: async ({ cache }) => {
writeJson(GITHUB_CACHE_STORAGE_KEY, cache)
}
},
runtime: createRuntimeApi(),
runtimeEnvironments: createRuntimeEnvironmentsApi(),
repos: createReposApi(),
worktrees: createWorktreesApi(),
fs: createFileApi(),
git: createGitApi(),
browser: createBrowserApi(),
gh: createGitHubApi(),
hostedReview: createRuntimeNamespaceApi('hostedReview'),
linear: createRuntimeNamespaceApi('linear'),
hooks: createHooksApi(),
stats: {
getSummary: async () =>
callRuntimeResult<StatsSummary>('stats.summary').catch(() => ({
totalAgentsSpawned: 0,
totalPRsCreated: 0,
totalAgentTimeMs: 0,
firstEventAt: null
}))
},
memory: {
getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot())
},
preflight: createPreflightApi(),
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
codexAccounts: createAccountsApi(),
claudeAccounts: createAccountsApi(),
cli: createCliApi(),
agentHooks: createAgentHooksApi(),
developerPermissions: createDeveloperPermissionsApi(),
computerUsePermissions: createComputerUsePermissionsApi(),
updater: createUpdaterApi(),
shell: createShellApi(),
skills: {
discover: () => Promise.resolve({ skills: [], sources: [], scannedAt: Date.now() })
},
pty: createPtyApi(),
ssh: createSshApi(),
wsl: { isAvailable: () => Promise.resolve(false) },
pwsh: { isAvailable: () => Promise.resolve(false) },
agentStatus: {
onSet: () => noopUnsubscribe,
getSnapshot: () => Promise.resolve([]),
inferInterrupt: () => Promise.resolve(false),
onMigrationUnsupported: () => noopUnsubscribe,
onMigrationUnsupportedClear: () => noopUnsubscribe,
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
drop: () => {}
},
mobile: {
listNetworkInterfaces: () => Promise.resolve({ interfaces: [] }),
getPairingQR: () => Promise.resolve({ available: false }),
getRuntimePairingUrl: () => Promise.resolve({ available: false }),
listDevices: () => Promise.resolve({ devices: [] }),
revokeDevice: () => Promise.resolve({ revoked: false }),
listRuntimeAccessGrants: () => Promise.resolve({ grants: [] }),
revokeRuntimeAccess: () => Promise.resolve({ revoked: false }),
isWebSocketReady: () => Promise.resolve({ ready: Boolean(activeEnvironment), endpoint: null })
},
telemetryTrack: () => Promise.resolve(),
telemetrySetOptIn: () => Promise.resolve(),
telemetryGetConsentState: () =>
Promise.resolve({ optedIn: false, source: 'default', blockedByEnv: false } as never),
telemetryAcknowledgeBanner: () => Promise.resolve()
}
}
function createEmptyWebKeybindingDocument(): WebKeybindingDocument {
return {
version: 1,
keybindings: {},
platforms: {
darwin: {},
linux: {},
win32: {}
}
}
}
function getWebKeybindingPlatform(): KeybindingPlatform {
return getKeybindingPlatform(getBrowserPlatform())
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function normalizeStoredWebOverrides(
value: unknown,
section: string,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({ severity: 'error', section, message: `${section} must be an object.` })
return {}
}
const overrides: KeybindingOverrides = {}
for (const [actionId, rawBindings] of Object.entries(value)) {
if (!isKeybindingActionId(actionId)) {
diagnostics.push({
severity: 'warning',
section,
actionId,
message: `Unknown keybinding action "${actionId}" was ignored.`
})
continue
}
if (
!Array.isArray(rawBindings) ||
!rawBindings.every((binding) => typeof binding === 'string')
) {
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: Use a string array.`
})
continue
}
const normalized = normalizeKeybindingArrayForAction(actionId, rawBindings)
if (!Array.isArray(normalized)) {
const error = normalized.ok ? 'Unable to parse shortcut.' : normalized.error
diagnostics.push({
severity: 'error',
section,
actionId,
message: `Shortcut for "${actionId}" was ignored: ${error}`
})
continue
}
overrides[actionId] = normalized
}
return overrides
}
function normalizeWebPlatformOverrides(
value: unknown,
diagnostics: KeybindingFileDiagnostic[]
): Partial<Record<KeybindingPlatform, KeybindingOverrides>> {
if (value === undefined) {
return {}
}
if (!isJsonObject(value)) {
diagnostics.push({
severity: 'error',
section: 'platforms',
message: 'platforms must be an object with darwin, linux, or win32 sections.'
})
return {}
}
const result: Partial<Record<KeybindingPlatform, KeybindingOverrides>> = {}
for (const [platform, overrides] of Object.entries(value)) {
if (!WEB_KEYBINDING_PLATFORMS.includes(platform as KeybindingPlatform)) {
diagnostics.push({
severity: 'warning',
section: `platforms.${platform}`,
message: `Unknown platform "${platform}" was ignored.`
})
continue
}
result[platform as KeybindingPlatform] = normalizeStoredWebOverrides(
overrides,
`platforms.${platform}`,
diagnostics
)
}
return result
}
function removeConflictingWebOverrides(
platform: KeybindingPlatform,
overrides: KeybindingOverrides,
diagnostics: KeybindingFileDiagnostic[]
): KeybindingOverrides {
let next = { ...overrides }
for (let attempt = 0; attempt < 20; attempt++) {
const conflicts = findKeybindingConflicts(platform, next)
const conflictingOverrides = new Set<KeybindingActionId>()
for (const conflict of conflicts) {
for (const actionId of conflict.actionIds) {
if (Object.prototype.hasOwnProperty.call(next, actionId)) {
conflictingOverrides.add(actionId)
}
}
}
if (conflictingOverrides.size === 0) {
return next
}
for (const actionId of conflictingOverrides) {
delete next[actionId]
}
diagnostics.push({
severity: 'error',
message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides)
.map((actionId) => actionId)
.join(', ')}.`
})
}
return next
}
function readWebKeybindingDocument(): WebKeybindingDocument {
const document = readJson(KEYBINDINGS_STORAGE_KEY, createEmptyWebKeybindingDocument())
return {
version: 1,
keybindings: isJsonObject(document.keybindings)
? (document.keybindings as KeybindingOverrides)
: {},
platforms: isJsonObject(document.platforms)
? (document.platforms as Partial<Record<KeybindingPlatform, KeybindingOverrides>>)
: {}
}
}
function getWebKeybindingSnapshot(): KeybindingFileSnapshot {
const platform = getWebKeybindingPlatform()
const diagnostics: KeybindingFileDiagnostic[] = []
const document = readWebKeybindingDocument()
const commonOverrides = normalizeStoredWebOverrides(
document.keybindings,
'keybindings',
diagnostics
)
const platformOverrides = normalizeWebPlatformOverrides(document.platforms, diagnostics)
const overrides = removeConflictingWebOverrides(
platform,
{
...commonOverrides,
...platformOverrides[platform]
},
diagnostics
)
return {
path: 'Browser local storage',
platform,
exists: window.localStorage.getItem(KEYBINDINGS_STORAGE_KEY) !== null,
overrides,
commonOverrides,
platformOverrides,
diagnostics
}
}
function writeWebKeybindingAction(
actionId: KeybindingActionId,
bindings: string[] | null
): KeybindingFileSnapshot {
if (!isKeybindingActionId(actionId)) {
throw new Error(`Unknown keybinding action "${actionId}".`)
}
const normalizedBindings =
bindings === null ? null : normalizeKeybindingArrayForAction(actionId, bindings)
if (normalizedBindings !== null && !Array.isArray(normalizedBindings)) {
throw new Error(normalizedBindings.ok ? 'Unable to parse shortcut.' : normalizedBindings.error)
}
const platform = getWebKeybindingPlatform()
const currentSnapshot = getWebKeybindingSnapshot()
const candidateOverrides = { ...currentSnapshot.overrides }
if (normalizedBindings === null) {
delete candidateOverrides[actionId]
} else {
candidateOverrides[actionId] = normalizedBindings
}
const blockingConflict = findKeybindingConflicts(platform, candidateOverrides).find((conflict) =>
conflict.actionIds.includes(actionId)
)
if (blockingConflict) {
throw new Error(
`${formatKeybindingList([blockingConflict.binding], platform)} conflicts with another shortcut.`
)
}
const activePlatform: KeybindingOverrides = { ...currentSnapshot.platformOverrides[platform] }
if (normalizedBindings === null) {
delete activePlatform[actionId]
} else {
activePlatform[actionId] = normalizedBindings
}
writeJson(KEYBINDINGS_STORAGE_KEY, {
version: 1,
keybindings: currentSnapshot.commonOverrides,
platforms: {
...currentSnapshot.platformOverrides,
darwin: currentSnapshot.platformOverrides.darwin ?? {},
linux: currentSnapshot.platformOverrides.linux ?? {},
win32: currentSnapshot.platformOverrides.win32 ?? {},
[platform]: activePlatform
}
} satisfies WebKeybindingDocument)
const snapshot = getWebKeybindingSnapshot()
notifyWebKeybindingListeners(snapshot)
return snapshot
}
function notifyWebKeybindingListeners(snapshot: KeybindingFileSnapshot): void {
for (const listener of webKeybindingListeners) {
listener(snapshot)
}
}
function createWebKeybindingsApi(): WebKeybindingsApi {
return {
get: () => Promise.resolve(getWebKeybindingSnapshot()),
ensureFile: () => Promise.resolve(getWebKeybindingSnapshot()),
setAction: async ({ actionId, bindings }) => writeWebKeybindingAction(actionId, bindings),
reload: () => {
const snapshot = getWebKeybindingSnapshot()
notifyWebKeybindingListeners(snapshot)
return Promise.resolve(snapshot)
},
openFile: () => Promise.resolve(getWebKeybindingSnapshot()),
revealFile: () => Promise.resolve(getWebKeybindingSnapshot()),
onChanged: (callback) => {
webKeybindingListeners.add(callback)
const onStorage = (event: StorageEvent): void => {
if (event.key === KEYBINDINGS_STORAGE_KEY) {
callback(getWebKeybindingSnapshot())
}
}
window.addEventListener('storage', onStorage)
return () => {
webKeybindingListeners.delete(callback)
window.removeEventListener('storage', onStorage)
}
}
}
}
function createRuntimeApi(): NonNullable<Partial<PreloadApi>['runtime']> {
return {
syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(),
getStatus: () => getRemoteRuntimeStatus(),
call: ({ method, params }) => callRuntimeEnvelope(method, params),
getTerminalFitOverrides: () => Promise.resolve([]),
getTerminalDrivers: () => Promise.resolve([]),
getBrowserDrivers: () => Promise.resolve([]),
restoreTerminalFit: () => Promise.resolve({ restored: false }),
reclaimBrowserForDesktop: () => Promise.resolve({ reclaimed: false }),
onTerminalFitOverrideChanged: () => noopUnsubscribe,
onTerminalDriverChanged: () => noopUnsubscribe,
onBrowserDriverChanged: () => noopUnsubscribe
}
}
function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtimeEnvironments']> {
return {
list: async () => {
const environment = requireActiveEnvironmentOrNull()
return environment ? [redactStoredWebRuntimeEnvironment(environment)] : []
},
addFromPairingCode: async ({ name, pairingCode }) => {
const offer = parseWebPairingInput(pairingCode)
if (!offer) {
throw new Error('Invalid Orca pairing code.')
}
closeActiveRuntimeClients()
activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer })
saveStoredWebRuntimeEnvironment(activeEnvironment)
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
},
resolve: async ({ selector }) =>
redactStoredWebRuntimeEnvironment(resolveEnvironment(selector)),
remove: async ({ selector }) => {
const environment = resolveEnvironment(selector)
if (activeEnvironment?.id === environment.id) {
disconnectActiveRuntimeEnvironment()
}
return { removed: redactStoredWebRuntimeEnvironment(environment) }
},
getStatus: ({ selector, timeoutMs }) =>
callEnvironmentEnvelope<RuntimeStatus>(selector, 'status.get', undefined, timeoutMs),
call: ({ selector, method, params, timeoutMs }) =>
callEnvironmentEnvelope(selector, method, params, timeoutMs),
subscribe: async ({ selector, method, params, timeoutMs }, callbacks) => {
const environment = resolveEnvironment(selector)
const client = getClientForEnvironment(environment)
return client.subscribe(method, params, callbacks, { timeoutMs })
}
}
}
function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
return {
list: async () => (await callRuntimeResult<{ repos: Repo[] }>('repo.list')).repos,
add: async ({ path, kind }) => callRuntimeResult('repo.add', { path, kind }),
remove: async ({ repoId }) => {
await callRuntimeResult('repo.rm', { repo: repoId })
cachedWorktrees = null
},
reorder: async ({ orderedIds }) => callRuntimeResult('repo.reorder', { orderedIds }),
update: async ({ repoId, updates }) =>
(await callRuntimeResult<{ repo: Repo }>('repo.update', { repo: repoId, updates })).repo,
pickFolder: () => Promise.resolve(null),
pickDirectory: () => Promise.resolve(null),
clone: async ({ url, destination }) =>
(await callRuntimeResult<{ repo: Repo }>('repo.clone', { url, destination }, 10 * 60_000))
.repo,
cloneAbort: () => Promise.resolve(),
addRemote: async ({ remotePath, displayName, kind }) => {
const result = await callRuntimeResult<{ repo: Repo }>('repo.add', {
path: remotePath,
kind
})
return displayName
? {
repo: await createReposApi().update({
repoId: result.repo.id,
updates: { displayName }
})
}
: result
},
create: async ({ parentPath, name, kind }) =>
callRuntimeResult('repo.create', { parentPath, name, kind }),
onCloneProgress: () => noopUnsubscribe,
getGitUsername: () => Promise.resolve(''),
getBaseRefDefault: async ({ repoId }) =>
callRuntimeResult('repo.baseRefDefault', { repo: repoId }),
searchBaseRefs: async ({ repoId, query, limit }) =>
(
await callRuntimeResult<{ refs: string[] }>('repo.searchRefs', {
repo: repoId,
query,
limit
})
).refs,
searchBaseRefDetails: async ({ repoId, query, limit }) => {
const result = await callRuntimeResult<{
refs: string[]
refDetails?: { refName: string; localBranchName: string }[]
}>('repo.searchRefs', {
repo: repoId,
query,
limit
})
return result.refDetails ?? result.refs.map(legacyBaseRefSearchResult)
},
onChanged: () => noopUnsubscribe
}
}
function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
return {
list: async ({ repoId }) =>
(
await callRuntimeResult<{ worktrees: Worktree[] }>('worktree.list', {
repo: repoId,
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
})
).worktrees,
listAll: () => listAllRuntimeWorktrees(),
create: async (args) => {
cachedWorktrees = null
return callRuntimeResult('worktree.create', {
repo: args.repoId,
name: args.name,
baseBranch: args.baseBranch,
branchNameOverride: args.branchNameOverride,
linkedIssue: args.linkedIssue,
linkedPR: args.linkedPR,
linkedLinearIssue: args.linkedLinearIssue,
linkedGitLabIssue: args.linkedGitLabIssue,
linkedGitLabMR: args.linkedGitLabMR,
displayName: args.displayName,
sparseCheckout: args.sparseCheckout,
pushTarget: args.pushTarget,
setupDecision: args.setupDecision,
createdWithAgent: args.createdWithAgent,
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder
})
},
resolvePrBase: async ({ repoId, prNumber, headRefName, isCrossRepository }) =>
callRuntimeResult('worktree.resolvePrBase', {
repo: repoId,
prNumber,
headRefName,
isCrossRepository
}),
resolveMrBase: async ({ repoId, mrIid, sourceBranch, isCrossRepository }) =>
callRuntimeResult('worktree.resolveMrBase', {
repo: repoId,
mrIid,
sourceBranch,
isCrossRepository
}),
remove: async ({ worktreeId, force }) => {
cachedWorktrees = null
await callRuntimeResult('worktree.rm', { worktree: worktreeId, force })
},
updateMeta: async ({ worktreeId, updates }) =>
(
await callRuntimeResult<{ worktree: Worktree }>('worktree.set', {
worktree: worktreeId,
...updates
})
).worktree,
listLineage: async () =>
(
await callRuntimeResult<{ lineage: Record<string, WorktreeLineage> }>(
'worktree.lineageList'
)
).lineage,
updateLineage: async ({ worktreeId, parentWorktreeId, noParent }) => {
cachedWorktrees = null
const result = await callRuntimeResult<{
worktree: Worktree & { lineage?: WorktreeLineage | null }
}>('worktree.set', {
worktree: worktreeId,
parentWorktree: parentWorktreeId,
noParent
})
return result.worktree.lineage ?? null
},
persistSortOrder: async ({ orderedIds }) => {
await callRuntimeResult('worktree.persistSortOrder', { orderedIds })
},
onChanged: () => noopUnsubscribe,
onBaseStatus: () => noopUnsubscribe,
onRemoteBranchConflict: () => noopUnsubscribe
}
}
function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
return {
readDir: async ({ dirPath }) => {
const file = await resolveRuntimeFilePath(dirPath)
return callRuntimeResult<DirEntry[]>('files.readDir', {
worktree: file.worktree.id,
relativePath: file.relativePath
})
},
readFile: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
return callRuntimeResult('files.readPreview', {
worktree: file.worktree.id,
relativePath: file.relativePath
})
},
listMarkdownDocuments: async ({ rootPath }) => {
const file = await resolveRuntimeFilePath(rootPath)
return callRuntimeResult('files.listMarkdownDocuments', { worktree: file.worktree.id })
},
writeFile: async ({ filePath, content }) => {
const file = await resolveRuntimeFilePath(filePath)
await callRuntimeResult('files.write', {
worktree: file.worktree.id,
relativePath: file.relativePath,
content
})
},
createFile: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
await callRuntimeResult('files.createFile', {
worktree: file.worktree.id,
relativePath: file.relativePath
})
},
createDir: async ({ dirPath }) => {
const file = await resolveRuntimeFilePath(dirPath)
await callRuntimeResult('files.createDir', {
worktree: file.worktree.id,
relativePath: file.relativePath
})
},
rename: async ({ oldPath, newPath }) => {
const oldFile = await resolveRuntimeFilePath(oldPath)
const newFile = await resolveRuntimeFilePath(newPath)
await callRuntimeResult('files.rename', {
worktree: oldFile.worktree.id,
oldRelativePath: oldFile.relativePath,
newRelativePath: newFile.relativePath
})
},
copy: async ({ sourcePath, destinationPath }) => {
const source = await resolveRuntimeFilePath(sourcePath)
const destination = await resolveRuntimeFilePath(destinationPath)
await callRuntimeResult('files.copy', {
worktree: source.worktree.id,
sourceRelativePath: source.relativePath,
destinationRelativePath: destination.relativePath
})
},
deletePath: async ({ targetPath, recursive }) => {
const file = await resolveRuntimeFilePath(targetPath)
await callRuntimeResult('files.delete', {
worktree: file.worktree.id,
relativePath: file.relativePath,
recursive
})
},
authorizeExternalPath: () => Promise.resolve(),
stat: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
return callRuntimeResult('files.stat', {
worktree: file.worktree.id,
relativePath: file.relativePath
})
},
listFiles: async ({ rootPath, excludePaths }) => {
const file = await resolveRuntimeFilePath(rootPath)
const result = await callRuntimeResult<{ files: { relativePath: string }[] }>(
'files.listAll',
{
worktree: file.worktree.id,
excludePaths
}
)
return result.files.map((entry) => entry.relativePath)
},
search: async (args) => {
const file = await resolveRuntimeFilePath(args.rootPath)
return callRuntimeResult<SearchResult>('files.search', {
worktree: file.worktree.id,
query: args.query,
caseSensitive: args.caseSensitive,
wholeWord: args.wholeWord,
useRegex: args.useRegex,
includePattern: args.includePattern,
excludePattern: args.excludePattern,
maxResults: args.maxResults
})
},
importExternalPaths: async () => ({ results: [] }),
stageExternalPathsForRuntimeUpload: async () => ({ sources: [] }),
resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }),
watchWorktree: () => Promise.resolve(),
unwatchWorktree: () => Promise.resolve(),
onFsChanged: () => noopUnsubscribe
}
}
function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
return {
status: async ({ worktreePath, includeIgnored }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.status', { worktree: worktree.id, includeIgnored })
},
checkIgnored: async ({ worktreePath, paths }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.checkIgnored', { worktree: worktree.id, paths })
},
history: async ({ worktreePath, limit, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.history', { worktree: worktree.id, limit, baseRef })
},
conflictOperation: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.conflictOperation', { worktree: worktree.id })
},
diff: async ({ worktreePath, filePath, staged, compareAgainstHead }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.diff', {
worktree: file.worktree.id,
filePath: file.relativePath,
staged,
compareAgainstHead
})
},
branchCompare: async ({ worktreePath, baseRef }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.branchCompare', { worktree: worktree.id, baseRef })
},
commitCompare: async ({ worktreePath, commitId }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.commitCompare', { worktree: worktree.id, commitId })
},
upstreamStatus: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.upstreamStatus', { worktree: worktree.id })
},
fetch: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.fetch', { worktree: worktree.id })
},
push: async ({ worktreePath, publish, pushTarget }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.push', { worktree: worktree.id, publish, pushTarget })
},
pull: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult('git.pull', { worktree: worktree.id })
},
branchDiff: async ({ worktreePath, filePath, compare, oldPath }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.branchDiff', {
worktree: file.worktree.id,
filePath: file.relativePath,
compare,
oldPath
})
},
commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
return callRuntimeResult('git.commitDiff', {
worktree: file.worktree.id,
filePath: file.relativePath,
commitOid,
parentOid,
oldPath
})
},
commit: async ({ worktreePath, message }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.commit', { worktree: worktree.id, message })
},
generateCommitMessage: async () => ({
success: false,
error: 'Commit message generation is unavailable in the web client.'
}),
discoverCommitMessageModels: async () => ({
success: false,
error: 'Commit message model discovery is unavailable in the web client.'
}),
cancelGenerateCommitMessage: () => Promise.resolve(),
generatePullRequestFields: async () => ({
success: false,
error: 'Pull request detail generation is unavailable in the web client.'
}),
cancelGeneratePullRequestFields: () => Promise.resolve(),
stage: async ({ worktreePath, filePath }) => mutateGitPath('git.stage', worktreePath, filePath),
bulkStage: async ({ worktreePath, filePaths }) =>
mutateGitPaths('git.bulkStage', worktreePath, filePaths),
unstage: async ({ worktreePath, filePath }) =>
mutateGitPath('git.unstage', worktreePath, filePath),
bulkUnstage: async ({ worktreePath, filePaths }) =>
mutateGitPaths('git.bulkUnstage', worktreePath, filePaths),
discard: async ({ worktreePath, filePath }) =>
mutateGitPath('git.discard', worktreePath, filePath),
bulkDiscard: async ({ worktreePath, filePaths }) => {
for (const filePath of filePaths) {
await mutateGitPath('git.discard', worktreePath, filePath)
}
},
remoteFileUrl: async ({ worktreePath, relativePath, line }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.remoteFileUrl', {
worktree: worktree.id,
relativePath,
line
})
}
}
}
function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']> {
return {
registerGuest: () => Promise.resolve(),
unregisterGuest: () => Promise.resolve(),
openDevTools: () => Promise.resolve(false),
setViewportOverride: () => Promise.resolve(false),
setAnnotationViewportBridge: () => Promise.resolve(false),
onGuestLoadFailed: () => noopUnsubscribe,
onPermissionDenied: () => noopUnsubscribe,
onPopup: () => noopUnsubscribe,
onDownloadRequested: () => noopUnsubscribe,
onDownloadProgress: () => noopUnsubscribe,
onDownloadFinished: () => noopUnsubscribe,
onContextMenuRequested: () => noopUnsubscribe,
onContextMenuDismissed: () => noopUnsubscribe,
onNavigationUpdate: () => noopUnsubscribe,
onActivateView: () => noopUnsubscribe,
onPaneFocus: () => noopUnsubscribe,
onOpenLinkInOrcaTab: () => noopUnsubscribe,
acceptDownload: () =>
Promise.resolve({ ok: false, reason: 'Downloads are handled by the server browser.' }),
cancelDownload: () => Promise.resolve(false),
setGrabMode: () =>
Promise.resolve({ ok: false, error: 'Grab mode is unavailable in the web client.' }),
awaitGrabSelection: () =>
Promise.resolve({ ok: false, error: 'Grab mode is unavailable in the web client.' }),
cancelGrab: () => Promise.resolve(false),
captureSelectionScreenshot: () =>
Promise.resolve({
ok: false,
error: 'Selection screenshots are unavailable in the web client.'
}),
extractHoverPayload: () =>
Promise.resolve({ ok: false, error: 'Hover extraction is unavailable in the web client.' }),
onGrabModeToggle: () => noopUnsubscribe,
onGrabActionShortcut: () => noopUnsubscribe,
sessionListProfiles: () => Promise.resolve([]),
sessionCreateProfile: () => Promise.resolve(null),
sessionDeleteProfile: () => Promise.resolve(false),
sessionImportCookies: () =>
Promise.resolve({
ok: false,
summary: null,
error: 'Cookie import is unavailable in the web client.'
}),
sessionResolvePartition: () => Promise.resolve(null),
sessionDetectBrowsers: () => Promise.resolve([]),
sessionImportFromBrowser: () =>
Promise.resolve({
ok: false,
summary: null,
error: 'Cookie import is unavailable in the web client.'
}),
sessionClearDefaultCookies: () => Promise.resolve(false),
notifyActiveTabChanged: () => Promise.resolve(false)
} as unknown as NonNullable<Partial<PreloadApi>['browser']>
}
function createGitHubApi(): NonNullable<Partial<PreloadApi>['gh']> {
const direct = (method: string) => (args?: unknown) =>
callRuntimeResult(method, mapRepoPathArg(args))
return {
viewer: () => Promise.resolve(null),
repoSlug: direct('github.repoSlug'),
prForBranch: direct('github.prForBranch'),
refreshPRNow: async ({ candidate }) => {
const pr = await callRuntimeResult('github.prForBranch', {
repo: candidate.repoId || candidate.repoPath,
repoPath: candidate.repoPath,
branch: candidate.branch,
linkedPRNumber: candidate.linkedPRNumber ?? null,
fallbackPRNumber: candidate.fallbackPRNumber ?? null
})
return pr
? { kind: 'found', pr, fetchedAt: Date.now() }
: { kind: 'no-pr', fetchedAt: Date.now() }
},
enqueuePRRefresh: () => Promise.resolve(false),
reportVisiblePRRefreshCandidates: () => Promise.resolve(false),
onPRRefreshEvent: () => noopUnsubscribe,
issue: direct('github.issue'),
workItem: direct('github.workItem'),
workItemByOwnerRepo: direct('github.workItemByOwnerRepo'),
workItemDetails: direct('github.workItemDetails'),
prFileContents: direct('github.prFileContents'),
listIssues: async () => [],
createIssue: direct('github.createIssue'),
countWorkItems: direct('github.countWorkItems'),
listWorkItems: direct('github.listWorkItems'),
prChecks: direct('github.prChecks'),
prCheckDetails: direct('github.prCheckDetails'),
rerunPRChecks: direct('github.rerunPRChecks'),
prComments: direct('github.prComments'),
resolveReviewThread: direct('github.resolveReviewThread'),
setPRFileViewed: direct('github.setPRFileViewed'),
updatePRTitle: direct('github.updatePRTitle'),
mergePR: direct('github.mergePR'),
updatePRState: direct('github.updatePRState'),
requestPRReviewers: direct('github.requestPRReviewers'),
removePRReviewers: direct('github.removePRReviewers'),
updateIssue: direct('github.updateIssue'),
addIssueComment: direct('github.addIssueComment'),
addPRReviewCommentReply: direct('github.addPRReviewCommentReply'),
addPRReviewComment: direct('github.addPRReviewComment'),
listLabels: direct('github.listLabels'),
listAssignableUsers: direct('github.listAssignableUsers'),
onWorkItemMutated: () => noopUnsubscribe,
checkOrcaStarred: () => Promise.resolve(null),
starOrca: () => Promise.resolve(false),
rateLimit: direct('github.rateLimit'),
diagnoseAuth: () =>
Promise.resolve({ ok: false, message: 'Unavailable in the web client.' } as never),
listAccessibleProjects: direct('github.project.listAccessible'),
resolveProjectRef: direct('github.project.resolveRef'),
listProjectViews: direct('github.project.listViews'),
getProjectViewTable: direct('github.project.viewTable'),
projectWorkItemDetailsBySlug: direct('github.project.workItemDetailsBySlug'),
updateProjectItemField: direct('github.project.updateItemField'),
clearProjectItemField: direct('github.project.clearItemField'),
updateIssueBySlug: direct('github.project.updateIssueBySlug'),
updatePullRequestBySlug: direct('github.project.updatePullRequestBySlug'),
addIssueCommentBySlug: direct('github.project.addIssueCommentBySlug'),
updateIssueCommentBySlug: direct('github.project.updateIssueCommentBySlug'),
deleteIssueCommentBySlug: direct('github.project.deleteIssueCommentBySlug'),
listLabelsBySlug: direct('github.project.listLabelsBySlug'),
listAssignableUsersBySlug: direct('github.project.listAssignableUsersBySlug'),
listIssueTypesBySlug: direct('github.project.listIssueTypesBySlug'),
updateIssueTypeBySlug: direct('github.project.updateIssueTypeBySlug')
} as NonNullable<Partial<PreloadApi>['gh']>
}
function createRuntimeNamespaceApi(prefix: string): never {
return createFallbackProxy([prefix], (path, args) => {
const method = `${prefix}.${path.at(-1) ?? ''}`
return callRuntimeResult(method, mapRuntimeNamespaceArg(prefix, args[0]))
}) as never
}
function createHooksApi(): NonNullable<Partial<PreloadApi>['hooks']> {
return {
check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }),
inspectSetupScriptImports: async ({ repoId }) =>
callRuntimeResult('repo.setupScriptImports', { repo: repoId }),
createIssueCommandRunner: async () => ({ launched: false }) as never,
readIssueCommand: async ({ repoId }) =>
callRuntimeResult('repo.issueCommandRead', { repo: repoId }),
writeIssueCommand: async ({ repoId, content }) => {
await callRuntimeResult('repo.issueCommandWrite', { repo: repoId, content })
}
}
}
function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
let zoomLevel = readLocalWebUIState().uiZoomLevel
return {
get: async () => {
try {
const result = await callRuntimeResult<{ ui: PersistedUIState }>(
'ui.get',
undefined,
15_000
)
const next = mergeWebUIState(readLocalWebUIState(), result.ui)
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
return next
} catch {
return readLocalWebUIState()
}
},
set: async (updates) => {
const next = mergeWebUIState(readLocalWebUIState(), updates)
writeJson(UI_STORAGE_KEY, next)
zoomLevel = next.uiZoomLevel
try {
await callRuntimeResult('ui.set', updates, 15_000)
} catch {
// Why: unpaired/offline web clients still need local UI persistence.
}
},
readClipboardText: () => navigator.clipboard?.readText?.() ?? Promise.resolve(''),
readSelectionClipboardText: () =>
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
saveClipboardImageAsTempFile: (_args?: { connectionId?: string | null }) =>
Promise.resolve(null),
writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(),
writeSelectionClipboardText: () =>
Promise.reject(new Error('Selection clipboard is unavailable in the web client')),
writeClipboardImage: () => Promise.resolve(),
getZoomLevel: () => zoomLevel,
setZoomLevel: (level) => {
zoomLevel = level
},
isMaximized: () => Promise.resolve(false),
onOpenSettings: () => noopUnsubscribe,
onOpenFeatureTour: () => noopUnsubscribe,
onOpenCrashReport: () => noopUnsubscribe,
onShowFeatureTourNudge: () => noopUnsubscribe,
onToggleLeftSidebar: () => noopUnsubscribe,
onToggleRightSidebar: () => noopUnsubscribe,
onToggleWorktreePalette: () => noopUnsubscribe,
onToggleFloatingTerminal: () => noopUnsubscribe,
onTerminalShortcutCaptured: () => noopUnsubscribe,
onOpenQuickOpen: () => noopUnsubscribe,
onOpenTasks: () => noopUnsubscribe,
onOpenNewWorkspace: () => noopUnsubscribe,
onJumpToWorktreeIndex: () => noopUnsubscribe,
onWorktreeHistoryNavigate: () => noopUnsubscribe,
onNewBrowserTab: () => noopUnsubscribe,
onRequestTabCreate: () => noopUnsubscribe,
replyTabCreate: () => {},
onRequestTabSetProfile: () => noopUnsubscribe,
replyTabSetProfile: () => {},
onRequestTabClose: () => noopUnsubscribe,
replyTabClose: () => {},
onNewTerminalTab: () => noopUnsubscribe,
onFocusBrowserAddressBar: () => noopUnsubscribe,
onFindInBrowserPage: () => noopUnsubscribe,
onReloadBrowserPage: () => noopUnsubscribe,
onHardReloadBrowserPage: () => noopUnsubscribe,
onCloseActiveTab: () => noopUnsubscribe,
onSwitchTab: () => noopUnsubscribe,
onSwitchTabAcrossAllTypes: () => noopUnsubscribe,
onSwitchRecentTab: () => noopUnsubscribe,
onSwitchTerminalTab: () => noopUnsubscribe,
onCtrlTabKeyDown: () => noopUnsubscribe,
onCtrlTabKeyUp: () => noopUnsubscribe,
onToggleStatusBar: () => noopUnsubscribe,
onDictationKeyDown: () => noopUnsubscribe,
onExportPdfRequested: () => noopUnsubscribe,
onActivateWorktree: () => noopUnsubscribe,
onCreateTerminal: () => noopUnsubscribe,
onRequestTerminalCreate: () => noopUnsubscribe,
replyTerminalCreate: () => {},
onSplitTerminal: () => noopUnsubscribe,
onRenameTerminal: () => noopUnsubscribe,
onFocusTerminal: () => noopUnsubscribe,
onFocusEditorTab: () => noopUnsubscribe,
onCloseSessionTab: () => noopUnsubscribe,
onMoveSessionTab: () => noopUnsubscribe,
onOpenFileFromMobile: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,
onSleepWorktree: () => noopUnsubscribe,
onTerminalZoom: () => noopUnsubscribe,
onFileDrop: () => noopUnsubscribe,
syncTrafficLights: () => {},
setMarkdownEditorFocused: () => {},
setTerminalInputFocused: () => {},
setFloatingTerminalInputFocused: () => {},
onRichMarkdownContextCommand: () => noopUnsubscribe,
onFullscreenChanged: () => noopUnsubscribe,
minimize: () => {},
maximize: () => {},
onMaximizeChanged: () => noopUnsubscribe,
requestClose: () => {},
popupMenu: () => {},
onWindowCloseRequested: () => noopUnsubscribe,
confirmWindowClose: () => {}
}
}
function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
const fallbackStatus: PreflightStatus = {
git: { installed: false },
gh: { installed: false, authenticated: false },
glab: { installed: false, authenticated: false },
bitbucket: { configured: false, authenticated: false, account: null },
azureDevOps: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
},
gitea: {
configured: false,
authenticated: false,
account: null,
baseUrl: null,
tokenConfigured: false
}
}
const fallbackRefreshAgents: RefreshAgentsResult = {
agents: [],
addedPathSegments: [],
shellHydrationOk: false,
pathSource: 'sync_seed_only',
pathFailureReason: 'spawn_error'
}
return {
check: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return fallbackStatus
}
return callRuntimeResult<PreflightStatus>('preflight.check', args)
},
detectAgents: async () => {
if (!requireActiveEnvironmentOrNull()) {
return []
}
return callRuntimeResult<string[]>('preflight.detectAgents').catch(() => [])
},
refreshAgents: () =>
requireActiveEnvironmentOrNull()
? callRuntimeResult('preflight.refreshAgents')
.then((result) => result as RefreshAgentsResult)
.catch(() => fallbackRefreshAgents)
: Promise.resolve(fallbackRefreshAgents),
detectRemoteAgents: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(() => [])
: []
}
}
function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
const status = {
platform: getBrowserPlatform(),
commandName: 'orca',
commandPath: null,
pathDirectory: null,
pathConfigured: false,
launcherPath: null,
installMethod: null,
supported: false,
state: 'unsupported',
currentTarget: null,
unsupportedReason: 'launch_mode_unavailable',
detail: 'CLI registration is managed on the Orca server, not in the web browser.'
} as const
return {
getInstallStatus: () => Promise.resolve(status),
install: () => Promise.resolve(status),
remove: () => Promise.resolve(status),
getWslInstallStatus: () => Promise.resolve(status),
installWsl: () => Promise.resolve(status),
removeWsl: () => Promise.resolve(status)
} as NonNullable<Partial<PreloadApi>['cli']>
}
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
const status = (
agent:
| 'claude'
| 'codex'
| 'gemini'
| 'antigravity'
| 'cursor'
| 'droid'
| 'grok'
| 'copilot'
| 'hermes'
) =>
Promise.resolve({
agent,
state: 'not_installed',
configPath: '',
managedHooksPresent: false,
detail: 'Agent hook status is only available on the Orca server.'
} as const)
return {
claudeStatus: () => status('claude'),
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
antigravityStatus: () => status('antigravity'),
cursorStatus: () => status('cursor'),
droidStatus: () => status('droid'),
grokStatus: () => status('grok'),
copilotStatus: () => status('copilot'),
hermesStatus: () => status('hermes')
}
}
function createDeveloperPermissionsApi(): NonNullable<Partial<PreloadApi>['developerPermissions']> {
return {
getStatus: () => Promise.resolve([]),
request: ({ id }) =>
Promise.resolve({ id, status: 'unsupported', openedSystemSettings: false } as const),
openSettings: () => Promise.resolve()
}
}
function createComputerUsePermissionsApi(): NonNullable<
Partial<PreloadApi>['computerUsePermissions']
> {
return {
getStatus: () =>
Promise.resolve({
platform: getBrowserPlatform(),
helperAppPath: null,
helperUnavailableReason: 'web_client',
permissions: []
}),
openSetup: () =>
Promise.resolve({
platform: getBrowserPlatform(),
helperAppPath: null,
openedSettings: false,
launchedHelper: false,
nextStep: 'Computer-use permissions are managed on the Orca server.'
})
}
}
function createNotificationsApi(): NonNullable<Partial<PreloadApi>['notifications']> {
return {
dispatch: () => Promise.resolve({ delivered: false, reason: 'not-supported' }),
openSystemSettings: () => Promise.resolve(),
getPermissionStatus: () =>
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
requestPermission: () =>
Promise.resolve({ supported: false, platform: getBrowserPlatform(), requested: false }),
playSound: () => Promise.resolve({ played: false, reason: 'missing-path' })
}
}
function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimits']> {
const empty: RateLimitState = {
claude: null,
codex: null,
gemini: null,
opencodeGo: null,
inactiveClaudeAccounts: [],
inactiveCodexAccounts: []
}
return {
get: () => Promise.resolve(empty),
refresh: () => Promise.resolve(empty),
setPollingInterval: () => Promise.resolve(),
fetchInactiveClaudeAccounts: () => Promise.resolve(),
fetchInactiveCodexAccounts: () => Promise.resolve(),
onUpdate: () => noopUnsubscribe
}
}
function createAccountsApi(): never {
const empty = { accounts: [], activeAccountId: null }
return {
list: () => Promise.resolve(empty),
add: () => Promise.resolve(empty),
reauthenticate: () => Promise.resolve(empty),
remove: () => Promise.resolve(empty),
select: () => Promise.resolve(empty)
} as never
}
function createUpdaterApi(): NonNullable<Partial<PreloadApi>['updater']> {
return {
getVersion: () => Promise.resolve('web'),
getStatus: () => Promise.resolve({ state: 'idle' } as never),
check: () => Promise.resolve(),
download: () => Promise.resolve(),
quitAndInstall: () => Promise.resolve(),
dismissNudge: () => Promise.resolve(),
onStatus: () => noopUnsubscribe,
onClearDismissal: () => noopUnsubscribe
}
}
function createShellApi(): NonNullable<Partial<PreloadApi>['shell']> {
const openResult = { ok: true } as const
return {
openPath: (path) =>
Promise.resolve(window.open(path, '_blank', 'noopener,noreferrer') as never),
openInFileManager: () => Promise.resolve(openResult),
openInExternalEditor: () => Promise.resolve(openResult),
openUrl: (url) => Promise.resolve(window.open(url, '_blank', 'noopener,noreferrer') as never),
openFilePath: () => Promise.resolve(),
openFileUri: (uri) =>
Promise.resolve(window.open(uri, '_blank', 'noopener,noreferrer') as never),
pathExists: async (path) => {
try {
await resolveRuntimeFilePath(path)
return true
} catch {
return false
}
},
pickAttachment: () => Promise.resolve(null),
pickImage: () => Promise.resolve(null),
pickAudio: () => Promise.resolve(null),
pickDirectory: () => Promise.resolve(null),
copyFile: () => Promise.resolve()
}
}
function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
return {
spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')),
write: () => {},
writeAccepted: () => Promise.resolve(false),
resize: () => {},
reportGeometry: () => {},
signal: () => {},
kill: () => Promise.resolve(),
ackColdRestore: () => {},
hasChildProcesses: () => Promise.resolve(false),
getForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'),
listSessions: () => Promise.resolve([]),
onData: () => noopUnsubscribe,
onReplay: () => noopUnsubscribe,
onExit: () => noopUnsubscribe,
onSerializeBufferRequest: () => noopUnsubscribe,
onClearBufferRequest: () => noopUnsubscribe,
sendSerializedBuffer: () => {},
declarePendingPaneSerializer: () => Promise.resolve(0),
settlePaneSerializer: () => Promise.resolve(),
clearPendingPaneSerializer: () => Promise.resolve(),
management: {
listSessions: () => Promise.resolve({ sessions: [] }),
killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0 }),
killOne: () => Promise.resolve({ success: false }),
restart: () => Promise.resolve({ success: false })
}
}
}
function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
return {
listTargets: () => Promise.resolve([]),
addTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
updateTarget: () =>
Promise.reject(new Error('SSH target management is unavailable in the web client.')),
removeTarget: () => Promise.resolve(),
importConfig: () => Promise.resolve([]),
connect: () => Promise.resolve(null),
disconnect: () => Promise.resolve(),
terminateSessions: () => Promise.resolve(),
resetRelay: () => Promise.resolve(),
getState: () => Promise.resolve(null),
needsPassphrasePrompt: () => Promise.resolve(false),
testConnection: () =>
Promise.resolve({ success: false, error: 'Unavailable in the web client.' }),
onStateChanged: () => noopUnsubscribe,
addPortForward: () =>
Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')),
updatePortForward: () =>
Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')),
removePortForward: () => Promise.resolve(null),
listPortForwards: () => Promise.resolve([]),
listDetectedPorts: () => Promise.resolve([]),
onPortForwardsChanged: () => noopUnsubscribe,
onDetectedPortsChanged: () => noopUnsubscribe,
browseDir: () => Promise.resolve({ entries: [], resolvedPath: '' }),
onCredentialRequest: () => noopUnsubscribe,
onCredentialResolved: () => noopUnsubscribe,
submitCredential: () => Promise.resolve()
}
}
async function callRuntimeEnvelope<TResult = unknown>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = requireActiveEnvironment()
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
getClientForEnvironment(environment).call(method, params, { timeoutMs })
)
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
async function callEnvironmentEnvelope<TResult = unknown>(
selector: string,
method: string,
params?: unknown,
timeoutMs?: number
): Promise<RuntimeRpcResponse<TResult>> {
const environment = resolveEnvironment(selector)
const response = await runtimeCallQueuePool.enqueue(environment.id, method, () =>
getClientForEnvironment(environment).call(method, params, { timeoutMs })
)
updateEnvironmentFromResponse(environment, response)
return response as RuntimeRpcResponse<TResult>
}
async function callRuntimeResult<TResult>(
method: string,
params?: unknown,
timeoutMs?: number
): Promise<TResult> {
const response = await callRuntimeEnvelope(method, params, timeoutMs)
if (!response.ok) {
throw new Error(response.error.message)
}
return response.result as TResult
}
async function getRemoteRuntimeStatus(): Promise<RuntimeStatus> {
return callRuntimeResult<RuntimeStatus>('status.get', undefined, 15_000)
}
function getClientForEnvironment(environment: StoredWebRuntimeEnvironment): WebRuntimeClient {
if (!activeClient || activeClientEnvironmentId !== environment.id) {
activeClient?.close()
activeClient = new WebRuntimeClient(getPreferredWebPairingOffer(environment))
activeClientEnvironmentId = environment.id
}
return activeClient
}
function closeActiveRuntimeClients(): void {
activeClient?.close()
activeClient = null
activeClientEnvironmentId = null
cachedWorktrees = null
}
function disconnectActiveRuntimeEnvironment(): void {
closeActiveRuntimeClients()
clearStoredWebRuntimeEnvironment()
activeEnvironment = null
}
function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
const environment = requireActiveEnvironment()
if (selector === environment.id || selector === environment.name || selector === 'active') {
return environment
}
if (selector.startsWith('web-') && environment.id.startsWith('web-')) {
// Why: persisted terminal ids can outlive a web-client re-pair, which creates
// a fresh web-* environment id even when it points at the same active server.
return environment
}
throw new Error(`Unknown Orca runtime environment: ${selector}`)
}
function requireActiveEnvironment(): StoredWebRuntimeEnvironment {
activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()
if (!activeEnvironment) {
throw new Error('Pair this web client with an Orca server first.')
}
return activeEnvironment
}
function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment | null {
activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment()
return activeEnvironment
}
function updateEnvironmentFromResponse(
environment: StoredWebRuntimeEnvironment,
response: RuntimeRpcResponse<unknown>
): void {
const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null)
activeEnvironment = updateStoredEnvironmentRuntimeId(environment, runtimeId)
}
function getStoredSettings(): GlobalSettings {
const environment = (activeEnvironment = activeEnvironment ?? readStoredWebRuntimeEnvironment())
const defaults = getDefaultSettings('~')
const stored = readJson<Partial<GlobalSettings>>(SETTINGS_STORAGE_KEY, {})
return mergeSettings(
{
...defaults,
floatingTerminalEnabled: false,
rightSidebarOpenByDefault: false,
activeRuntimeEnvironmentId: environment?.id ?? null
},
stored
)
}
function getStoredOnboarding(): OnboardingState {
const storedRaw = window.localStorage.getItem(ONBOARDING_STORAGE_KEY)
if (storedRaw) {
const stored = readJson(ONBOARDING_STORAGE_KEY, getDefaultOnboardingState())
if (stored.checklist.dismissed) {
return stored
}
const closed = closeWebOnboarding(stored)
writeJson(ONBOARDING_STORAGE_KEY, closed)
return closed
}
const closed = closeWebOnboarding(getDefaultOnboardingState())
// Why: pairing already means the user has an Orca server. Desktop first-run
// onboarding would incorrectly probe browser-local tools and block the client.
writeJson(ONBOARDING_STORAGE_KEY, closed)
return closed
}
function getStoredWorkspaceSession(): WorkspaceSessionState {
const localSession = sanitizeWebRuntimeWorkspaceSession(
readJson(SESSION_STORAGE_KEY, getDefaultWorkspaceSession())
)
if (!requireActiveEnvironmentOrNull()) {
return localSession
}
const ui = readLocalWebUIState()
// Why: paired web clients mirror host session-tabs after startup. Replaying
// browser-local terminal handles first creates stale remote PTYs and errors.
return sanitizeWebRuntimeWorkspaceSession({
...getDefaultWorkspaceSession(),
activeRepoId: ui.lastActiveRepoId,
activeWorktreeId: ui.lastActiveWorktreeId,
lastVisitedAtByWorktreeId: localSession.lastVisitedAtByWorktreeId
})
}
function closeWebOnboarding(base: OnboardingState): OnboardingState {
return {
...base,
closedAt: Date.now(),
outcome: 'dismissed',
checklist: {
...base.checklist,
dismissed: true
}
}
}
function readLocalWebUIState(): PersistedUIState {
return mergeWebUIState(
getDefaultUIState(),
readJson<Partial<PersistedUIState>>(UI_STORAGE_KEY, {})
)
}
function mergeWebUIState(
base: PersistedUIState,
updates: Partial<PersistedUIState>
): PersistedUIState {
return {
...base,
...updates
}
}
function mergeSettings(base: GlobalSettings, updates: Partial<GlobalSettings>): GlobalSettings {
const defaults = getDefaultSettings('~')
return {
...base,
...updates,
notifications: {
...base.notifications,
...updates.notifications
},
githubProjects: {
...(base.githubProjects ?? defaults.githubProjects),
...updates.githubProjects
} as GlobalSettings['githubProjects'],
voice: {
...(base.voice ?? defaults.voice),
...updates.voice
} as NonNullable<GlobalSettings['voice']>,
activeRuntimeEnvironmentId: activeEnvironment?.id ?? updates.activeRuntimeEnvironmentId ?? null
}
}
async function listAllRuntimeWorktrees(): Promise<Worktree[]> {
if (cachedWorktrees && Date.now() - cachedWorktrees.loadedAt < 5_000) {
return cachedWorktrees.worktrees
}
const result = await callRuntimeResult<{ worktrees: Worktree[] }>('worktree.list', {
limit: WEB_RUNTIME_WORKTREE_LIST_LIMIT
})
cachedWorktrees = { loadedAt: Date.now(), worktrees: result.worktrees }
return result.worktrees
}
async function resolveRuntimeWorktreeByPath(worktreePath: string): Promise<Worktree> {
const worktrees = await listAllRuntimeWorktrees()
const match = worktrees
.map((worktree) => ({
worktree,
relativePath: relativePathInsideRoot(worktree.path, worktreePath)
}))
.filter((entry) => entry.relativePath !== null)
.sort((a, b) => b.worktree.path.length - a.worktree.path.length)[0]
if (!match) {
throw new Error(`No runtime worktree owns ${worktreePath}`)
}
return match.worktree
}
async function resolveRuntimeFilePath(
filePath: string,
preferredWorktreePath?: string
): Promise<{ worktree: Worktree; relativePath: string }> {
const worktree = preferredWorktreePath
? await resolveRuntimeWorktreeByPath(preferredWorktreePath)
: await resolveRuntimeWorktreeByPath(filePath)
const relativePath = relativePathInsideRoot(worktree.path, filePath)
if (relativePath === null) {
throw new Error(`File is outside runtime worktree: ${filePath}`)
}
return { worktree, relativePath }
}
async function mutateGitPath(
method: string,
worktreePath: string,
filePath: string
): Promise<void> {
const file = await resolveRuntimeFilePath(filePath, worktreePath)
await callRuntimeResult(method, { worktree: file.worktree.id, filePath: file.relativePath })
}
async function mutateGitPaths(
method: string,
worktreePath: string,
filePaths: string[]
): Promise<void> {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
await callRuntimeResult(method, { worktree: worktree.id, filePaths })
}
function mapRepoPathArg(args: unknown): unknown {
if (!args || typeof args !== 'object' || !('repoPath' in args)) {
return args
}
const record = args as Record<string, unknown>
const repoId = typeof record.repoId === 'string' && record.repoId.trim() ? record.repoId : null
return {
...record,
// Why: runtime repo selectors accept loose path/name forms, but duplicate
// checked-out repos can make those ambiguous. The renderer already passes
// Orca's repo id on task calls, so prefer the explicit selector.
repo: repoId ? `id:${repoId}` : record.repoPath
}
}
function mapRuntimeNamespaceArg(prefix: string, args: unknown): unknown {
if (prefix !== 'hostedReview') {
return args
}
return mapRepoPathArg(args)
}
function createEmptyMemorySnapshot(): MemorySnapshot {
const emptyUsage = { cpu: 0, memory: 0 }
return {
app: { ...emptyUsage, main: emptyUsage, renderer: emptyUsage, other: emptyUsage, history: [] },
worktrees: [],
host: {
totalMemory: 0,
freeMemory: 0,
usedMemory: 0,
memoryUsagePercent: 0,
cpuCoreCount: navigator.hardwareConcurrency || 1,
loadAverage1m: 0
},
totalCpu: 0,
totalMemory: 0,
collectedAt: Date.now()
}
}
function getBrowserPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Windows')) {
return 'win32'
}
if (navigator.userAgent.includes('Linux')) {
return 'linux'
}
return 'darwin'
}
function readJson<T>(key: string, fallback: T): T {
const raw = window.localStorage.getItem(key)
if (!raw) {
return cloneJson(fallback)
}
try {
return { ...cloneJson(fallback), ...JSON.parse(raw) } as T
} catch {
return cloneJson(fallback)
}
}
function writeJson<T>(key: string, value: T): void {
window.localStorage.setItem(key, JSON.stringify(value))
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function withFallback<T extends object>(target: T, path: string[]): T {
return new Proxy(target, {
get(current, property, receiver) {
if (property in current) {
const value = Reflect.get(current, property, receiver) as unknown
if (value && typeof value === 'object' && !Array.isArray(value)) {
return withFallback(value as object, [...path, String(property)])
}
return value
}
return createFallbackProxy([...path, String(property)])
}
})
}
function createFallbackProxy(
path: string[],
applyOverride?: (path: string[], args: unknown[]) => unknown
): never {
const fn = () => undefined
return new Proxy(fn, {
get(_target, property) {
if (property === 'then') {
return undefined
}
return createFallbackProxy([...path, String(property)], applyOverride)
},
apply(_target, _thisArg, args) {
if (applyOverride) {
return applyOverride(path, args)
}
return getFallbackResult(path, args)
}
}) as never
}
function getFallbackResult(path: string[], args: unknown[]): unknown {
const name = path.at(-1) ?? ''
if (name.startsWith('on')) {
return noopUnsubscribe
}
if (name.startsWith('is') || name.startsWith('has') || name === 'pathExists') {
return Promise.resolve(false)
}
if (name.startsWith('list') || name.startsWith('detect')) {
return Promise.resolve([])
}
if (name.startsWith('preview')) {
return Promise.resolve({ found: false, diff: {}, unsupportedKeys: [] })
}
if (name.startsWith('get') && name.endsWith('Status')) {
return Promise.resolve([])
}
if (name === 'write' || name === 'resize' || name === 'reportGeometry') {
return undefined
}
if (args.length === 0 && (name === 'getZoomLevel' || name === 'declarePendingPaneSerializer')) {
return 0
}
return Promise.resolve(undefined)
}
function noopUnsubscribe(): void {}