From 4eefe642ed0ded2d215efffe3f8358296fea5a39 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 30 Aug 2026 01:19:30 -0400 Subject: [PATCH] fix(mobile): close hosted runtime review blockers --- config/scripts/pr-code-change-scope.mjs | 17 +- mobile/app/index.tsx | 1388 +----- .../mobile-web-route-error-boundary.tsx | 1 + .../MobileWebBridgeDocumentUrl.kt | 2 +- .../MobileWebDebugIsolationProbe.kt | 2 +- .../modules/mobilewebshell/MobileWebOrigin.kt | 21 + .../mobilewebshell/MobileWebPackageStore.kt | 2 +- .../mobilewebshell/MobileWebShellView.kt | 24 +- .../MobileWebBridgeDocumentUrlTest.kt | 19 +- .../use-mobile-web-package-recovery.ts | 5 +- .../mobile-e2ee-v2-physical-channel.ts | 3 +- src/main/runtime/orca-runtime.ts | 24 +- src/main/runtime/rpc/methods/terminal.ts | 3878 +---------------- .../rpc/methods/terminal/stream-schemas.ts | 2 + .../terminal/terminal-input-delivery.ts | 4 +- .../terminal-legacy-binary-control-frames.ts | 9 +- .../terminal-legacy-subscribe-snapshot.ts | 4 +- .../terminal-legacy-subscription-types.ts | 1 + .../terminal-multiplex-initial-snapshot.ts | 7 +- .../terminal-multiplex-slot-frames.ts | 8 +- ...erminal-multiplex-stream-initialization.ts | 1 + .../methods/terminal/terminal-stream-types.ts | 1 + .../terminal/terminal-subscribe-method.ts | 1 + src/mobile-web/src/native-shell-channel.ts | 6 + 24 files changed, 125 insertions(+), 5305 deletions(-) create mode 100644 mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 6e502f2d2f2..4fa54ec0829 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -215,6 +215,18 @@ const DESKTOP_IRRELEVANT_PREFIXES = [ '.github/workflows/mobile-android-release.yml' ] +// The hosted mobile UI is packaged and served by the desktop runtime; changes +// here must keep desktop typecheck/package jobs enabled even though most RN +// sources remain mobile-only. +const DESKTOP_RELEVANT_MOBILE_PREFIXES = [ + 'mobile/host-web-app/', + 'mobile/packages/expo-mobile-web-shell/', + 'src/mobile-web/', + 'src/shared/mobile-web/', + 'config/scripts/package-mobile-web-rnw.mjs', + 'config/scripts/verify-mobile-web-rnw-build.mjs' +] + export function isDocsOnlyPath(file) { if (DOCS_ONLY_FILES.has(file)) { return true @@ -297,7 +309,10 @@ function isTestFile(file) { } function isDesktopIrrelevantPath(file) { - return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES) + return ( + matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES) && + !matchesPrefix(file, DESKTOP_RELEVANT_MOBILE_PREFIXES) + ) } function isNativeCacheInputPath(file) { diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index ee7d7ca078a..73785a3cce9 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,1387 +1,3 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from 'react' -import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter, useFocusEffect } from 'expo-router' -import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native' -import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' -import { - type AccountsSnapshot, - type ProviderKey, - decodeAccountsSnapshot, - getActiveProviderRateLimits, - getUsageBarState, - hasActiveProviderUsage, - hasRenderableUsage, - UsageBar -} from '../src/components/AccountUsage' -import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHostCatalog } from '../src/transport/host-store' -import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection' -import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' -import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' -import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' -import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' -import type { RpcClient } from '../src/transport/rpc-client' -import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' -import { sendSingleFlightRequest } from '../src/transport/request-single-flight' -import { - useDisconnectHostClient, - useForceReconnect, - useForgetHostClient, - usePrimeHosts -} from '../src/transport/client-context' -import { useAllHostClients } from '../src/transport/use-all-host-clients' -import { - resolveHomeHostConnectionState, - selectHomeAutoConnectHostIds -} from '../src/transport/home-host-auto-connect' -import { classifyConnection } from '../src/transport/connection-health' -import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' -import { - loadMobileOnboardingSteps, - mobileOnboardingDestination -} from '../src/onboarding/mobile-onboarding-plan' -import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types' -import { triggerMediumImpact } from '../src/platform/haptics' -import { OrcaLogo } from '../src/components/OrcaLogo' -import { MobileHostCard } from '../src/components/MobileHostCard' -import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions' -import { TaskProviderLogo } from '../src/components/TaskProviderLogo' -import { ActionSheetModal } from '../src/components/ActionSheetModal' -import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions' -import { ConfirmModal } from '../src/components/ConfirmModal' -import { - setCachedWorktrees, - getCachedWorktrees, - getProvenCachedWorktrees -} from '../src/cache/worktree-cache' -import { - LAST_VISITED_WORKTREE_STORAGE_KEY, - readLastVisitedWorktreeRecord -} from '../src/worktree/last-visited-worktree-repo' -import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache' -import { colors, spacing, radii } from '../src/theme/mobile-theme' -import { - filterAvailableTaskProviders, - normalizeVisibleTaskProviders, - type TaskProvider -} from '../src/tasks/mobile-task-providers' -import { useResponsiveLayout } from '../src/layout/responsive-layout' -import { navigateFromMobileHome } from '../src/mobile-web/mobile-web-home-navigation' -import { - isResumeTargetConfirmedMissing, - selectHomeResumeCard, - type HomeResumeCard -} from '../src/worktree/home-resume-card' -import { hostEndpointLabel } from '../src/transport/host-endpoint-label' +import { MobileHomeScreen } from '../src/home/MobileHomeScreen' -type HomeTaskSettings = { - visibleTaskProviders?: unknown -} - -type HomePreflightStatus = { - glab?: { installed?: boolean } -} - -type HomeLinearStatus = { - connected?: boolean -} - -const TASK_PROVIDER_LABELS: Record = { - github: 'GitHub', - gitlab: 'GitLab', - linear: 'Linear' -} - -function formatDuration(ms: number): string { - const totalMinutes = Math.floor(ms / 60_000) - const totalHours = Math.floor(totalMinutes / 60) - const days = Math.floor(totalHours / 24) - const hours = totalHours % 24 - if (days > 0) { - return `${days}d ${hours}h` - } - const minutes = totalMinutes % 60 - if (totalHours > 0) { - return `${totalHours}h ${minutes}m` - } - return `${totalMinutes}m` -} - -// Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners. -const clientIdentities = new WeakMap() -let nextClientIdentity = 1 -function clientKey(client: RpcClient): number { - let id = clientIdentities.get(client) - if (id == null) { - id = nextClientIdentity++ - clientIdentities.set(client, id) - } - return id -} - -function fetchStats( - client: RpcClient, - hostId: string, - setStats: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - sendSingleFlightRequest(client, hostId, 'stats.summary') - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - // Keyed by host: the header totals every desktop instead of showing whoever replied last. - setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) - } - }) - .catch(() => {}) -} - -function fetchAccountsSnapshot( - client: RpcClient, - hostId: string, - setSnapshots: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - sendSingleFlightRequest(client, hostId, 'accounts.list') - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const snapshot = decodeAccountsSnapshot(response.result) - setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) - } - }) - .catch(() => {}) -} - -function fetchTaskProviders( - client: RpcClient, - hostId: string, - setProviders: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - Promise.all([ - sendSingleFlightRequest(client, hostId, 'settings.get'), - sendSingleFlightRequest(client, hostId, 'preflight.check'), - sendSingleFlightRequest(client, hostId, 'linear.status') - ]) - .then(([settingsResponse, preflightResponse, linearResponse]) => { - if (disposed()) { - return - } - const settings = settingsResponse.ok - ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? - {}) as HomeTaskSettings) - : {} - const preflight = preflightResponse.ok - ? (preflightResponse.result as HomePreflightStatus) - : null - const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null - const providers = filterAvailableTaskProviders( - normalizeVisibleTaskProviders(settings.visibleTaskProviders), - { - gitlabInstalled: preflight?.glab?.installed === true, - linearConnected: linear?.connected === true - } - ) - setProviders((prev) => ({ ...prev, [hostId]: providers })) - }) - .catch(() => { - if (disposed()) { - return - } - setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] })) - }) -} - -// Why: hash repo name to a stable color, matching the host detail page's dots. -const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] -function repoColor(name: string): string { - let hash = 0 - for (let i = 0; i < name.length; i++) { - hash = (hash * 31 + name.charCodeAt(i)) | 0 - } - return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length] -} - -export default function HomeScreen() { - const router = useRouter() - const openMobileHostEdit = useOpenMobileHostEdit() - const insets = useSafeAreaInsets() - // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. - const { isWideLayout, contentMaxWidth } = useResponsiveLayout() - const [hostCatalog, setHostCatalog] = useState([]) - const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState<{ - id: string - name: string - publicKeyB64: string - } | null>(null) - const [hostStates, setHostStates] = useState>({}) - const [hostAttempts, setHostAttempts] = useState>({}) - const [hostLastConnected, setHostLastConnected] = useState>({}) - const [statsByHost, setStatsByHost] = useState>({}) - const [worktreeInfo, setWorktreeInfo] = useState>({}) - const [accountsByHost, setAccountsByHost] = useState>({}) - const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) - const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( - null - ) - // Why: focus can fire repeatedly while an async gate is pending; one probe per - // mount avoids duplicate storage/permission reads and competing navigation. - const onboardingOptInCheckedRef = useRef(false) - - // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. - const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) - const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. - const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) - const allClients = useAllHostClients(hostIds, { - autoConnectHostIds, - closeUnusedOnRelease: true - }) - const hostPaths = useMemo( - () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), - [allClients] - ) - const hostPendingPaths = useMemo( - () => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])), - [allClients] - ) - const hostPairingRejected = useMemo( - () => - Object.fromEntries( - allClients.map(({ hostId, pairingRejected }) => [hostId, pairingRejected]) - ), - [allClients] - ) - const disconnectHostClient = useDisconnectHostClient() - const forgetHostClient = useForgetHostClient() - const forceReconnectHost = useForceReconnect() - const primeHosts = usePrimeHosts() - // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start. - useEffect(() => { - if (hosts.length > 0) { - primeHosts(hosts) - } - }, [hosts, primeHosts]) - const allClientsRef = useRef>([]) - // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients. - allClientsRef.current = allClients.map((entry) => ({ - hostId: entry.hostId, - client: entry.client - })) - - // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty. - const hydratedRef = useRef(false) - useEffect(() => { - if (hydratedRef.current) { - return - } - hydratedRef.current = true - let cancelled = false - void loadHomeSnapshot().then((snap) => { - if (cancelled || !snap) { - return - } - setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo)) - setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost)) - for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { - const wt = info.lastActiveWorktree - if (wt) { - // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object. - setCachedWorktrees(hostId, [wt]) - } - } - }) - return () => { - cancelled = true - } - }, []) - - // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes). - useEffect(() => { - if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { - return - } - saveHomeSnapshot({ - worktreeInfo, - accountsByHost, - savedAt: Date.now() - }) - }, [worktreeInfo, accountsByHost]) - - useFocusEffect( - useCallback(() => { - let stale = false - void loadHostCatalog().then(async (catalog) => { - if (stale) { - return - } - setHostCatalog(catalog) - if (catalog.length === 0 || onboardingOptInCheckedRef.current) { - return - } - onboardingOptInCheckedRef.current = true - const onboardingSteps = await loadMobileOnboardingSteps() - if (stale) { - return - } - if (onboardingSteps.length > 0) { - router.replace(mobileOnboardingDestination(onboardingSteps)) - } - }) - void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { - if (stale) { - return - } - // Why the validating reader: this record becomes the Resume card's navigation target, - // so a malformed or older-shaped payload must read as no history, not a broken route. - setLastVisited(readLastVisitedWorktreeRecord(raw)) - }) - for (const entry of allClientsRef.current) { - if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) - void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) - fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) - } - } - return () => { - stale = true - } - }, [router]) - ) - - const sortedHosts = useMemo( - () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), - [hosts] - ) - const sortedHostCatalog = useMemo( - () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), - [hostCatalog] - ) - - // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. - useEffect(() => { - setHostAttempts((prev) => { - const next: Record = { ...prev } - let changed = false - for (const entry of allClients) { - const a = entry.client.getReconnectAttempt() - if (next[entry.hostId] !== a) { - next[entry.hostId] = a - changed = true - } - } - return changed ? next : prev - }) - setHostLastConnected((prev) => { - const next: Record = { ...prev } - let changed = false - for (const entry of allClients) { - const t = entry.client.getLastConnectedAt() - if (next[entry.hostId] !== t) { - next[entry.hostId] = t - changed = true - } - } - return changed ? next : prev - }) - setHostStates((prev) => { - const next: Record = { ...prev } - let changed = false - const liveIds = new Set(allClients.map((e) => e.hostId)) - for (const entry of allClients) { - if (next[entry.hostId] !== entry.state) { - next[entry.hostId] = entry.state - changed = true - } - } - // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hostCatalog) { - if (liveIds.has(host.id)) { - continue - } - if (host.credentialStatus === 'missing') { - if (next[host.id] !== 'auth-failed') { - next[host.id] = 'auth-failed' - changed = true - } - continue - } - if (host.credentialStatus === 'temporarily-unavailable') { - if (next[host.id] !== 'disconnected') { - next[host.id] = 'disconnected' - changed = true - } - continue - } - const prevState = next[host.id] - if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { - next[host.id] = 'disconnected' - changed = true - } - } - // Drop entries for hosts we no longer track at all. - for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { - delete next[id] - changed = true - } - } - return changed ? next : prev - }) - }, [allClients, hostCatalog]) - - // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body - // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the - // returned disposer owns every handle allocated here. - const wireHostSubscriptions = (entry: { - hostId: string - client: RpcClient - state: ConnectionState - }) => { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - const refetchGate = createHostConnectRefetchGate() - const wireUp = (state: ConnectionState) => { - const reconnected = refetchGate.observe(state) - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return - } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host - // snapshot on every reconnect — a one-shot latch left the card on stale data forever. - if (reconnected) { - fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) - void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } - } - } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - return () => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - } - } - - // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. - useEffect(() => { - const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) - return () => { - for (const c of cleanups) { - c() - } - } - // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render. - }, [ - allClients - .map((e) => `${e.hostId}:${clientKey(e.client)}`) - .sort() - .join(',') - ]) - - // Why: the card renders from cached/snapshot data the moment a candidate exists — see - // selectHomeResumeCard for why its slot must not wait for the host to connect. - const resumeCard = useMemo( - () => - selectHomeResumeCard({ - hosts: sortedHosts, - hostStates, - worktreeInfo, - lastVisited, - cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null - }), - [sortedHosts, hostStates, worktreeInfo, lastVisited] - ) - - // Why: the card is drawn from a snapshot that can name a workspace the desktop has since - // deleted. When the host has proven otherwise, open its workspace list rather than a session - // screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes - // through and the session screen bounces once the host answers (F7). - const openResume = useCallback( - (card: HomeResumeCard) => { - if ( - isResumeTargetConfirmedMissing( - card, - getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null - ) - ) { - navigateFromMobileHome({ - router, - hostId: card.hostId, - target: { kind: 'workspaceList' } - }) - return - } - navigateFromMobileHome({ - router, - hostId: card.hostId, - target: { kind: 'session', hostWorkspaceId: card.worktree.worktreeId } - }) - }, - [router] - ) - - // Why: only show Account usage for connected hosts; stale cached usage would imply live data. - const accountsHosts = useMemo(() => { - const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] - for (const host of sortedHosts) { - if (hostStates[host.id] !== 'connected') { - continue - } - const snap = accountsByHost[host.id] - if (!snap) { - continue - } - // Why: also show hosts whose only usage is the system-default login, else those users see no usage section. - if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) { - items.push({ host, snapshot: snap }) - } - } - return items - }, [sortedHosts, hostStates, accountsByHost]) - - const connectedHosts = useMemo( - () => sortedHosts.filter((host) => hostStates[host.id] === 'connected'), - [sortedHosts, hostStates] - ) - const primaryConnectedHost = connectedHosts[0] ?? null - const primaryTaskProviders = primaryConnectedHost - ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github']) - : [] - const openTasks = useCallback( - (provider?: TaskProvider) => { - if (!primaryConnectedHost) { - return - } - navigateFromMobileHome({ - router, - hostId: primaryConnectedHost.id, - target: { kind: 'tasks', ...(provider ? { taskSource: provider } : {}) } - }) - }, - [primaryConnectedHost, router] - ) - const renderTaskHomeCard = () => ( - [ - styles.taskHomeCard, - !primaryConnectedHost && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => { - openTasks() - }} - > - - - - - Tasks - - {primaryTaskProviders.length > 0 - ? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ') - : 'No task sources connected'} - - - - TASK_PROVIDER_LABELS[provider]) - .join(', ')} - > - {primaryTaskProviders.map((provider) => ( - [ - styles.taskHomeProviderButton, - pressed && styles.taskHomeProviderButtonPressed - ]} - onPress={(event) => { - event.stopPropagation() - openTasks(provider) - }} - > - - - ))} - - - - - ) - - async function handleRemove() { - if (!confirmRemove) { - return - } - const hostToRemove = confirmRemove - try { - await removeHostAndCloseClient(hostToRemove.id, hostToRemove.publicKeyB64, forgetHostClient) - setConfirmRemove(null) - setHostCatalog(await loadHostCatalog()) - } catch { - // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. - setConfirmRemove(hostToRemove) - Alert.alert('Could not remove host', 'Please try again.') - } - } - - return ( - - {/* ─── Top bar ─── */} - - - - - - Orca - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={() => router.push('/settings')} - > - - - - - {hostCatalog.length === 0 ? ( - /* ─── Empty state: onboarding ─── */ - - - Connect your desktop - - Pair with Orca on your computer to check on your agents, jump into any terminal, and - drive work from your phone. - - router.push('/pair-scan')}> - - Pair Desktop - - - - - How it works - {ONBOARDING_STEPS.map((step, i) => ( - 0 && styles.stepRowBorder]}> - - {i + 1} - - - {step.title} - {step.desc} - - - ))} - - - ) : ( - /* ─── Populated state ─── */ - h.id} - // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. - contentContainerStyle={[ - styles.list, - { paddingBottom: spacing.xl + insets.bottom }, - isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } - ]} - ListHeaderComponent={ - - - Welcome back - - - {stats && ( - - - - {stats.totalAgentsSpawned.toLocaleString()} - - Agents spawned - - - {formatDuration(stats.totalAgentTimeMs)} - Agent time - - - {stats.totalPRsCreated.toLocaleString()} - PRs created - - - )} - - Desktops - - } - ItemSeparatorComponent={CardGap} - renderItem={({ item }) => { - const info = worktreeInfo[item.id] - const state = resolveHomeHostConnectionState( - item.id, - hostStates[item.id], - autoConnectHostIds - ) - const attempts = hostAttempts[item.id] ?? 0 - const lastConnectedAt = hostLastConnected[item.id] ?? null - const verdict = classifyConnection({ - state, - reconnectAttempts: attempts, - lastConnectedAt, - endpoint: item.endpoint, - pendingPath: hostPendingPaths[item.id] ?? null, - pairingRejected: hostPairingRejected[item.id] ?? false - }) - return ( - { - if (item.credentialStatus === 'missing') { - router.push('/pair-scan') - } else if (item.credentialStatus === 'temporarily-unavailable') { - void loadHostCatalog() - .then(setHostCatalog) - .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) - } else { - navigateFromMobileHome({ - router, - hostId: item.id, - target: { kind: 'workspaceList' } - }) - } - }} - onLongPress={() => { - triggerMediumImpact() - if (item.profile) { - setActionTarget(item.profile) - } else { - setConfirmRemove(item) - } - }} - onOpenActions={() => { - if (item.profile) { - setActionTarget(item.profile) - } else { - setConfirmRemove(item) - } - }} - /> - ) - }} - ListFooterComponent={ - - {/* ─── Resume card ─── */} - {resumeCard ? ( - <> - Resume - [ - styles.resumeCard, - !resumeCard.actionable && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => openResume(resumeCard)} - > - - - - - - {resumeCard.worktree.displayName} - - - - - {resumeCard.worktree.repo} - {' · '} - {resumeCard.worktree.branch} - - - - - - - ) : null} - Tasks - {renderTaskHomeCard()} - - {/* ─── Quick actions ─── */} - router.push('/pair-scan')} - onCreateWorkspace={(hostId) => - navigateFromMobileHome({ - router, - hostId, - target: { kind: 'newWorkspace' } - }) - } - /> - - {/* ─── Account usage ─── */} - {accountsHosts.length > 0 ? ( - <> - - Account usage - - {accountsHosts.map(({ host, snapshot }) => { - const claudeActiveId = snapshot.claude.activeAccountId - const claudeActive = - snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null - const codexActiveId = snapshot.codex.activeAccountId - const codexActive = - snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null - const showHostName = accountsHosts.length > 1 - return ( - [ - styles.accountsCard, - pressed && styles.hostCardPressed - ]} - onPress={() => - navigateFromMobileHome({ - router, - hostId: host.id, - target: { kind: 'accounts' } - }) - } - > - {showHostName ? ( - - {host.name} - - ) : null} - {(['claude', 'codex'] as ProviderKey[]).map((provider) => { - const active = provider === 'claude' ? claudeActive : codexActive - const accounts = - provider === 'claude' - ? snapshot.claude.accounts - : snapshot.codex.accounts - const limits = getActiveProviderRateLimits(snapshot, provider) - // Why: with no managed accounts, still render the row when the active target has live usage data. - if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { - return null - } - const sessionBar = getUsageBarState(limits, 'session') - const weeklyBar = getUsageBarState(limits, 'weekly') - return ( - - - {provider === 'claude' ? ( - - ) : ( - - )} - - - - {active?.email ?? 'System default'} - - - - - - - - ) - })} - - ) - })} - - ) : null} - - } - /> - )} - - {/* ─── Action sheets (shared by both states) ─── */} - setActionTarget(null), - onReconnect: (hostId) => void forceReconnectHost(hostId), - onDisconnect: disconnectHostClient, - onDiagnostics: (hostId) => - router.push({ pathname: '/connection-log', params: { hostId } }), - onEdit: openMobileHostEdit, - onRemove: (host) => setConfirmRemove(host) - })} - onClose={() => setActionTarget(null)} - /> - - void handleRemove()} - onCancel={() => setConfirmRemove(null)} - /> - - ) -} - -function CardGap() { - return -} - -const ONBOARDING_STEPS = [ - { - title: 'Open Orca desktop', - desc: 'Go to Settings → Mobile and generate a pairing QR code.' - }, - { - title: 'Scan the code', - desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.' - }, - { - title: "You're connected", - desc: 'Your desktop will appear here. Everything is encrypted end-to-end.' - } -] - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase - }, - - /* ─── Top bar ─── */ - topBar: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingTop: spacing.sm, - paddingBottom: spacing.md - }, - brandLockup: { - flexDirection: 'row', - alignItems: 'center', - minWidth: 0 - }, - logoMark: { - marginRight: spacing.sm - }, - brandName: { - color: colors.textPrimary, - fontSize: 17, - fontWeight: '700' - }, - iconButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center' - }, - iconButtonPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Hero / greeting ─── */ - hero: { - paddingTop: spacing.xs, - paddingBottom: spacing.md - }, - heroTitle: { - color: colors.textPrimary, - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.3 - }, - - /* ─── Stat cards ─── */ - statsRow: { - flexDirection: 'row', - gap: 10, - marginBottom: spacing.lg - }, - statCard: { - flex: 1, - backgroundColor: 'rgba(26,26,26,0.6)', - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: 10, - paddingVertical: 8, - paddingHorizontal: spacing.md - }, - statValue: { - color: colors.textPrimary, - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3 - }, - statLabel: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '500', - marginTop: 2 - }, - - /* ─── Section heading ─── */ - sectionHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.6, - marginBottom: spacing.sm, - paddingHorizontal: spacing.xs - }, - sectionHeadingTightTop: { - marginTop: spacing.lg - }, - - /* ─── List ─── */ - list: { - paddingHorizontal: spacing.lg, - paddingBottom: spacing.xl - }, - cardGap: { - height: spacing.sm - }, - - /* ─── Host cards ─── */ - hostCardPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Resume card ─── */ - resumeCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - paddingLeft: spacing.md, - paddingRight: spacing.md, - paddingVertical: 12 - }, - resumeIcon: { - width: 46, - height: 46, - borderRadius: 13, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center', - marginRight: 14 - }, - resumeMain: { - flex: 1, - minWidth: 0 - }, - resumeTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - resumeSub: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - marginTop: 3 - }, - repoDot: { - width: 7, - height: 7, - borderRadius: 3.5 - }, - resumeSubText: { - fontSize: 12, - color: colors.textSecondary, - flex: 1 - }, - - /* ─── Tasks card ─── */ - taskHomeCard: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - minHeight: 72, - paddingLeft: spacing.md, - paddingRight: spacing.md, - paddingVertical: 12 - }, - cardDisabled: { - opacity: 0.45 - }, - taskHomeIcon: { - width: 46, - height: 46, - borderRadius: 13, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center', - marginRight: 14 - }, - taskHomeMain: { - flex: 1, - minWidth: 0 - }, - taskHomeTitle: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - taskHomeSubtitle: { - fontSize: 12, - color: colors.textSecondary, - marginTop: 3 - }, - taskHomeTrailing: { - flexDirection: 'row', - alignItems: 'center', - flexShrink: 0, - marginLeft: spacing.sm - }, - taskHomeProviderRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-end', - gap: 2 - }, - taskHomeProviderButton: { - width: 34, - height: 34, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - taskHomeProviderButtonPressed: { - backgroundColor: colors.bgRaised - }, - - /* ─── Account usage ─── */ - accountsCard: { - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - borderRadius: radii.card, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2, - gap: spacing.sm, - marginBottom: spacing.sm - }, - accountsHostLabel: { - fontSize: 11, - color: colors.textMuted, - fontWeight: '500', - textTransform: 'uppercase', - letterSpacing: 0.4 - }, - accountsRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2 - }, - accountsIcon: { - width: 32, - height: 32, - borderRadius: 9, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center' - }, - accountsInfo: { - flex: 1, - minWidth: 0, - gap: 2 - }, - accountsEmail: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary - }, - accountsBars: { - flexDirection: 'row', - gap: spacing.md, - marginTop: 4 - }, - - /* ─── Empty state ─── */ - emptyContainer: { - flex: 1 - }, - emptyGreeting: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - paddingBottom: spacing.sm - }, - emptyHero: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 32, - paddingBottom: 40 - }, - emptyTitle: { - fontSize: 22, - fontWeight: '700', - color: colors.textPrimary, - textAlign: 'center', - marginBottom: 10 - }, - emptyBody: { - fontSize: 15, - color: colors.textSecondary, - textAlign: 'center', - lineHeight: 22, - marginBottom: 32 - }, - primaryButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - backgroundColor: colors.textPrimary, - paddingHorizontal: 28, - paddingVertical: 14, - borderRadius: radii.card - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: 15, - fontWeight: '700' - }, - - /* ─── Onboarding steps ─── */ - stepsSection: { - paddingHorizontal: spacing.xl - }, - stepRow: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: 14, - paddingVertical: spacing.lg - }, - stepRowBorder: { - borderTopWidth: 1, - borderTopColor: colors.borderSubtle - }, - stepNum: { - width: 28, - height: 28, - borderRadius: 8, - backgroundColor: 'rgba(255,255,255,0.04)', - borderWidth: 1, - borderColor: colors.borderSubtle, - alignItems: 'center', - justifyContent: 'center', - marginTop: 1 - }, - stepNumText: { - fontSize: 12, - fontWeight: '700', - color: colors.textSecondary - }, - stepText: { - flex: 1 - }, - stepTitle: { - fontSize: 14, - fontWeight: '600', - color: colors.textPrimary, - marginBottom: 3 - }, - stepDesc: { - fontSize: 12, - color: colors.textMuted, - lineHeight: 17 - } -}) +export default MobileHomeScreen diff --git a/mobile/host-web-app/mobile-web-route-error-boundary.tsx b/mobile/host-web-app/mobile-web-route-error-boundary.tsx index cb07a36cc42..e7b14068efa 100644 --- a/mobile/host-web-app/mobile-web-route-error-boundary.tsx +++ b/mobile/host-web-app/mobile-web-route-error-boundary.tsx @@ -41,6 +41,7 @@ export class MobileWebRouteErrorBoundary extends Component< } componentDidCatch(error: unknown, info: ErrorInfo): void { + window.dispatchEvent(new Event('orca-mobile-web-route-failure')) console.error('[mobile-web] hosted route stopped', { code: mobileWebRouteFailureCode(error), componentDepth: info.componentStack?.split('\n').length ?? 0 diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt index d4eb69e4e52..96b8bc667b5 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrl.kt @@ -9,7 +9,7 @@ internal fun isAllowedMobileWebBridgeDocumentUrl(value: String, sessionId: Strin val url = URI(value) value.length <= MOBILE_WEB_DOCUMENT_URL_LIMIT && url.scheme == MOBILE_WEB_ORIGIN_SCHEME && - url.host == MOBILE_WEB_ORIGIN_HOST && + isMobileWebOriginForSession(url, sessionId) && url.port == -1 && url.userInfo == null && url.fragment == sessionId diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt index 6ae0352237c..32bd8c7a9ad 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebDebugIsolationProbe.kt @@ -28,7 +28,7 @@ internal fun installMobileWebDebugIsolationProbe( WebViewCompat.addDocumentStartJavaScript( webView, script, - setOf(MOBILE_WEB_ORIGIN) + setOf("*") ) } else { throw IllegalStateException("mobile_web_debug_isolation_probe_unavailable") diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt new file mode 100644 index 00000000000..0e1de007f75 --- /dev/null +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebOrigin.kt @@ -0,0 +1,21 @@ +package expo.modules.mobilewebshell + +import android.net.Uri + +private const val MOBILE_WEB_ORIGIN_SUFFIX = ".orca-mobile-web.invalid" + +/** Derives a per-session origin so WebView cookies/storage cannot cross hosts. */ +internal fun mobileWebOriginForSession(sessionId: String): String { + require(sessionId.isNotEmpty() && sessionId.length <= 128 && sessionId.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { + "mobile_web_session_id_invalid" + } + return "$MOBILE_WEB_ORIGIN_SCHEME://${sessionId.take(32)}$MOBILE_WEB_ORIGIN_SUFFIX" +} + +internal fun mobileWebOriginUriForSession(sessionId: String): Uri = Uri.parse(mobileWebOriginForSession(sessionId)) + +internal fun isMobileWebOriginForSession(url: Uri, sessionId: String): Boolean = + url.scheme == MOBILE_WEB_ORIGIN_SCHEME && + url.host == mobileWebOriginUriForSession(sessionId).host && + url.port == -1 && + url.userInfo == null diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt index 79eb5f31f78..acadf49e541 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebPackageStore.kt @@ -642,7 +642,7 @@ internal class MobileWebPackageStore internal constructor( private fun sessionResponse(sessionId: String, buildId: String): Map = mapOf( "sessionId" to sessionId, "buildId" to buildId, - "url" to "$MOBILE_WEB_ORIGIN/#$sessionId" + "url" to "${mobileWebOriginForSession(sessionId)}/#$sessionId" ) private fun randomIdentifier(): String { diff --git a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt index fc594c36a3a..e71904dccfd 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/main/java/expo/modules/mobilewebshell/MobileWebShellView.kt @@ -119,15 +119,15 @@ internal class MobileWebShellView( return } if (sessionId == activeSessionId) return - removeBridgeMessageListener() - addBridgeMessageListener() activeSessionId = sessionId + removeBridgeMessageListener() + addBridgeMessageListener(sessionId) webView.stopLoading() attachWebView() visibility = View.VISIBLE webView.visibility = View.VISIBLE onLoadState(mapOf("state" to "loading")) - webView.loadUrl("$MOBILE_WEB_ORIGIN/#$sessionId") + webView.loadUrl("${mobileWebOriginForSession(sessionId)}/#$sessionId") } fun activateSessionView(sessionId: String) { @@ -180,12 +180,12 @@ internal class MobileWebShellView( } } - private fun addBridgeMessageListener() { + private fun addBridgeMessageListener(sessionId: String) { if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { WebViewCompat.addWebMessageListener( webView, MOBILE_WEB_BRIDGE_NAME, - setOf(MOBILE_WEB_ORIGIN), + setOf(mobileWebOriginForSession(sessionId)), OriginLockedMessageListener() ) } else { @@ -206,7 +206,7 @@ internal class MobileWebShellView( val documentUrl = view.url?.let(Uri::parse) ?: return if ( !isMainFrame || - !isMobileWebOrigin(sourceOrigin) || + !isMobileWebOriginForSession(sourceOrigin, sessionId) || !isAllowedMobileWebBridgeDocumentUrl(documentUrl.toString(), sessionId) || body.toByteArray(Charsets.UTF_8).size > MOBILE_WEB_MESSAGE_BYTE_LIMIT ) return @@ -259,7 +259,7 @@ internal class MobileWebShellView( request.method == "GET" && request.requestHeaders.keys.none { it.equals("Range", ignoreCase = true) } && sessionId != null && - isMobileWebOrigin(url) && + isMobileWebOriginForSession(url, sessionId) && (url.fragment == null || (request.isForMainFrame && url.fragment == sessionId)) && url.query == null && !url.encodedPath.orEmpty().contains('%') && @@ -290,7 +290,7 @@ internal class MobileWebShellView( private fun isAllowedDocumentUrl(url: Uri): Boolean = activeSessionId != null && - isMobileWebOrigin(url) && + isMobileWebOriginForSession(url, activeSessionId ?: return false) && url.path == "/" && url.encodedPath == "/" && url.query == null && @@ -299,7 +299,7 @@ internal class MobileWebShellView( private fun isAllowedEmbeddedDocumentUrl(url: Uri): Boolean = activeSessionId != null && - isMobileWebOrigin(url) && + isMobileWebOriginForSession(url, activeSessionId ?: return false) && url.path == "/$MOBILE_WEB_MERMAID_FRAME_PATH" && url.encodedPath == "/$MOBILE_WEB_MERMAID_FRAME_PATH" && url.query == null && @@ -316,12 +316,6 @@ internal class MobileWebShellView( ) } -private fun isMobileWebOrigin(url: Uri): Boolean = - url.scheme == MOBILE_WEB_ORIGIN_SCHEME && - url.host == MOBILE_WEB_ORIGIN_HOST && - url.port == -1 && - url.userInfo == null - private object JSONObjectQuote { fun quote(value: String): String = org.json.JSONObject.quote(value) } diff --git a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt index 7f334fe8b6b..0c72c08dcf4 100644 --- a/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt +++ b/mobile/packages/expo-mobile-web-shell/android/src/test/java/expo/modules/mobilewebshell/MobileWebBridgeDocumentUrlTest.kt @@ -6,24 +6,25 @@ import org.junit.Test class MobileWebBridgeDocumentUrlTest { private val sessionId = "S".repeat(43) + private val origin = mobileWebOriginForSession(sessionId) @Test fun `accepts session-bound client routes`() { assertTrue( isAllowedMobileWebBridgeDocumentUrl( - "https://orca-mobile-web.invalid/#$sessionId", + "$origin/#$sessionId", sessionId ) ) assertTrue( isAllowedMobileWebBridgeDocumentUrl( - "https://orca-mobile-web.invalid/h/paired-orca-desktop/tasks#$sessionId", + "$origin/h/paired-orca-desktop/tasks#$sessionId", sessionId ) ) assertTrue( isAllowedMobileWebBridgeDocumentUrl( - "https://orca-mobile-web.invalid/h/paired-orca-desktop/session/workspace" + + "$origin/h/paired-orca-desktop/session/workspace" + "?name=Feature+One#$sessionId", sessionId ) @@ -33,13 +34,13 @@ class MobileWebBridgeDocumentUrlTest { @Test fun `rejects documents outside the active origin and session`() { val rejected = listOf( - "http://orca-mobile-web.invalid/#$sessionId", - "https://user@orca-mobile-web.invalid/#$sessionId", - "https://orca-mobile-web.invalid:443/#$sessionId", + "http://${origin.removePrefix("https://")}/#$sessionId", + "https://user@${origin.removePrefix("https://")}/#$sessionId", + "$origin:443/#$sessionId", "https://orca-mobile-web.invalid.evil.test/#$sessionId", - "https://orca-mobile-web.invalid/", - "https://orca-mobile-web.invalid/#${"T".repeat(43)}", - "https://orca-mobile-web.invalid/${"a".repeat(8 * 1024)}#$sessionId", + "$origin/", + "${mobileWebOriginForSession("T".repeat(43))}/#$sessionId", + "$origin/${"a".repeat(8 * 1024)}#$sessionId", "not a url" ) diff --git a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts index f158edfa97a..8d5a9ab5da5 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts @@ -179,7 +179,10 @@ export function useMobileWebPackageRecovery({ }, [ownedSessionRef, recoverSession, setPackageWarning]) const clearCache = useCallback(async () => { - const hostEpoch = hostEpochRef.current + // Invalidate refresh/open continuations before any await so a clear cannot race a + // download that publishes a session into the cache being removed. + const hostEpoch = hostEpochRef.current + 1 + hostEpochRef.current = hostEpoch const current = ownedSessionRef.current if (!host || !activeHostIdRef.current) { return diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts index ec3b8088915..89d93537137 100644 --- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts +++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts @@ -170,8 +170,7 @@ export class MobileE2EEV2PhysicalChannel { if (this.state !== 'ready') { return false } - this.outboundQueue.enqueue(item) - return true + return this.outboundQueue.enqueue(item) } } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8769085444c..9c063362a99 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7017,21 +7017,13 @@ export class OrcaRuntimeService { } private collectMobileSessionWorktreeIdsForSshTarget(targetId: string): Set { - const repoIds = new Set( - (this.store?.getRepos() ?? []) - .filter((repo) => repo.connectionId === targetId) - .map((repo) => repo.id) - ) - if (repoIds.size === 0) { - return new Set() - } const worktreeIds = new Set() + const executionHostId = toSshExecutionHostId(targetId) for (const worktreeId of [ ...this.getKnownWorkspaceSessionWorktreeIds(), ...this.mobileSessionTabsByWorktree.keys() ]) { - const parsed = splitWorktreeId(worktreeId) - if (parsed && repoIds.has(parsed.repoId)) { + if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) { worktreeIds.add(worktreeId) } } @@ -7042,21 +7034,13 @@ export class OrcaRuntimeService { targetId: string, generation: number ): Promise { - const repoIds = new Set( - (this.store?.getRepos() ?? []) - .filter((repo) => repo.connectionId === targetId) - .map((repo) => repo.id) - ) - if (repoIds.size === 0) { - return - } const worktreeIds = new Set() + const executionHostId = toSshExecutionHostId(targetId) for (const worktreeId of [ ...this.getKnownWorkspaceSessionWorktreeIds(), ...this.mobileSessionTabsByWorktree.keys() ]) { - const parsed = splitWorktreeId(worktreeId) - if (parsed && repoIds.has(parsed.repoId)) { + if (this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) === executionHostId) { worktreeIds.add(worktreeId) } } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 93a2b87f8c4..e80d07773dc 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1,3864 +1,22 @@ -/* oxlint-disable max-lines -- Why: terminal RPC methods are co-located for discoverability; splitting would scatter related handlers across files. */ -import { z } from 'zod' -import { randomUUID } from 'node:crypto' +import type { RpcAnyMethod } from '../core' +import { TERMINAL_LIFECYCLE_METHODS } from './terminal/terminal-lifecycle-methods' +import { TERMINAL_MULTIPLEX_METHODS } from './terminal/terminal-multiplex-method' +import { TERMINAL_QUERY_METHODS } from './terminal/terminal-query-methods' +import { TERMINAL_SEND_METHODS } from './terminal/terminal-send-method' +import { TERMINAL_SUBSCRIBE_METHODS } from './terminal/terminal-subscribe-method' import { - InvalidArgumentError, - defineMethod, - defineStreamingMethod, - type RpcAnyMethod -} from '../core' -import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' -import type { DriverState, OrcaRuntimeService, SubscriptionRegistration } from '../../orca-runtime' -import { - TerminalStreamOpcode, - decodeTerminalStreamJson, - decodeTerminalStreamText, - encodeTerminalStreamFrame, - encodeTerminalStreamJson, - encodeTerminalStreamText, - type TerminalStreamFrame -} from '../../../../shared/terminal-stream-protocol' -import { - iterateTerminalOutputFrameChunks, - sliceTerminalOutputSourceRanges, - type TerminalOutputFrameChunk, - type TerminalOutputMeta -} from '../terminal-output-frame-chunks' -import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-education-telemetry' -import type { TerminalOscLinkRange } from '../../../../shared/terminal-osc-link-ranges' -import { - TERMINAL_INPUT_MAX_BYTES, - TERMINAL_INPUT_TOO_LARGE_ERROR, - isTerminalInputTooLargeWithYield -} from '../../../../shared/terminal-input' -import { - measureTerminalStreamByteLength, - terminalStreamByteLength, - terminalStreamByteLengthExceeds -} from '../terminal-stream-byte-length' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' -import { - EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, - scanTerminalReplyQuerySequences, - type TerminalReplyQuerySequence, - type TerminalReplyQueryScanState -} from '../../../../shared/terminal-reply-query-scan' -import { - MOBILE_SNAPSHOT_BYTE_BUDGET, - MOBILE_SUBSCRIBE_SCROLLBACK_ROWS -} from '../../scrollback-limits' -import { assertTerminalAgentSendable } from '../terminal-agent-send-guard' -import { - navigationTargetsHost, - resolveRuntimeNavigationTarget -} from '../../../../shared/runtime-navigation' -import { - TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES, - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION, - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES, - TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR, - TERMINAL_OUTPUT_BATCH_MAX_BYTES -} from '../../../../shared/terminal-multiplex-flow-control' -import { drainTerminalMultiplexRoundRobin } from '../terminal-multiplex-round-robin' -import type { TerminalSourceRangeLedger } from '../terminal-source-range-ledger' -import { TerminalSourceRangeRegistry } from '../terminal-source-range-registry' -import { - sameTerminalOutputSourceIdentity, - type TerminalOutputSourceRange -} from '../../../../shared/terminal-output-source-range' -import type { TerminalSnapshotUnavailableReason } from '../../../../shared/terminal-snapshot-unavailability' -import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer' -import { withTerminalCloseAttribution } from '../terminal-close-attribution' - -const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 -const TERMINAL_OUTPUT_FLUSH_MS = 5 -const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 -// Why: bound initial subscribe latency; readiness after this deadline triggers an in-stream recovery snapshot. -const MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS = 3_000 -let nextTerminalStreamId = 1 - -type SnapshotFrameOptions = { - kind: 'scrollback' | 'resized' - cols: number - rows: number - data: string - requestId?: number - displayMode?: string - reason?: string - seq?: number - cwd?: string | null - truncated?: boolean - truncatedByByteBudget?: boolean - // Why: distinguishes "I could not answer right now" from a genuinely empty buffer; omitted on success. - unavailable?: TerminalSnapshotUnavailableReason - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - pendingEscapeTailAnsi?: string - /** Effective kitty flags proven at this frame's own `seq`. */ - kittyKeyboardFlags?: number -} - -type SerializedSnapshot = { - data: string - scrollbackAnsi?: string - cols: number - rows: number - seq?: number - cwd?: string | null - source?: 'headless' | 'renderer' - oscLinks?: TerminalOscLinkRange[] - scrollbackRows: number - truncatedByByteBudget: boolean - pendingEscapeTailAnsi?: string - kittyKeyboardFlags?: number -} | null - -type TerminalViewportClient = { - id: string - type?: 'mobile' | 'desktop' -} - -type TerminalMultiplexStream = { - streamId: number - terminal: string - ptyId: string - client: TerminalViewportClient | undefined - isMobile: boolean - ackOutput: boolean - ackOutputSourceRanges: boolean - streamGeneration: string - sourceRangeLedger: TerminalSourceRangeLedger | null - sourceRangeConsumerAttached: boolean - sourceRangeReplacement: RemoteTerminalSourceRangeReplacementReservation | null - ackInFlightBytes: number - ackWindowBytes: number - supportsOutputPause: boolean - supportsQueryReply: boolean - supportsWriteUnavailable: boolean - outputPaused: boolean - supportsDesktopViewportClaims: boolean - desktopClaimTail: Promise - // Whether THIS stream registered the width driver, so detach won't release a peer stream's floor. - registeredRemoteDesktopDriver: boolean - remoteDesktopSubscriptionKey: string - pendingRemoteDesktopViewport: { cols: number; rows: number } | null - buffering: boolean - ackPendingOutput: TerminalOutputFrameChunk[] - ackPendingOutputBytes: number - ackPendingOutputOverflowed: boolean - ackRecoverySnapshotInFlight: boolean - pendingOutput: TerminalOutputChunk[] - pendingOutputBytes: number - pendingOutputOverflowed: boolean - // Cols the mobile client last rewrapped to; re-stream full scrollback only when width actually changes. - lastResizeCols: number | undefined - resizeGeneration: number - outputBatcher: ReturnType - unsubscribeData: () => void - unsubscribeResize: () => void - unsubscribeFit: () => void - unsubscribeDriver: () => void - unregisterBinaryHandler: () => void - // Why: the runtime drops the exit-waiter only on real PTY exit; abort on detach so a never-exiting agent terminal doesn't leak the waiter. - exitWaiterAbort: AbortController -} - -type TerminalOutputChunk = { - data: string - bytes: number - meta?: TerminalOutputMeta -} - -function createTerminalOutputBatcher(onFlush: (data: string, meta?: TerminalOutputMeta) => void): { - push: (data: string, meta?: TerminalOutputMeta) => void - flush: () => void - dispose: () => void -} { - let chunks: string[] = [] - let bytes = 0 - let lastSeq: number | undefined - let pendingCwd: string | undefined - let pendingRawLength = 0 - let pendingSourceRanges: TerminalOutputSourceRange[] = [] - let timer: ReturnType | null = null - - const clearTimer = (): void => { - if (!timer) { - return - } - clearTimeout(timer) - timer = null - } - - const flush = (): void => { - clearTimer() - if (chunks.length === 0 && pendingRawLength === 0) { - return - } - const data = chunks.length === 1 ? chunks[0]! : chunks.join('') - const meta = - typeof lastSeq === 'number' || pendingCwd !== undefined || pendingSourceRanges.length > 0 - ? { - ...(typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: pendingRawLength } : {}), - ...(pendingCwd !== undefined ? { cwd: pendingCwd } : {}), - ...(pendingSourceRanges.length > 0 - ? { sourceRanges: Object.freeze(pendingSourceRanges.slice()) } - : {}) - } - : undefined - chunks = [] - bytes = 0 - lastSeq = undefined - pendingCwd = undefined - pendingRawLength = 0 - pendingSourceRanges = [] - onFlush(data, meta) - } - - return { - push(data: string, meta?: TerminalOutputMeta): void { - const rawLength = meta?.rawLength ?? data.length - if (!data && rawLength === 0) { - return - } - if (meta?.transformed || rawLength !== data.length) { - flush() - onFlush(data, { ...meta, rawLength, transformed: true }) - return - } - const nextSourceRanges = meta?.sourceRanges ?? [] - const lastSourceRange = pendingSourceRanges.at(-1) - const firstNextSourceRange = nextSourceRanges[0] - if ( - chunks.length > 0 && - (pendingSourceRanges.length > 0 !== nextSourceRanges.length > 0 || - (lastSourceRange && - firstNextSourceRange && - (!sameTerminalOutputSourceIdentity(lastSourceRange, firstNextSourceRange) || - lastSourceRange.displayEnd !== firstNextSourceRange.displayStart))) - ) { - flush() - } - if (meta?.cwd !== undefined) { - flush() - pendingCwd = meta.cwd - } - chunks.push(data) - pendingRawLength += rawLength - pendingSourceRanges.push(...nextSourceRanges) - const remainingBudget = Math.max(1, TERMINAL_OUTPUT_BATCH_MAX_BYTES - bytes) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - bytes += measurement.byteLength - if (typeof meta?.seq === 'number') { - lastSeq = meta.seq - } - if (measurement.exceededLimit || bytes >= TERMINAL_OUTPUT_BATCH_MAX_BYTES) { - flush() - return - } - if (!timer) { - // Why: coalesce stream output before it crosses the network; desktop subscribers share the same burst boundary. - timer = setTimeout(flush, TERMINAL_OUTPUT_FLUSH_MS) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - }, - flush, - dispose(): void { - clearTimer() - chunks = [] - bytes = 0 - pendingRawLength = 0 - pendingSourceRanges = [] - } - } -} - -function isTerminalInputLockedForClient( - runtime: OrcaRuntimeService, - ptyId: string, - client: TerminalViewportClient | undefined -): boolean { - if (client?.type === 'mobile') { - return false - } - // Why: pre-refactor mobile builds sent no client metadata, so treat a missing client as legacy mobile (unlocked). - if (!client) { - return false - } - return runtime.getDriver(ptyId).kind === 'mobile' -} - -async function assertTerminalSendTextWithinLimit(text: string | undefined): Promise { - if (!text) { - return - } - // Why: sends can be paste-sized; validate outside Zod so large input yields before runtime dispatch. - if (await isTerminalInputTooLargeWithYield(text, TERMINAL_INPUT_MAX_BYTES)) { - throw new InvalidArgumentError(TERMINAL_INPUT_TOO_LARGE_ERROR) - } -} - -function resolveMobileFloorClientId( - driver: DriverState | null, - client: TerminalViewportClient | undefined -): string | null { - if (client?.type === 'mobile') { - return client.id - } - if (!client && driver?.kind === 'mobile') { - return driver.clientId - } - return null -} - -type TerminalStreamInputOutcome = 'delivered' | 'rejected' | 'failed' - -function watchSubscriptionLifetime( - runtime: OrcaRuntimeService, - ptyId: string, - signal: AbortSignal | undefined, - registration: SubscriptionRegistration -): () => void { - let unsubscribeExit: (() => void) | null = null - let removeAbort: (() => void) | null = null - let stopped = false - const stop = (): void => { - stopped = true - unsubscribeExit?.() - removeAbort?.() - } - const release = (): void => { - registration.releaseIfCurrent() - stop() - } - unsubscribeExit = runtime.subscribeToPtyExit(ptyId, release) - if (stopped) { - unsubscribeExit() - return stop - } - if (!signal) { - return stop - } - if (signal.aborted) { - release() - return stop - } - const onAbort = (): void => release() - removeAbort = () => signal.removeEventListener('abort', onAbort) - signal.addEventListener('abort', onAbort, { once: true }) - if (stopped) { - removeAbort() - } - return stop -} - -function isTerminalStreamInputRejection(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') -} - -async function sendTerminalStreamInput( - runtime: OrcaRuntimeService, - args: { - terminal: string - ptyId: string - text: string - client: TerminalViewportClient | undefined - isMobile: boolean - inputKind: 'input' | 'query-reply' - } -): Promise { - const action = { text: args.text, enter: false, interrupt: false } - const clientId = args.isMobile ? args.client?.id : undefined - const floorClaim: MobileInputFloorClaimHolder = { current: null } - try { - if (args.inputKind === 'query-reply') { - if (!clientId || !isTerminalQueryReply(args.text)) { - return 'failed' - } - const result = await runtime.sendTerminal(args.terminal, action, { - beforeWrite: (writePtyId) => { - if ( - writePtyId !== args.ptyId || - !runtime.isMobileTerminalQueryReplyAuthority(args.ptyId, clientId) - ) { - throw new Error('mobile_query_reply_authority_unavailable') - } - } - }) - return result.accepted ? 'delivered' : 'rejected' - } - if (!clientId) { - const result = await runtime.sendTerminal(args.terminal, action) - return result.accepted ? 'delivered' : 'rejected' - } - const result = await runtime.sendTerminal(args.terminal, action, { - reserveWrite: (writePtyId) => { - const claim = runtime.beginMobileInputFloor(writePtyId, clientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - floorClaim.current = claim - }, - afterWrite: () => commitMobileInputFloorClaim(floorClaim) - }) - if (!result.accepted) { - floorClaim.current?.rollback() - return 'rejected' - } - return 'delivered' - } catch (error) { - floorClaim.current?.rollback() - return isTerminalStreamInputRejection(error) ? 'rejected' : 'failed' - } -} - -type MobileInputFloorClaimHolder = { - current: ReturnType -} - -async function commitMobileInputFloorClaim(claim: MobileInputFloorClaimHolder): Promise { - const current = claim.current - if (!current) { - return - } - try { - await current.commit() - } finally { - // Why: the runtime may yield before the next write, which then needs a fresh reservation if desktop reclaimed the floor. - if (claim.current === current) { - claim.current = null - } - } -} - -function getTerminalSendGuardRefusedReason(error: unknown): 'no-agent' | 'permission' | undefined { - const message = error instanceof Error ? error.message : String(error) - if (message.includes('terminal_guard_permission')) { - return 'permission' - } - if (message.includes('terminal_guard_no_agent')) { - return 'no-agent' - } - return undefined -} - -function isTerminalSendGuardNotWritable(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return message.includes('terminal_guard_not_writable') -} - -function assertTerminalSendExactPtyBinding( - runtime: OrcaRuntimeService, - handle: string, - expectedPtyId: string | undefined -): void { - try { - if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { - return - } - } catch { - // Fall through to the stable guarded-send result below. - } - throw new Error('terminal_guard_not_writable') -} - -function appendPendingMultiplexOutput( - stream: TerminalMultiplexStream, - data: string, - meta?: TerminalOutputMeta -): void { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - stream.pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - stream.pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - stream.pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputBytes) - stream.pendingOutputBytes = trimmed.bytes - stream.pendingOutputOverflowed ||= trimmed.overflowed -} - -function getOutputAfterSnapshotSeq( - chunk: TerminalOutputChunk, - snapshotSeq: number | undefined -): TerminalOutputChunk | null { - if ( - typeof snapshotSeq !== 'number' || - typeof chunk.meta?.seq !== 'number' || - typeof chunk.meta.rawLength !== 'number' - ) { - return chunk - } - if (chunk.meta.seq <= snapshotSeq) { - return null - } - const chunkStartSeq = chunk.meta.seq - chunk.meta.rawLength - if (chunkStartSeq >= snapshotSeq) { - return chunk - } - if (chunk.meta.transformed) { - return null - } - const offset = snapshotSeq - chunkStartSeq - return { - data: chunk.data.slice(offset), - bytes: chunk.bytes, - meta: { - ...chunk.meta, - rawLength: chunk.meta.rawLength - offset, - sourceRanges: sliceTerminalOutputSourceRanges( - chunk.meta.sourceRanges, - offset, - chunk.data.length - ) - } - } -} - -function stripSnapshotBoundaryQuerySuffixes( - data: string, - dataStartSeq: number, - snapshotSeq: number, - queries: TerminalReplyQuerySequence[] -): string { - let output = '' - let offset = 0 - for (const query of queries) { - if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { - continue - } - const removeStart = Math.max(0, query.startSeq - dataStartSeq) - const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) - if (removeEnd <= offset || removeStart >= data.length) { - continue - } - output += data.slice(offset, removeStart) - offset = removeEnd - } - return output + data.slice(offset) -} - -function appendAckPendingOutput( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk -): void { - stream.ackPendingOutput.push(chunk) - stream.ackPendingOutputBytes += chunk.bytes.byteLength - let omittedChunkCount = 0 - while ( - stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < stream.ackPendingOutput.length - ) { - stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - stream.ackPendingOutput.splice(0, omittedChunkCount) - stream.ackPendingOutputOverflowed = true - } -} - -function trimPendingOutputToBudget( - pendingOutput: TerminalOutputChunk[], - pendingOutputBytes: number -): { bytes: number; overflowed: boolean } { - let omittedChunkCount = 0 - while ( - pendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && - omittedChunkCount < pendingOutput.length - ) { - const chunk = pendingOutput[omittedChunkCount] - pendingOutputBytes -= chunk.bytes - omittedChunkCount += 1 - } - if (omittedChunkCount > 0) { - pendingOutput.splice(0, omittedChunkCount) - } - return { bytes: pendingOutputBytes, overflowed: omittedChunkCount > 0 } -} - -function trimPendingOutputCoveredBySnapshot( - pendingOutput: TerminalOutputChunk[], - snapshotSeq: number | undefined -): { chunks: TerminalOutputChunk[]; bytes: number } { - if (typeof snapshotSeq !== 'number') { - return { - chunks: pendingOutput, - bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) - } - } - const chunks: TerminalOutputChunk[] = [] - let bytes = 0 - for (const chunk of pendingOutput) { - const chunkSeq = chunk.meta?.seq - const rawLength = chunk.meta?.rawLength ?? chunk.data.length - if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const startSeq = chunkSeq - rawLength - if (snapshotSeq >= chunkSeq) { - continue - } - if (snapshotSeq <= startSeq) { - chunks.push(chunk) - bytes += chunk.bytes - continue - } - const data = chunk.data.slice(snapshotSeq - startSeq) - const slicedBytes = terminalStreamByteLength(data) - chunks.push({ data, bytes: slicedBytes, meta: undefined }) - bytes += slicedBytes - } - return { chunks, bytes } -} - -function* iterateTerminalStreamTextPayloads(data: string): Generator> { - if (!data) { - return - } - for (const chunk of iterateTerminalOutputFrameChunks(data)) { - yield chunk.bytes - } -} - -function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { - // Why: a limited preview is an incomplete payload even when the retained buffer wasn't truncated. - return read.truncated || read.limited === true -} - -function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.max(0, Math.min(50_000, Math.floor(value))) -} - -function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { - const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] - .filter((rows): rows is number => typeof rows === 'number') - .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) - return [...new Set(candidates)] -} - -async function serializeBudgetedRequestedSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - scrollbackRows: number | undefined -): Promise { - const requestedRows = scrollbackRows ?? 0 - for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { - const serialized = await runtime.serializeAuthoritativeTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - if (!serialized) { - return null - } - const scrollbackAnsi = - 'scrollbackAnsi' in serialized && typeof serialized.scrollbackAnsi === 'string' - ? serialized.scrollbackAnsi - : '' - const data = scrollbackAnsi + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < requestedRows || overByteBudget - } - } - } - return null -} - -function sendSnapshotFrames( - sendFrame: ( - opcode: TerminalStreamOpcode, - payload?: Uint8Array - ) => boolean | void, - options: SnapshotFrameOptions -): { bytes: number; chunks: number; published: boolean } { - if ( - sendFrame( - TerminalStreamOpcode.SnapshotStart, - encodeTerminalStreamJson({ - kind: options.kind, - cols: options.cols, - rows: options.rows, - requestId: options.requestId, - displayMode: options.displayMode, - reason: options.reason, - unavailable: options.unavailable, - seq: options.seq, - cwd: options.cwd, - source: options.source, - oscLinks: options.oscLinks, - pendingEscapeTailAnsi: options.pendingEscapeTailAnsi, - // Why conditional and additive: old clients ignore the unknown field, - // and a new client must read absence as unknown rather than zero, so - // no opcode or capability negotiation is involved (Rule 1 of - // docs/reference/remote-wire-compatibility.md). - // Why `seq` is required: the flags are only proven at this frame's own - // seq, so without a replay boundary the client cannot order them. - ...(typeof options.seq === 'number' && options.kittyKeyboardFlags !== undefined - ? { kittyKeyboardFlags: options.kittyKeyboardFlags } - : {}), - truncated: options.truncated === true, - truncatedByByteBudget: options.truncatedByByteBudget === true - }) - ) === false - ) { - return { bytes: 0, chunks: 0, published: false } - } - let chunks = 0 - let bytes = 0 - for (const chunk of iterateTerminalStreamTextPayloads(options.data)) { - if (sendFrame(TerminalStreamOpcode.SnapshotChunk, chunk) === false) { - return { bytes, chunks, published: false } - } - chunks++ - bytes += chunk.byteLength - } - const published = sendFrame(TerminalStreamOpcode.SnapshotEnd) !== false - return { bytes, chunks, published } -} - -async function serializeBudgetedMobileSnapshot( - runtime: OrcaRuntimeService, - ptyId: string, - isMobile: boolean -): Promise { - if (!isMobile) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized - ? { - ...serialized, - data: (serialized.scrollbackAnsi ?? '') + serialized.data, - scrollbackRows: 0, - truncatedByByteBudget: false - } - : null - } - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - for (const rows of candidates) { - const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) - if (!serialized) { - return null - } - const data = (serialized.scrollbackAnsi ?? '') + serialized.data - const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - data, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - } - return null -} - -async function serializeStableMobileRendererSnapshot( - runtime: OrcaRuntimeService, - ptyId: string -): Promise { - const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] - let candidateIndex = 0 - for (let attempt = 0; attempt < candidates.length; attempt += 1) { - // Why: advance toward zero scrollback each retry so the final attempt always has a bounded payload. - candidateIndex = Math.max(candidateIndex, attempt) - const rows = candidates[candidateIndex] - const outputSequenceBefore = runtime.getPtyOutputSequence(ptyId) - const serialized = await runtime.serializeRendererTerminalBuffer(ptyId, { - scrollbackRows: rows - }) - const outputSequenceAfter = runtime.getPtyOutputSequence(ptyId) - if (outputSequenceBefore !== outputSequenceAfter) { - continue - } - if (!serialized) { - return null - } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) - if (!overByteBudget || rows === 0) { - return { - ...serialized, - scrollbackRows: rows, - truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget - } - } - candidateIndex += 1 - } - return null -} - -// Why: mobile xterm can't rewrap the HARD newlines baked into a restored snapshot, so a real reflow re-serializes and replays the FULL buffer at the new cols. -async function sendMobileResizeRestream( - runtime: OrcaRuntimeService, - ptyId: string, - sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number }, - shouldSend?: () => boolean -): Promise { - // Why: only a true geometry reflow rewraps scrollback; a dimensionless mode-change would re-send the whole buffer for nothing. - if (event.reason !== 'apply-layout' || runtime.isTerminalAlternateScreen(ptyId)) { - return false - } - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, true) - if (!serialized) { - return false - } - if (shouldSend && !shouldSend()) { - return true - } - sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: serialized.cols, - rows: serialized.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq ?? serialized.seq, - source: serialized.source, - cwd: serialized.cwd, - oscLinks: serialized.oscLinks, - truncated: false, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - }) - return true -} - -async function updateViewportForClient( - runtime: OrcaRuntimeService, - ptyId: string, - subscriptionKey: string, - client: TerminalViewportClient, - viewport: { cols: number; rows: number }, - defaultType: 'mobile' | 'desktop', - // Why: the one-shot RPC has no disconnect hook, so 'refresh' only updates a stream-owned floor; stream paths that own cleanup 'register'. - registration: 'register' | 'refresh' = 'register', - claim = false -): Promise<{ updated: boolean; applied: boolean }> { - const type = client.type ?? defaultType - if (type === 'mobile') { - return runtime.updateMobileViewport(ptyId, client.id, viewport) - } - // Why: stream attachment observes geometry without taking control; a later claim frame makes it authoritative. - const updated = - registration === 'refresh' - ? await runtime.refreshRemoteDesktopViewer( - ptyId, - client.id, - viewport.cols, - viewport.rows, - claim - ) - : await runtime.updateRemoteDesktopViewer( - ptyId, - subscriptionKey, - client.id, - viewport.cols, - viewport.rows, - claim - ) - return { updated, applied: updated } -} - -const TerminalHandle = z.object({ - terminal: requiredString('Missing terminal handle') -}) - -const TerminalFocus = TerminalHandle.extend({ - navigation: z.enum(['caller', 'host']).optional() -}) - -const TerminalListParams = z.object({ - worktree: OptionalString, - limit: OptionalFiniteNumber, - handles: z - .array(requiredString('Missing terminal handle').pipe(z.string().max(256))) - .max(64) - .optional(), - requireFreshPtyLiveness: z.boolean().optional(), - // Why: layouts are ~31% of a large listing and only the human CLI formatter - // reads them. Absent means "include" so pre-flag clients keep rendering them. - includeVisualLayouts: z.boolean().optional() -}) - -const TerminalResolveActive = z.object({ - worktree: OptionalString -}) - -const TerminalResolvePane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: OptionalString -}) - -const TerminalRecoverPane = z.object({ - paneKey: requiredString('Missing pane key'), - worktreeId: requiredString('Missing worktree ID'), - expectedTerminal: requiredString('Missing expected terminal handle').optional() -}) - -const TerminalRead = TerminalHandle.extend({ - cursor: z - .unknown() - .transform((value) => { - if (value === undefined) { - return undefined - } - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - return Number.NaN - } - return value - }) - .pipe( - z - .number() - .optional() - .refine((v) => v === undefined || Number.isFinite(v), { - message: 'Cursor must be a non-negative integer' - }) - ) - .optional(), - limit: OptionalFiniteNumber, - // Why: optional so an older host that does not understand it simply drops the key and answers - // with its usual stream read; the response's `source` is what tells the caller which it got. - screen: z.literal(true).optional() -}).refine((params) => !(params.screen === true && params.cursor !== undefined), { - // Why: a cursor pages through accumulated output; a screen is the current frame with nothing - // behind it. Honoring both would answer with rendered lines carrying the stream's pagination - // metadata — two frames of reference in one payload, which is the confusion `source` exists to - // remove. The CLI already refuses the pair, but the RPC is reachable without it. - message: 'Cursor cannot be combined with a screen read' -}) - -// Why: preserve the legacy contract — `title: string | null` only, `undefined` rejected, so the CLI's "reset" signal stays distinct. -const TerminalRename = TerminalHandle.extend({ - title: z.custom((value) => value === null || typeof value === 'string', { - message: 'Missing --title (pass empty string or null to reset)' - }) -}) - -const TerminalSend = TerminalHandle.extend({ - text: OptionalString, - enter: z.unknown().optional(), - interrupt: z.unknown().optional(), - // Why: older hosts strip this optional intent and retain their direct-send behavior. - agentPrompt: z.literal(true).optional(), - resolvedLaunchDraft: z - .object({ - text: z.string(), - createdAt: z.number().finite() - }) - .optional(), - requireAgentStatus: z.enum(['sendable']).optional(), - // Why: terminal-generated replies are valid input but must not transfer the shared terminal floor. - inputKind: z.enum(['query-reply']).optional(), - // Why: identifies the caller for the driver state machine; when absent (older clients) the server falls back to the most recent mobile actor (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - viewport: z - .object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) - }) - .optional(), - claimViewport: z.literal(true).optional() -}) - -const TerminalViewport = z.object({ - cols: z.number().int().min(1).max(1000), - rows: z.number().int().min(1).max(500) -}) - -const TerminalWait = TerminalHandle.extend({ - for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { - message: 'Invalid --for value. Supported: exit, tui-idle' - }), - timeoutMs: OptionalFiniteNumber -}) - -const TerminalCreateParams = z.object({ - worktree: OptionalString, - clientMutationId: z.string().min(1).max(128).optional(), - reconcileExisting: z.boolean().optional(), - command: OptionalString, - startupCommandDelivery: z.enum(['fast', 'shell-ready']).optional(), - env: z.record(z.string(), z.string()).optional(), - envToDelete: z.array(z.string().min(1).max(256)).max(32).optional(), - launchConfig: z - .object({ - agentCommand: z.string().optional(), - agentArgs: z.string(), - agentEnv: z.record(z.string(), z.string()), - ompResumeFilePath: z - .string() - .min(1) - .max(32 * 1024) - .optional() - }) - .optional(), - resumeProviderSession: z - .object({ - key: z.enum(['session_id', 'conversation_id']), - id: z.string().min(1).max(512), - transcriptPath: z.string().min(1).max(32_768).optional() - }) - .optional(), - launchToken: OptionalString, - launchAgent: z.string().refine(isTuiAgent).optional(), - terminalColorQueryReplies: z - .object({ - foreground: z.string().max(128).optional(), - background: z.string().max(128).optional() - }) - .optional(), - title: OptionalString, - focus: z.unknown().optional(), - rendererBacked: z.unknown().optional(), - activate: z.unknown().optional(), - presentation: z.enum(['background', 'focused']).optional(), - tabId: OptionalString, - leafId: OptionalString -}) - -const TerminalSplit = TerminalHandle.extend({ - direction: z - .unknown() - .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) - .pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()])) - .optional(), - command: OptionalString, - env: z.record(z.string(), z.string()).optional(), - telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional() -}) - -const TerminalStop = z.object({ - worktree: requiredString('Missing worktree selector') -}) - -const TerminalSleep = TerminalStop - -const TerminalStopExact = TerminalStop.extend({ - expectedPtyIds: z.array(requiredString('Missing PTY ID')).min(1), - keepHistory: z.boolean().optional(), - targetOnly: z.boolean().optional() -}) - -const AgentTeamsTmuxCompat = z.object({ - teamId: requiredString('Missing agent team ID'), - token: requiredString('Missing agent team token'), - envPane: requiredString('Missing tmux pane identity'), - cwd: OptionalString, - argv: z.array(z.string()) -}) - -const AgentTeamsPrepareLaunch = z.object({ - paneKey: requiredString('Missing pane key'), - env: z.record(z.string(), z.string()).optional() -}) - -const TerminalResizeForClient = z.discriminatedUnion('mode', [ - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('mobile-fit'), - cols: z.number().finite().positive(), - rows: z.number().finite().positive(), - clientId: requiredString('Missing client ID') - }), - z.object({ - terminal: requiredString('Missing terminal handle'), - mode: z.literal('restore'), - clientId: requiredString('Missing client ID') - }) -]) - -const TerminalSubscribe = TerminalHandle.extend({ - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - terminalBinaryStream: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - mobileInputLeaseOnly: z.literal(1).optional(), - queryReply: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplex = z.object({}) - -const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ - streamId: z.number().int().min(1), - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop') - }) - .optional(), - viewport: TerminalViewport.optional(), - capabilities: z - .object({ - ackOutput: z.literal(1).optional(), - ackOutputSourceRanges: z.literal(1).optional(), - desktopViewportClaims: z.literal(1).optional(), - outputPause: z.literal(1).optional(), - queryReply: z.literal(1).optional(), - writeUnavailable: z.literal(1).optional() - }) - .optional() -}) - -const TerminalMultiplexLegacyAckFrame = z - .object({ - bytes: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSourceRangeAckFrame = z - .object({ - streamGeneration: z.string().min(1), - ackedEndByte: z.number().int().nonnegative() - }) - .strict() - -const TerminalMultiplexSnapshotRequestFrame = z.object({ - requestId: z.number().int().positive().optional(), - scrollbackRows: z.number().finite().optional() -}) - -const TerminalSetDisplayMode = TerminalHandle.extend({ - // Why: 'auto' = mobile drives dims while subscribed (desktop restores on last-leave); 'desktop' = no resize, mobile scales to fit. - mode: z.enum(['auto', 'desktop']), - // Why: identifies the caller for the driver state machine; optional for older mobile clients. - client: z - .object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('desktop').optional() - }) - .optional(), - // Why: carries the measured viewport so an 'auto' toggle on a viewport-less record can phone-fit instead of no-op'ing. - viewport: z - .object({ - cols: z.number().int().positive(), - rows: z.number().int().positive() - }) - .optional() -}) - -const TerminalUnsubscribe = z.object({ - subscriptionId: requiredString('Missing subscription ID'), - // Why: lets the server rebuild the composite `${terminal}:${clientId}` cleanup key when older clients pass a bare subscriptionId (docs/mobile-presence-lock.md). - client: z - .object({ - id: requiredString('Missing client ID') - }) - .optional() -}) - -// Why: in-place update avoids an unsubscribe→resubscribe that flashed the lock banner and stranded the PTY at phone dims (docs/mobile-presence-lock.md). -const TerminalUpdateViewport = TerminalHandle.extend({ - client: z.object({ - id: requiredString('Missing client ID'), - type: z.enum(['mobile', 'desktop']).default('mobile').optional() - }), - viewport: z.object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(8).max(120) - }), - claim: z.boolean().optional() -}) - -// Why: phone-fit auto-restore preference (docs/mobile-fit-hold.md); `null` = Indefinite, finite ms clamped to [5_000, 60min] server-side. -const TerminalSetAutoRestoreFit = z.object({ - ms: z.number().nullable() -}) + TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS, + TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS +} from './terminal/terminal-viewport-methods' +// The manifest order is part of the released RPC contract. Keep composition here so the +// public entry point owns registration rather than forwarding an aggregated child export. export const TERMINAL_METHODS: RpcAnyMethod[] = [ - defineMethod({ - name: 'terminal.list', - params: TerminalListParams, - handler: async (params, { runtime }) => - runtime.listTerminals(params.worktree, params.limit, { - handles: params.handles, - requireFreshPtyLiveness: params.requireFreshPtyLiveness, - includeVisualLayouts: params.includeVisualLayouts - }) - }), - defineMethod({ - name: 'terminal.resolveActive', - params: TerminalResolveActive, - handler: async (params, { runtime }) => ({ - handle: await runtime.resolveActiveTerminal(params.worktree) - }) - }), - defineMethod({ - name: 'terminal.resolvePane', - params: TerminalResolvePane, - handler: async (params, { runtime }) => ({ - terminal: runtime.resolveTerminalPane(params.paneKey, params.worktreeId) - }) - }), - defineMethod({ - name: 'terminal.recoverPane', - params: TerminalRecoverPane, - handler: async (params, { runtime }) => ({ - terminal: await runtime.recoverTerminalPane( - params.paneKey, - params.worktreeId, - params.expectedTerminal - ) - }) - }), - defineMethod({ - name: 'terminal.show', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - terminal: await runtime.showTerminal(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.read', - params: TerminalRead, - handler: async (params, { runtime }) => ({ - terminal: await runtime.readTerminal(params.terminal, { - cursor: params.cursor, - limit: params.limit, - screen: params.screen - }) - }) - }), - defineMethod({ - name: 'terminal.inspectProcess', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - process: await runtime.inspectTerminalProcess(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.isRunningAgent', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - isRunningAgent: await runtime.isTerminalRunningAgent(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.agentStatus', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - agentStatus: await runtime.getTerminalAgentStatus(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.rename', - params: TerminalRename, - handler: async (params, { runtime }) => ({ - rename: await runtime.renameTerminal(params.terminal, params.title || null) - }) - }), - defineMethod({ - name: 'terminal.clearBuffer', - params: TerminalHandle, - handler: async (params, { runtime }) => ({ - clear: await runtime.clearTerminalBuffer(params.terminal) - }) - }), - defineMethod({ - name: 'terminal.send', - params: TerminalSend, - handler: async (params, { runtime, clientId, signal }) => { - await assertTerminalSendTextWithinLimit(params.text) - await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) - const queryReplyClientId = clientId ?? params.client?.id - if ( - params.inputKind === 'query-reply' && - (!params.text || - !isTerminalQueryReply(params.text) || - params.enter === true || - params.interrupt === true || - params.agentPrompt === true || - params.requireAgentStatus !== undefined || - params.client?.type !== 'mobile' || - !queryReplyClientId || - (clientId !== undefined && params.client.id !== clientId)) - ) { - throw new InvalidArgumentError('Invalid terminal query reply') - } - // Why: a stale handle must fail with terminal_handle_stale, not evaluate driver/lock state against the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null - if ( - params.inputKind === 'query-reply' && - leaf?.ptyId && - !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) - ) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - if ( - leaf?.ptyId && - params.client?.type === 'desktop' && - params.claimViewport === true && - params.viewport - ) { - const claim = await updateViewportForClient( - runtime, - leaf.ptyId, - `send:${params.client.id}`, - params.client, - params.viewport, - 'desktop', - 'refresh', - true - ) - // Why: a stream-less request can't safely create ownership, so never write at stale geometry. - if (!claim.updated || isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - } - const hasText = typeof params.text === 'string' && params.text.length > 0 - const hasSuffix = params.enter === true || params.interrupt === true - if (params.requireAgentStatus === 'sendable' && hasText && hasSuffix) { - // Why: guarded sends are two-phase; reject combined payload + submit so a guard flip can't cause partial delivery. - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - // Why: recheck permission/no-agent state immediately before accepting the PTY write. - const assertSendPreconditions = - params.requireAgentStatus === 'sendable' - ? async (ptyId?: string): Promise => { - await assertTerminalAgentSendable({ - runtime, - handle: params.terminal, - assertWritable: () => { - assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) - if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - throw new Error('terminal_guard_not_writable') - } - } - }) - } - : undefined - if (params.requireAgentStatus === 'sendable') { - try { - await assertSendPreconditions?.(leaf?.ptyId ?? undefined) - } catch (error) { - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (!refusedReason) { - throw error - } - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - } - const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - const mobileFloorClaim: MobileInputFloorClaimHolder = { current: null } - const beforeWrite = assertSendPreconditions - const useSettledAgentPrompt = - params.agentPrompt === true && - hasText && - params.enter === true && - params.interrupt !== true && - params.client?.type === 'desktop' && - (await runtime.isTerminalRunningSettledPromptAgent(params.terminal)) - const reserveWrite = - params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId - ? (ptyId: string): void => { - const claim = runtime.beginMobileInputFloor(ptyId, mobileFloorClientId) - if (!claim) { - throw new Error('mobile_input_floor_unavailable') - } - mobileFloorClaim.current = claim - } - : undefined - let result - try { - result = useSettledAgentPrompt - ? await runtime.sendTerminalAgentPrompt(params.terminal, params.text!, { - beforeWrite, - signal - }) - : await runtime.sendTerminal( - params.terminal, - { - text: params.text, - enter: params.enter === true, - interrupt: params.interrupt === true - }, - { - beforeWrite, - ...(reserveWrite ? { reserveWrite } : {}), - ...(params.inputKind !== 'query-reply' && mobileFloorClientId - ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } - : {}) - } - ) - } catch (error) { - mobileFloorClaim.current?.rollback() - const refusedReason = getTerminalSendGuardRefusedReason(error) - if (refusedReason) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0, - refusedReason - } - } - } - if (isTerminalSendGuardNotWritable(error)) { - return { - send: { - handle: params.terminal, - accepted: false, - bytesWritten: 0 - } - } - } - throw error - } - if (result.accepted !== true) { - mobileFloorClaim.current?.rollback() - } - if ( - result.accepted === true && - params.enter === true && - params.client?.type === 'mobile' && - params.resolvedLaunchDraft - ) { - runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft) - } - // Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver. - return { send: result } - } - }), - defineMethod({ - name: 'terminal.wait', - params: TerminalWait, - handler: async (params, { runtime, signal }) => ({ - wait: await runtime.waitForTerminal(params.terminal, { - condition: params.for, - timeoutMs: params.timeoutMs, - signal - }) - }) - }), - defineMethod({ - name: 'terminal.create', - params: TerminalCreateParams, - handler: async (params, { runtime, pairedDeviceId, clientId }) => ({ - terminal: await runtime.dedupeTerminalCreate( - pairedDeviceId ?? clientId ?? 'local', - params.worktree, - params.clientMutationId, - params.reconcileExisting === true, - (canonicalWorktreeSelector, preAllocatedHandle) => - runtime.createTerminal(canonicalWorktreeSelector, { - command: params.command, - startupCommandDelivery: params.startupCommandDelivery, - env: params.env, - envToDelete: params.envToDelete, - ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), - ...(params.resumeProviderSession - ? { resumeProviderSession: params.resumeProviderSession } - : {}), - ...(params.launchToken ? { launchToken: params.launchToken } : {}), - ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), - ...(params.terminalColorQueryReplies - ? { terminalColorQueryReplies: params.terminalColorQueryReplies } - : {}), - title: params.title, - focus: params.focus === true, - rendererBacked: params.rendererBacked === true, - activate: params.activate === true, - presentation: params.presentation, - tabId: params.tabId, - leafId: params.leafId, - ...(preAllocatedHandle ? { preAllocatedHandle } : {}) - }) - ) - }) - }), - defineMethod({ - name: 'terminal.split', - params: TerminalSplit, - handler: async (params, { runtime }) => ({ - split: await runtime.splitTerminal(params.terminal, { - direction: params.direction, - command: params.command, - env: params.env, - telemetrySource: params.telemetrySource - }) - }) - }), - defineMethod({ - name: 'terminal.stop', - params: TerminalStop, - handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.sleep', - params: TerminalSleep, - handler: async (params, { runtime }) => runtime.sleepTerminalsForWorktree(params.worktree) - }), - defineMethod({ - name: 'terminal.stopExact', - params: TerminalStopExact, - handler: async (params, { runtime }) => - runtime.stopExactTerminalsForWorktree(params.worktree, params.expectedPtyIds, { - keepHistory: params.keepHistory, - targetOnly: params.targetOnly - }) - }), - defineMethod({ - name: 'terminal.resizeForClient', - params: TerminalResizeForClient, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not resize the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const result = await runtime.resizeForClient( - leaf.ptyId, - params.mode, - params.clientId, - params.mode === 'mobile-fit' ? params.cols : undefined, - params.mode === 'mobile-fit' ? params.rows : undefined - ) - return { - terminal: { - handle: params.terminal, - ...result - } - } - } - }), - defineMethod({ - name: 'terminal.focus', - params: TerminalFocus, - handler: async (params, { runtime, clientKind }) => ({ - focus: await runtime.focusTerminal(params.terminal, { - navigateHost: navigationTargetsHost( - resolveRuntimeNavigationTarget({ navigation: params.navigation, clientKind }) - ) - }) - }) - }), - defineMethod({ - name: 'terminal.close', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.close', - context, - 'terminal', - params.terminal, - () => context.runtime.closeTerminal(params.terminal) - ) - }) - }), - defineMethod({ - name: 'terminal.closeTab', - params: TerminalHandle, - handler: async (params, context) => ({ - close: await withTerminalCloseAttribution( - 'terminal.closeTab', - context, - 'terminal-tab', - params.terminal, - () => context.runtime.closeTerminalTab(params.terminal) - ) - }) - }), - defineMethod({ - name: 'agentTeams.tmuxCompat', - params: AgentTeamsTmuxCompat, - handler: async (params, { runtime }) => ({ - tmux: await runtime.handleAgentTeamsTmuxCompat(params) - }) - }), - defineMethod({ - name: 'agentTeams.prepareLaunch', - params: AgentTeamsPrepareLaunch, - handler: async (params, { runtime }) => ({ - launch: await runtime.prepareClaudeAgentTeamsLeader({ - paneKey: params.paneKey, - baseEnv: params.env - }) - }) - }), - defineMethod({ - name: 'terminal.setDisplayMode', - params: TerminalSetDisplayMode, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not mutate the wrong PTY's display mode/viewport (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - // Why: late-bind viewport for desktop-subscribed callers; otherwise an 'auto' toggle skips phone-fit and nothing resizes. - if (params.viewport && params.client?.id) { - runtime.updateMobileSubscriberViewport(leaf.ptyId, params.client.id, params.viewport) - } - if (params.client && params.client.type === 'mobile' && params.mode !== 'desktop') { - runtime.markMobileActor(leaf.ptyId, params.client.id) - } - runtime.setMobileDisplayMode(leaf.ptyId, params.mode) - await runtime.applyMobileDisplayMode(leaf.ptyId) - return { mode: params.mode, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - defineMethod({ - name: 'terminal.restoreFit', - params: TerminalHandle, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not reclaim the wrong PTY to desktop dims (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - return { restored: await runtime.reclaimTerminalForDesktop(leaf.ptyId) } - } - }), - defineMethod({ - name: 'terminal.getDisplayMode', - params: TerminalHandle, - handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) - const mode = leaf?.ptyId ? runtime.getMobileDisplayMode(leaf.ptyId) : 'auto' - const isPhoneFitted = leaf?.ptyId ? runtime.isMobileSubscriberActive(leaf.ptyId) : false - return { mode, isPhoneFitted } - } - }), - defineMethod({ - name: 'terminal.updateViewport', - params: TerminalUpdateViewport, - handler: async (params, { runtime }) => { - // Why: a stale handle must fail with terminal_handle_stale, not write viewport state to the wrong PTY (#7718). - const leaf = runtime.resolveLiveLeafForHandle(params.terminal) - if (!leaf?.ptyId) { - throw new Error('no_connected_pty') - } - const viewportUpdate = await updateViewportForClient( - runtime, - leaf.ptyId, - `viewport:${params.client.id}`, - params.client, - params.viewport, - 'mobile', - // Why: one-shot RPC with no disconnect hook — refresh the existing stream-owned floor, never create a leak-prone one. - 'refresh', - params.claim === true - ) - return { ...viewportUpdate, seq: runtime.getLayout(leaf.ptyId)?.seq } - } - }), - // Why: one streaming RPC owns the binary socket and routes many panes by streamId; legacy subscribe stays as fallback. - defineStreamingMethod({ - name: 'terminal.multiplex', - params: TerminalMultiplex, - handler: async ( - _params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - if (!sendBinary || !registerBinaryStreamHandler || !connectionId) { - throw new Error('binary_terminal_stream_required') - } - - let closed = false - let cursor = 0 - const streams = new Map() - const sourceRangeRegistry = new TerminalSourceRangeRegistry() - const pendingPtyWaitControllers = new Map>() - let ackTotalInFlightBytes = 0 - let ackTotalWindowBytes = TERMINAL_MULTIPLEX_ACK_TOTAL_INITIAL_WINDOW_BYTES - let ackFlushCursorStreamId: number | null = null - let resolveMultiplex = (): void => {} - const multiplexClosed = new Promise((resolve) => { - resolveMultiplex = resolve - }) - const sendFrame = ( - streamId: number, - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - seq?: number, - onRejected?: () => void - ): boolean => { - if (closed) { - onRejected?.() - return false - } - // Why: a seq-less Output chunk must carry sentinel 0, not the control-frame cursor, or it poisons the client's frame-drop tracker. - const resolvedSeq = - typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ - let sent: boolean | void - try { - sent = sendBinary( - encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) - ) - } catch { - onRejected?.() - closeMultiplex() - return false - } - if (sent === false) { - onRejected?.() - // Why: false means the transport discarded this frame; reconnect is the only available retry boundary with an authoritative snapshot. - closeMultiplex() - return false - } - return true - } - const sendStreamError = (streamId: number, message: string): void => { - sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) - emit({ type: 'error', streamId, message }) - } - const notifyStreamWriteUnavailable = ( - stream: TerminalMultiplexStream, - outcome: TerminalStreamInputOutcome - ): void => { - if ( - closed || - streams.get(stream.streamId) !== stream || - outcome !== 'rejected' || - !stream.supportsWriteUnavailable - ) { - return - } - sendFrame(stream.streamId, TerminalStreamOpcode.WriteUnavailable) - } - const sendResizedFrame = ( - stream: TerminalMultiplexStream, - event: { cols: number; rows: number; displayMode: string; reason: string; seq?: number } - ): void => { - stream.lastResizeCols = event.cols - sendFrame( - stream.streamId, - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { - if (!stream.ackOutput) { - return true - } - return ( - stream.ackInFlightBytes + bytes <= stream.ackWindowBytes && - ackTotalInFlightBytes + bytes <= ackTotalWindowBytes && - (!stream.ackOutputSourceRanges || stream.sourceRangeLedger?.canAccept(bytes) === true) - ) - } - const sendAckGatedOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): boolean => { - const prepared = stream.ackOutputSourceRanges - ? stream.sourceRangeLedger?.prepareAccept( - chunk.bytes.byteLength, - chunk.displayLength, - chunk.sourceRanges ?? [], - chunk.seq - ) - : undefined - if (stream.ackOutputSourceRanges && prepared?.status !== 'ready') { - if (prepared?.status !== 'capacity') { - detachStream(stream.streamId, true) - } - return false - } - const admission = prepared?.status === 'ready' ? prepared.admission : undefined - const sent = sendFrame( - stream.streamId, - chunk.opcode ?? TerminalStreamOpcode.Output, - chunk.bytes, - chunk.seq, - admission?.rollback - ) - if (!sent) { - return false - } - if (admission && !admission.commit()) { - detachStream(stream.streamId, true) - return false - } - if (stream.ackOutput) { - stream.ackInFlightBytes += chunk.bytes.byteLength - ackTotalInFlightBytes += chunk.bytes.byteLength - } - return true - } - const queueOrSendOutput = ( - stream: TerminalMultiplexStream, - chunk: TerminalOutputFrameChunk - ): void => { - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if ( - stream.ackPendingOutputOverflowed || - stream.ackPendingOutput.length > 0 || - !canSendAckGatedOutput(stream, chunk.bytes.byteLength) - ) { - appendAckPendingOutput(stream, chunk) - return - } - sendAckGatedOutput(stream, chunk) - } - const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { - if ( - closed || - streams.get(stream.streamId) !== stream || - stream.outputPaused || - stream.ackRecoverySnapshotInFlight - ) { - return - } - stream.ackRecoverySnapshotInFlight = true - let replacement: RemoteTerminalSourceRangeReplacementReservation | null = null - try { - const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) - if (closed || streams.get(stream.streamId) !== stream || stream.outputPaused) { - return - } - if (!serialized) { - throw new Error('Remote terminal recovery snapshot unavailable.') - } - if ( - stream.ackOutputSourceRanges && - (serialized.source === undefined || typeof serialized.seq !== 'number') - ) { - throw new Error('Remote terminal recovery snapshot source identity unavailable.') - } - if ( - stream.ackOutputSourceRanges && - serialized.source !== undefined && - typeof serialized.seq === 'number' - ) { - replacement = runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'ack-pending-overflow' - ) - stream.sourceRangeReplacement = replacement - } - const displayMode = runtime.getMobileDisplayMode(stream.ptyId) - const publication = sendSnapshotFrames( - (opcode, payload) => - !closed && - streams.get(stream.streamId) === stream && - sendFrame(stream.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized.cols, - rows: serialized.rows, - displayMode, - reason: 'ack-pending-overflow', - seq: serialized.seq, - source: serialized.source, - kittyKeyboardFlags: serialized.kittyKeyboardFlags, - truncatedByByteBudget: serialized.truncatedByByteBudget, - data: serialized.data - } - ) - if (!publication.published) { - throw new Error('Remote terminal recovery snapshot was not published.') - } - if (closed || streams.get(stream.streamId) !== stream) { - throw new Error('Remote terminal recovery snapshot stream detached.') - } - const localReplacement = replacement - ? typeof serialized.seq === 'number' - ? stream.sourceRangeLedger?.planSourceRangeReplacement(serialized.seq) - : null - : null - if (replacement && !localReplacement) { - throw new Error('Remote terminal recovery source ledger replacement unavailable.') - } - if ( - replacement && - (!serialized.source || - typeof serialized.seq !== 'number' || - !runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - })) - ) { - throw new Error('Remote terminal recovery snapshot replacement was not accepted.') - } - localReplacement?.commit() - stream.sourceRangeReplacement = null - replacement = null - if (typeof serialized.seq === 'number') { - const snapshotSeq = serialized.seq - const retained = stream.ackPendingOutput.filter( - (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) - ) - stream.ackPendingOutput = retained - stream.ackPendingOutputBytes = retained.reduce( - (total, chunk) => total + chunk.bytes.byteLength, - 0 - ) - } - stream.ackPendingOutputOverflowed = false - } catch (error) { - if (replacement) { - if (stream.sourceRangeReplacement === replacement) { - stream.sourceRangeReplacement = null - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'ack-pending-overflow-unpublished' - ) - } - replacement = null - } - if (closed || streams.get(stream.streamId) !== stream) { - return - } - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' - ) - detachStream(stream.streamId, true) - } finally { - if (streams.get(stream.streamId) === stream) { - stream.ackRecoverySnapshotInFlight = false - flushAllAckPendingOutput() - } - } - } - const flushAckPendingOutput = ( - stream: TerminalMultiplexStream, - maxChunks = Number.POSITIVE_INFINITY - ): number => { - if (stream.outputPaused) { - return 0 - } - if (stream.ackPendingOutputOverflowed) { - void sendAckRecoverySnapshot(stream) - return 0 - } - let flushed = 0 - while ( - flushed < stream.ackPendingOutput.length && - flushed < maxChunks && - canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) - ) { - if (!sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!)) { - return flushed - } - flushed += 1 - } - if (flushed > 0) { - stream.ackPendingOutput.splice(0, flushed) - stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( - (total, pending) => total + pending.bytes.byteLength, - 0 - ) - } - return flushed - } - const flushAllAckPendingOutput = (): void => { - const ordered = Array.from(streams.values()) - ackFlushCursorStreamId = drainTerminalMultiplexRoundRobin({ - streams: ordered, - cursorStreamId: ackFlushCursorStreamId, - canContinue: () => !closed, - drainOne: (stream) => { - if (streams.get(stream.streamId) !== stream) { - return false - } - if (flushAckPendingOutput(stream, 1) > 0) { - return true - } - return false - } - }) - } - const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { - if (!stream.ackOutput || bytes <= 0) { - return - } - const acknowledged = Math.min(stream.ackInFlightBytes, bytes) - stream.ackWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_STREAM_MAX_WINDOW_BYTES, - stream.ackWindowBytes + acknowledged - ) - ackTotalWindowBytes = Math.min( - TERMINAL_MULTIPLEX_ACK_TOTAL_MAX_WINDOW_BYTES, - ackTotalWindowBytes + acknowledged - ) - stream.ackInFlightBytes -= acknowledged - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) - flushAllAckPendingOutput() - } - const acknowledgeSourceRanges = ( - stream: TerminalMultiplexStream, - streamGeneration: string, - ackedEndByte: number - ): void => { - if (!stream.ackOutputSourceRanges) { - return - } - const result = stream.sourceRangeLedger?.acknowledge(streamGeneration, ackedEndByte) - if (!result) { - return - } - if (result.status !== 'accepted') { - return - } - if (result.settled.length > 0) { - runtime.settleRemoteTerminalSourceRanges( - { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - result.settled - ) - } - acknowledgeOutput(stream, result.acknowledgedBytes) - } - const detachSourceRangeConsumer = (stream: TerminalMultiplexStream, reason: string): void => { - if (!stream.sourceRangeConsumerAttached) { - return - } - stream.sourceRangeConsumerAttached = false - const ledger = stream.sourceRangeLedger - stream.sourceRangeLedger = null - if (!ledger) { - return - } - const identity = { - ptyId: stream.ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - } - const transfer = ledger.beginTransfer() - const ranges = transfer.frames.flatMap((frame) => frame.sourceRanges) - try { - runtime.cancelRemoteTerminalSourceRanges(identity, ranges, reason) - } finally { - transfer.commit() - } - } - const detachStream = ( - streamId: number, - emitEnd: boolean, - releaseRemoteDesktopDriver = true - ): void => { - const stream = streams.get(streamId) - if (!stream) { - return - } - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'stream-detached-replacement-aborted' - ) - } - stream.outputBatcher.flush() - stream.outputBatcher.dispose() - detachSourceRangeConsumer(stream, 'stream-detached') - ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) - stream.ackInFlightBytes = 0 - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - stream.ackRecoverySnapshotInFlight = false - stream.unsubscribeData() - stream.unsubscribeResize() - stream.unsubscribeFit() - stream.unsubscribeDriver() - stream.unregisterBinaryHandler() - streams.delete(streamId) - flushAllAckPendingOutput() - // Why: release the runtime exit-waiter for this slot (see the field's note); delete before abort so its .catch no-ops instead of re-detaching. - stream.exitWaiterAbort.abort() - if (stream.isMobile && stream.client?.id) { - runtime.handleMobileUnsubscribe(stream.ptyId, stream.client.id) - } else if ( - releaseRemoteDesktopDriver && - stream.registeredRemoteDesktopDriver && - stream.client?.id - ) { - // Why: release the width floor only if THIS stream took it, so a passive stream can't release a peer's floor. - runtime.unregisterRemoteDesktopViewer(stream.ptyId, stream.remoteDesktopSubscriptionKey) - } - if (emitEnd) { - emit({ type: 'end', streamId }) - } - } - const cancelPendingPtyWaits = (streamId: number): void => { - const controllers = pendingPtyWaitControllers.get(streamId) - if (!controllers) { - return - } - pendingPtyWaitControllers.delete(streamId) - for (const controller of controllers) { - controller.abort() - } - } - const cancelAllPendingPtyWaits = (): void => { - for (const streamId of Array.from(pendingPtyWaitControllers.keys())) { - cancelPendingPtyWaits(streamId) - } - } - const closeMultiplex = (): void => { - if (closed) { - return - } - closed = true - signal?.removeEventListener('abort', cancelAllPendingPtyWaits) - cancelAllPendingPtyWaits() - const remoteDesktopKeysByPty = new Map() - for (const streamId of Array.from(streams.keys())) { - const stream = streams.get(streamId) - if (stream?.registeredRemoteDesktopDriver && !stream.isMobile && stream.client?.id) { - const keys = remoteDesktopKeysByPty.get(stream.ptyId) ?? [] - keys.push(stream.remoteDesktopSubscriptionKey) - remoteDesktopKeysByPty.set(stream.ptyId, keys) - } - detachStream(streamId, false, false) - } - // Why: one connection can own many panes on the same PTY; remove floors together so close scans each registry once. - for (const [ptyId, subscriptionKeys] of remoteDesktopKeysByPty) { - void runtime.unregisterRemoteDesktopViewers(ptyId, subscriptionKeys) - } - unregisterControlHandler() - resolveMultiplex() - } - const handleSlotFrame = ( - stream: TerminalMultiplexStream, - frame: TerminalStreamFrame - ): void => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(stream.streamId) - detachStream(stream.streamId, false) - return - } - if (frame.opcode === TerminalStreamOpcode.Ack) { - const payload = decodeTerminalStreamJson(frame.payload) ?? {} - if (stream.ackOutputSourceRanges) { - const parsed = TerminalMultiplexSourceRangeAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeSourceRanges( - stream, - parsed.data.streamGeneration, - parsed.data.ackedEndByte - ) - } - } else { - const parsed = TerminalMultiplexLegacyAckFrame.safeParse(payload) - if (parsed.success) { - acknowledgeOutput(stream, parsed.data.bytes) - } - } - return - } - if ( - frame.opcode === TerminalStreamOpcode.Input || - (frame.opcode === TerminalStreamOpcode.QueryReply && stream.supportsQueryReply) - ) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - // Mobile already has the higher-priority floor, so a rejected desktop claim must not suppress later phone input. - const inputClaimTail = stream.isMobile ? Promise.resolve(true) : stream.desktopClaimTail - void inputClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, stream.ptyId, stream.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: stream.terminal, - ptyId: stream.ptyId, - text, - client: stream.client, - isMobile: stream.isMobile, - inputKind: frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input' - }) - notifyStreamWriteUnavailable(stream, outcome) - }) - return - } - if (frame.opcode === TerminalStreamOpcode.SetOutputPaused && stream.supportsOutputPause) { - const payload = decodeTerminalStreamJson<{ paused?: unknown }>(frame.payload) - if (typeof payload?.paused !== 'boolean' || stream.outputPaused === payload.paused) { - return - } - stream.outputPaused = payload.paused - if (stream.outputPaused) { - stream.outputBatcher.flush() - stream.ackPendingOutput = [] - stream.ackPendingOutputBytes = 0 - stream.ackPendingOutputOverflowed = false - } - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && stream.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - // Why: resize registers stream-scoped geometry so detach can release it; older clients lack explicit claims. - if (!stream.isMobile && stream.client?.id) { - stream.registeredRemoteDesktopDriver = true - if (stream.buffering) { - stream.pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - stream.desktopClaimTail = stream.desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!, - { cols, rows }, - stream.isMobile ? 'mobile' : 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - return stream.supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - stream.client && - !stream.isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if (!viewport || typeof viewport.cols !== 'number' || typeof viewport.rows !== 'number') { - return - } - const cols = viewport.cols - const rows = viewport.rows - stream.registeredRemoteDesktopDriver = true - stream.desktopClaimTail = stream.desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client!.id, - cols, - rows, - true - ) - ) - .catch(() => false) - return - } - if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { - const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( - decodeTerminalStreamJson(frame.payload) ?? {} - ) - void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) - } - } - const sendRequestedSnapshot = async ( - stream: TerminalMultiplexStream, - request: z.infer - ): Promise => { - if (closed || streams.get(stream.streamId) !== stream) { - return - } - stream.outputBatcher.flush() - stream.pendingOutputOverflowed = false - stream.buffering = true - const requestId = request.requestId - let sentSnapshotOutputSeq: number | undefined - try { - const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) - let serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - let size = runtime.getTerminalSize(stream.ptyId) - let displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - // Why: the overflowed tail is newer than the first snapshot, so retry for a current image instead of null. - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - serialized = await serializeBudgetedRequestedSnapshot( - runtime, - stream.ptyId, - scrollbackRows - ) - if (closed || streams.get(stream.streamId) !== stream) { - return - } - size = runtime.getTerminalSize(stream.ptyId) - displayMode = runtime.getMobileDisplayMode(stream.ptyId) - if (stream.pendingOutputOverflowed) { - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: size?.cols ?? 80, - rows: size?.rows ?? 24, - requestId, - displayMode, - truncated: true, - truncatedByByteBudget: false, - unavailable: 'pending-output-overflowed', - data: '' - }) - return - } - } - sentSnapshotOutputSeq = serialized?.seq - sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - requestId, - displayMode, - seq: serialized?.seq, - cwd: serialized?.cwd, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - truncated: false, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - // Why: no serializer answered, which is not proof the pane is empty — say so instead of passing off '' as the buffer. - unavailable: serialized ? undefined : 'no-serializable-buffer', - data: serialized?.data ?? '' - }) - } catch (error) { - sendStreamError( - stream.streamId, - error instanceof Error ? error.message : 'Remote terminal snapshot failed.' - ) - } finally { - if (streams.get(stream.streamId) === stream) { - const shouldFlushPendingOutput = !stream.pendingOutputOverflowed - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (shouldFlushPendingOutput) { - for (const chunk of pendingOutput) { - // Why: an untagged reply resets the client to the snapshot's - // high-water, so covered bytes would render twice; tagged - // snapshots feed a side consumer and the live view still - // needs every buffered chunk. - const uncovered = - typeof requestId === 'number' - ? chunk - : getOutputAfterSnapshotSeq(chunk, sentSnapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - // Why: a resize parked during snapshot buffering must be applied now, or it is dropped until the viewer's next resize. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - stream.ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - } - } - } - const handleSubscribeFrame = async (payload: Uint8Array): Promise => { - const raw = decodeTerminalStreamJson(payload) - const parsed = TerminalMultiplexSubscribeFrame.safeParse(raw) - if (!parsed.success) { - return - } - const request = parsed.data - detachStream(request.streamId, false) - cancelPendingPtyWaits(request.streamId) - - const isMobile = request.client?.type === 'mobile' - let leaf: { ptyId: string | null } | null - try { - // Why: binding the stream to whatever PTY now occupies a stale handle's pane would mirror the wrong terminal (#7718). - leaf = runtime.resolveLiveLeafForHandle(request.terminal) - } catch { - sendStreamError(request.streamId, 'terminal_handle_stale') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (!leaf?.ptyId && request.client) { - if ( - pendingPtyWaitControllers.size >= - TERMINAL_MULTIPLEX_MAX_PENDING_PTY_WAITS_PER_CONNECTION - ) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - runtime.requestRendererTerminalTabMount(request.terminal) - const waitController = new AbortController() - const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() - pendingControllers.add(waitController) - pendingPtyWaitControllers.set(request.streamId, pendingControllers) - if (signal?.aborted) { - waitController.abort() - } - // Why: the live slot handler does not exist until the PTY attaches; retain cancellation ownership while the pane is still pending. - const unregisterPendingHandler = registerBinaryStreamHandler( - request.streamId, - (frame) => { - if (frame.opcode === TerminalStreamOpcode.Unsubscribe) { - cancelPendingPtyWaits(request.streamId) - detachStream(request.streamId, false) - } - } - ) - try { - const ptyId = await runtime.waitForLeafPtyId( - request.terminal, - 10_000, - waitController.signal - ) - leaf = { ptyId } - } catch { - if (closed || signal?.aborted || waitController.signal.aborted) { - return - } - // Fall through to the explicit no_connected_pty error below. - } finally { - const currentControllers = pendingPtyWaitControllers.get(request.streamId) - currentControllers?.delete(waitController) - if (currentControllers?.size === 0) { - pendingPtyWaitControllers.delete(request.streamId) - } - unregisterPendingHandler() - } - } - if (!leaf?.ptyId) { - sendStreamError(request.streamId, 'no_connected_pty') - emit({ type: 'end', streamId: request.streamId }) - return - } - if (closed) { - return - } - // Why: a competing subscribe may own this streamId after the PTY await; detach it so an orphaned view subscriber can't silence the model responder (terminal-query-authority.md). - detachStream(request.streamId, false) - if (streams.size >= TERMINAL_MULTIPLEX_MAX_ACTIVE_STREAMS_PER_CONNECTION) { - sendStreamError(request.streamId, TERMINAL_MULTIPLEX_STREAM_LIMIT_ERROR) - emit({ type: 'end', streamId: request.streamId }) - return - } - - const ptyId = leaf.ptyId - const remoteDesktopSubscriptionKey = `multiplex:${connectionId}:${request.streamId}` - const streamGeneration = randomUUID() - const requestedSourceRangeConsumer = - request.capabilities?.ackOutput === 1 && request.capabilities?.ackOutputSourceRanges === 1 - const sourceRangeLedger = requestedSourceRangeConsumer - ? sourceRangeRegistry.open(streamGeneration) - : null - const sourceRangeConsumerAttached = - sourceRangeLedger !== null && - runtime.attachRemoteTerminalSourceRangeConsumer({ - ptyId, - consumerId: remoteDesktopSubscriptionKey, - streamGeneration - }) - if (!sourceRangeConsumerAttached) { - sourceRangeLedger?.close() - } - const stream: TerminalMultiplexStream = { - streamId: request.streamId, - terminal: request.terminal, - ptyId, - client: request.client, - isMobile, - ackOutput: request.capabilities?.ackOutput === 1, - ackOutputSourceRanges: sourceRangeConsumerAttached, - streamGeneration, - sourceRangeLedger: sourceRangeConsumerAttached ? sourceRangeLedger : null, - sourceRangeConsumerAttached, - sourceRangeReplacement: null, - ackInFlightBytes: 0, - ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, - supportsOutputPause: request.capabilities?.outputPause === 1, - supportsQueryReply: request.capabilities?.queryReply === 1, - supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, - outputPaused: false, - supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, - desktopClaimTail: Promise.resolve(true), - registeredRemoteDesktopDriver: false, - // Why: streamId is client-local, so key the width floor by connectionId or two connections sharing stream 1 for one PTY clobber each other's floor. - remoteDesktopSubscriptionKey, - pendingRemoteDesktopViewport: null, - buffering: true, - ackPendingOutput: [], - ackPendingOutputBytes: 0, - ackPendingOutputOverflowed: false, - ackRecoverySnapshotInFlight: false, - pendingOutput: [], - pendingOutputBytes: 0, - pendingOutputOverflowed: false, - lastResizeCols: undefined, - resizeGeneration: 0, - outputBatcher: createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - request.streamId, - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - queueOrSendOutput(stream, chunk) - } - }), - unsubscribeData: () => {}, - unsubscribeResize: () => {}, - unsubscribeFit: () => {}, - unsubscribeDriver: () => {}, - unregisterBinaryHandler: () => {}, - exitWaiterAbort: new AbortController() - } - streams.set(request.streamId, stream) - stream.unregisterBinaryHandler = registerBinaryStreamHandler(request.streamId, (frame) => - handleSlotFrame(stream, frame) - ) - - try { - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.outputPaused) { - return - } - if (stream.buffering) { - appendPendingMultiplexOutput(stream, data, meta) - return - } - stream.outputBatcher.push(data, meta) - }) - // Why: a multiplexed stream feeds a remote xterm view with query authority, so the main model responder yields while attached (terminal-query-authority.md). - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - stream.unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - - if (isMobile && request.client?.id) { - await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) - } else if (request.client?.id && request.viewport) { - // Why: subscribe records this stream's geometry and cleanup key but doesn't claim ownership; activity frames claim later. - stream.registeredRemoteDesktopDriver = true - stream.pendingRemoteDesktopViewport = request.viewport - } - if ( - !isMobile && - request.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - await updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - request.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ) - } - if (closed || streams.get(request.streamId) !== stream) { - return - } - - let read = await runtime.readTerminal(request.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - let initialOutputOverflowed = false - if (stream.pendingOutputOverflowed) { - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - read = await runtime.readTerminal(request.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed || streams.get(request.streamId) !== stream) { - return - } - if (stream.pendingOutputOverflowed) { - initialOutputOverflowed = true - stream.pendingOutput.splice(0) - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: layout versions and output offsets are different sequence domains. - const snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId: request.streamId, - terminal: request.terminal, - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq, - ...((stream.ackOutputSourceRanges || - stream.supportsOutputPause || - stream.supportsQueryReply) && { - capabilities: { - ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}), - ...(stream.supportsQueryReply ? { queryReply: 1 as const } : {}) - } - }), - ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), - // Why: retained-tail truncation loses history, not the authoritative latest-screen fallback. - truncated: initialOutputOverflowed - }) - stream.sourceRangeReplacement = - stream.ackOutputSourceRanges && - serialized?.source !== undefined && - typeof serialized.seq === 'number' - ? runtime.reserveRemoteTerminalSourceRangeReplacement( - { - ptyId, - consumerId: stream.remoteDesktopSubscriptionKey, - streamGeneration: stream.streamGeneration - }, - serialized.seq, - 'initial-snapshot' - ) - : null - const snapshotPublication = sendSnapshotFrames( - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - { - kind: 'scrollback', - cols: serialized?.cols ?? size?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - source: serialized?.source, - kittyKeyboardFlags: serialized?.kittyKeyboardFlags, - oscLinks: serialized?.oscLinks, - pendingEscapeTailAnsi: serialized?.pendingEscapeTailAnsi, - data: - serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') - } - ) - const replacement = stream.sourceRangeReplacement - stream.sourceRangeReplacement = null - if (replacement) { - const committed = - snapshotPublication.published && - serialized?.source !== undefined && - typeof serialized.seq === 'number' && - runtime.commitRemoteTerminalSourceRangeReplacement(replacement, { - source: serialized.source, - seq: serialized.seq - }) - if (!committed) { - runtime.rollbackRemoteTerminalSourceRangeReplacement( - replacement, - 'initial-snapshot-unpublished' - ) - } - } - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - stream.lastResizeCols = serialized?.cols ?? size?.cols - stream.buffering = false - const pendingOutput = stream.pendingOutput.splice(0) - if (!initialOutputOverflowed) { - for (const chunk of pendingOutput) { - const uncovered = getOutputAfterSnapshotSeq(chunk, snapshotOutputSeq) - if (uncovered) { - stream.outputBatcher.push(uncovered.data, uncovered.meta) - } - } - } - stream.pendingOutputBytes = 0 - stream.pendingOutputOverflowed = false - stream.outputBatcher.flush() - if (!isMobile) { - stream.unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, stream.remoteDesktopSubscriptionKey) - .mode ?? 'desktop-fit') - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode, - cols: event.cols, - rows: event.rows - }) - }) - stream.unsubscribeDriver = runtime.subscribeToDriverChanges(ptyId, (driver) => { - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver - }) - }) - const fitOverride = runtime.getTerminalFitOverride(ptyId) - const desktopHold = runtime.getRemoteDesktopFitHold?.( - ptyId, - stream.remoteDesktopSubscriptionKey - ) ?? { mode: 'desktop-fit' as const, cols: size?.cols ?? 0, rows: size?.rows ?? 0 } - emit({ - type: 'fit-override-changed', - streamId: request.streamId, - mode: fitOverride?.mode ?? desktopHold.mode, - cols: fitOverride?.cols ?? desktopHold.cols, - rows: fitOverride?.rows ?? desktopHold.rows - }) - emit({ - type: 'driver-changed', - streamId: request.streamId, - driver: runtime.getDriver(ptyId) - }) - } - stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - stream.outputBatcher.flush() - const resizeGeneration = stream.resizeGeneration + 1 - stream.resizeGeneration = resizeGeneration - const widthChanged = stream.isMobile && event.cols !== stream.lastResizeCols - if (widthChanged) { - stream.lastResizeCols = event.cols - // Why: re-serialize+replay the full scrollback at the new cols so restored hard-wrapped lines rewrap; live output resumes after the snapshot lands. - void sendMobileResizeRestream( - runtime, - ptyId, - (opcode, payload) => sendFrame(request.streamId, opcode, payload), - event, - () => - !closed && - streams.get(request.streamId) === stream && - stream.resizeGeneration === resizeGeneration - ) - .then((restreamed) => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - if (!restreamed) { - sendResizedFrame(stream, event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if ( - closed || - streams.get(request.streamId) !== stream || - stream.resizeGeneration !== resizeGeneration - ) { - return - } - sendResizedFrame(stream, event) - }) - return - } - sendResizedFrame(stream, event) - }) - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - !stream.isMobile && - stream.client?.id && - stream.registeredRemoteDesktopDriver && - stream.pendingRemoteDesktopViewport - ) { - const viewport = stream.pendingRemoteDesktopViewport - stream.pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - stream.remoteDesktopSubscriptionKey, - stream.client, - viewport, - 'desktop', - 'register', - !stream.supportsDesktopViewportClaims - ).catch(() => {}) - } - void runtime - .waitForTerminal(request.terminal, { - condition: 'exit', - signal: stream.exitWaiterAbort.signal - }) - .then(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - .catch(() => { - if (streams.get(request.streamId) === stream) { - detachStream(request.streamId, true) - } - }) - } catch (error) { - // Why the ownership check: a newer subscribe may already own this streamId; tearing down the slot here would kill the successor's live registrations. - if (streams.get(request.streamId) !== stream) { - return - } - detachStream(request.streamId, false) - sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) - emit({ type: 'end', streamId: request.streamId }) - } - } - const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode === TerminalStreamOpcode.Subscribe) { - void handleSubscribeFrame(frame.payload) - } - }) - - signal?.addEventListener('abort', cancelAllPendingPtyWaits, { once: true }) - - runtime.registerSubscriptionCleanup( - `terminal-multiplex:${connectionId}`, - closeMultiplex, - connectionId - ) - emit({ type: 'ready' }) - await multiplexClosed - } - }), - // terminal.subscribe: streams live terminal output over WebSocket; mobile clients pass client+viewport for server-side auto-fit. - defineStreamingMethod({ - name: 'terminal.subscribe', - params: TerminalSubscribe, - handler: async ( - params, - { runtime, connectionId, sendBinary, registerBinaryStreamHandler, signal }, - emit - ) => { - let leaf = runtime.resolveLeafForHandle(params.terminal) - const isMobile = params.client?.type === 'mobile' - const serializerGenerationBeforeAnyMount = isMobile - ? (runtime.getRendererTerminalSerializerGenerationForHandle?.(params.terminal) ?? 0) - : 0 - let rendererMountRequestedBeforePty = false - const useBinaryStream = params.capabilities?.terminalBinaryStream === 1 && Boolean(sendBinary) - // Why: a closed stream must not allocate listeners, mobile-fit state, or a hidden renderer surface no client will consume. - if (signal?.aborted) { - return - } - - // Why: the PTY spawns asynchronously after tab creation; wait for it so an early subscribe gets a live stream instead of a bare scrollback+end. - if (!leaf?.ptyId && params.client) { - // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) - try { - const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) - leaf = { ptyId } - } catch { - if (signal?.aborted) { - return - } - // PTY wait timed out — fall through to scrollback-only path below - } - } - - if (!leaf?.ptyId) { - const read = await runtime.readTerminal(params.terminal) - emit({ - type: 'subscribed', - streamId: null, - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read) - }) - emit({ type: 'end' }) - return - } - - if (isMobile && (!useBinaryStream || !sendBinary)) { - throw new Error('binary_terminal_stream_required') - } - - const ptyId = leaf.ptyId - const clientId = params.client?.id - const mobileInputLeaseOnly = - isMobile && params.capabilities?.mobileInputLeaseOnly === 1 && Boolean(clientId) - // Why: mount/PTY wait and phone-fit can each emit a redraw creating suffix-only state, so capture the pre-mount absence signal first. - const missingHeadlessStateBeforeMobileFit = - isMobile && - (rendererMountRequestedBeforePty || runtime.hasHeadlessTerminalState?.(ptyId) === false) - const serializerGenerationBeforeMobileFit = missingHeadlessStateBeforeMobileFit - ? rendererMountRequestedBeforePty - ? serializerGenerationBeforeAnyMount - : runtime.getRendererTerminalSerializerGeneration(ptyId) - : 0 - const supportsDesktopViewportClaims = params.capabilities?.desktopViewportClaims === 1 - const supportsQueryReply = params.capabilities?.queryReply === 1 - const supportsWriteUnavailable = params.capabilities?.writeUnavailable === 1 - if (mobileInputLeaseOnly && clientId) { - let closed = false - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - const subscriptionId = `${params.terminal}:${clientId}` - // Why: chat needs the input-floor ack without registering a view subscriber or transporting duplicate PTY output. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - runtime.handleMobileUnsubscribe(ptyId, clientId) - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - // Why: a lease-only subscriber has no terminal view, so its cached viewport must never phone-fit the PTY. - await runtime.handleMobileSubscribe(ptyId, clientId, undefined) - if (closed || signal?.aborted) { - // Why: a disconnect can win the awaited subscribe and resurrect mobile presence after cleanup already released it. - // Unguarded on purpose: this must still fire when our own cleanup already ran. - // Safe only because lease-only passes no viewport and both !viewport paths in - // handleMobileSubscribeInternal return with no await, so no rebind can land - // first. Adding an await there — or passing a viewport here — makes a - // superseded handler delete the replacement's (ptyId, clientId) presence. - runtime.handleMobileUnsubscribe(ptyId, clientId) - if (!closed) { - registration.releaseIfCurrent() - } - return - } - emit({ type: 'subscribed', streamId: null, lines: [], truncated: false }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - // Why: only unregister the width floor this subscription took (see the multiplex stream's registeredRemoteDesktopDriver note). - let registeredRemoteDesktopDriver = false - if (!useBinaryStream) { - // Why: a hidden watcher and a visible pane can subscribe to one terminal, so key by client so neither stream evicts the other. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const remoteDesktopSubscriptionKey = `json:${nextTerminalStreamId++}` - let closed = false - let outputBatcher: ReturnType | null = null - let unsubscribeData = (): void => {} - let unsubscribeFit = (): void => {} - let stopWatchingLifetime = (): void => {} - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register before viewport/snapshot awaits so a socket close can't orphan the stream listeners or its remote-desktop width floor. - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - closed = true - outputBatcher?.flush() - outputBatcher?.dispose() - unsubscribeData() - unsubscribeFit() - if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - try { - if (clientId && params.client && params.viewport) { - registeredRemoteDesktopDriver = true - await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - params.viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - } - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const read = await runtime.readTerminal(params.terminal) - const serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, false) - if (closed || signal?.aborted) { - registration.releaseIfCurrent() - return - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq - emit({ - type: 'scrollback', - lines: read.tail, - truncated: isTerminalReadPayloadIncomplete(read), - serialized: serialized?.data, - oscLinks: serialized?.oscLinks, - cwd: serialized?.cwd, - // Why: an empty snapshot with no PTY size must still report the dims the fit - // will produce — dimless frames re-armed the mobile fit loop (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows, - displayMode, - seq - }) - outputBatcher = createTerminalOutputBatcher((chunk) => { - emit({ type: 'data', chunk }) - }) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { - outputBatcher?.push(data) - }) - // Why: the legacy JSON stream can feed a live xterm view, so register as a view subscriber; worst case is a withheld model reply, safer than a double reply. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - outputBatcher?.flush() - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - await streamClosed - } catch (error) { - registration.releaseIfCurrent() - throw error - } - return - } - - const streamId = nextTerminalStreamId++ - const remoteDesktopSubscriptionKey = `stream:${streamId}` - let cursor = 0 - let closed = false - let buffering = true - let pendingRemoteDesktopViewport: { cols: number; rows: number } | null = null - // Why: cols the mobile client last rewrapped to; gates the resize re-stream to fire only on an actual width change. - let lastResizeCols: number | undefined - let resizeGeneration = 0 - let pendingOutput: TerminalOutputChunk[] = [] - let desktopClaimTail: Promise = Promise.resolve(true) - let pendingOutputBytes = 0 - let pendingOutputOverflowed = false - let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - const pendingQuerySequences: TerminalReplyQuerySequence[] = [] - let pendingQueryChars = 0 - let pendingQueryOverflowed = false - let unsubscribeData = (): void => {} - let unsubscribeResize = (): void => {} - let unsubscribeFit = (): void => {} - let unregisterBinaryHandler = (): void => {} - let abortRendererMountWait = (): void => {} - let stopWatchingLifetime = (): void => {} - let lateRendererReadyPromise: Promise | null = null - let outputBatcher: ReturnType | null = null - let resolveStream = (): void => {} - const streamClosed = new Promise((resolve) => { - resolveStream = resolve - }) - // Why: register cleanup before any await so a mid-subscribe disconnect still removes mobile presence; client-scoped ids also allow parallel desktop subscribers. - const subscriptionId = clientId ? `${params.terminal}:${clientId}` : params.terminal - const registration = runtime.registerOwnedSubscriptionCleanup( - subscriptionId, - () => { - stopWatchingLifetime() - outputBatcher?.flush() - outputBatcher?.dispose() - closed = true - unsubscribeData() - unsubscribeResize() - unsubscribeFit() - unregisterBinaryHandler() - abortRendererMountWait() - if (isMobile && clientId) { - runtime.handleMobileUnsubscribe(ptyId, clientId) - } else if (registeredRemoteDesktopDriver && clientId) { - runtime.unregisterRemoteDesktopViewer(ptyId, remoteDesktopSubscriptionKey) - } - emit({ type: 'end' }) - resolveStream() - }, - connectionId - ) - stopWatchingLifetime = watchSubscriptionLifetime(runtime, ptyId, signal, registration) - if (closed) { - // Why: an already-exited pty releases synchronously, so cleanup ran before this setup registers anything. - return - } - const sendFrame = ( - opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array(), - frameSeq = cursor++ - ): void => { - if (closed || !sendBinary) { - return - } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: frameSeq, payload })) - } - outputBatcher = createTerminalOutputBatcher((data, meta) => { - if (meta?.cwd !== undefined) { - sendFrame( - TerminalStreamOpcode.Metadata, - encodeTerminalStreamJson({ cwd: meta.cwd }), - meta.seq - ) - } - for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(chunk.opcode ?? TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) - } - }) - unregisterBinaryHandler = - registerBinaryStreamHandler?.(streamId, (frame) => { - if (closed) { - return - } - if ( - frame.opcode === TerminalStreamOpcode.Input || - (frame.opcode === TerminalStreamOpcode.QueryReply && supportsQueryReply) - ) { - const text = decodeTerminalStreamText(frame.payload) - if (!text) { - return - } - if (isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - void desktopClaimTail.then(async (claimed) => { - if (!claimed || isTerminalInputLockedForClient(runtime, ptyId, params.client)) { - return - } - const outcome = await sendTerminalStreamInput(runtime, { - terminal: params.terminal, - ptyId, - text, - client: params.client, - isMobile, - inputKind: - frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input' - }) - if (!closed && outcome === 'rejected' && supportsWriteUnavailable) { - sendFrame(TerminalStreamOpcode.WriteUnavailable) - } - }) - return - } - if (frame.opcode === TerminalStreamOpcode.Resize && params.client) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - if (clientId) { - registeredRemoteDesktopDriver = true - if (buffering) { - pendingRemoteDesktopViewport = { cols: viewport.cols, rows: viewport.rows } - return - } - } - desktopClaimTail = desktopClaimTail - .then(async (priorClaimed) => { - const result = await updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client!, - { cols, rows }, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ) - return supportsDesktopViewportClaims - ? priorClaimed && result.applied - : result.applied - }) - .catch(() => false) - return - } - if ( - frame.opcode === TerminalStreamOpcode.ClaimViewport && - params.client && - clientId && - !isMobile - ) { - const viewport = decodeTerminalStreamJson<{ cols?: unknown; rows?: unknown }>( - frame.payload - ) - if ( - !viewport || - typeof viewport.cols !== 'number' || - typeof viewport.rows !== 'number' - ) { - return - } - const cols = viewport.cols - const rows = viewport.rows - registeredRemoteDesktopDriver = true - desktopClaimTail = desktopClaimTail - .then( - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ), - () => - runtime.updateRemoteDesktopViewer( - ptyId, - remoteDesktopSubscriptionKey, - clientId, - cols, - rows, - true - ) - ) - .catch(() => false) - } - }) ?? (() => {}) - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const rawLength = meta?.rawLength - if ( - typeof meta?.seq === 'number' && - typeof rawLength === 'number' && - rawLength === data.length - ) { - const scan = scanTerminalReplyQuerySequences( - data, - meta.seq - rawLength, - pendingQueryScanState - ) - pendingQueryScanState = scan.state - for (const query of scan.queries) { - if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { - pendingQueryOverflowed = true - break - } - pendingQuerySequences.push(query) - pendingQueryChars += query.data.length - } - } else { - pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE - } - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: capture live bytes before mobile-fit awaits; registering presence first would suppress main while no view held the query. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - // Server-side auto-fit: resize PTY to phone dims before serializing scrollback - try { - if (isMobile && clientId) { - await runtime.handleMobileSubscribe(ptyId, clientId, params.viewport) - } else if (clientId && params.viewport) { - // Why: legacy subscribe records geometry without taking ownership; only an explicit activity/claim frame may suppress the host. - registeredRemoteDesktopDriver = true - pendingRemoteDesktopViewport = params.viewport - } - if (closed) { - return - } - - let read = await runtime.readTerminal(params.terminal) - let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - // Why: missing model state (not blank snapshot text) signals a never-attached PTY; a renderer-sourced snapshot already proves attachment, so skip the remount. - const mountRequested = - missingHeadlessStateBeforeMobileFit && - serialized?.source !== 'renderer' && - (rendererMountRequestedBeforePty || - runtime.requestRendererTerminalTabMount(params.terminal)) - if (missingHeadlessStateBeforeMobileFit && mountRequested) { - // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. - const mountWaitController = new AbortController() - const abortMountWait = (): void => mountWaitController.abort() - abortRendererMountWait = abortMountWait - if (signal?.aborted) { - abortMountWait() - } else { - signal?.addEventListener('abort', abortMountWait, { once: true }) - } - const rendererReadyPromise = runtime - .waitForRendererTerminalSerializer( - ptyId, - serializerGenerationBeforeMobileFit, - undefined, - mountWaitController.signal - ) - .catch(() => false) - const finishMountWait = (): void => { - signal?.removeEventListener('abort', abortMountWait) - if (abortRendererMountWait === abortMountWait) { - abortRendererMountWait = () => {} - } - } - void rendererReadyPromise.then(finishMountWait, finishMountWait) - let deadlineTimer: ReturnType | null = null - const initialDeadline = new Promise((resolve) => { - deadlineTimer = setTimeout(() => resolve(false), MOBILE_RENDERER_MOUNT_READY_TIMEOUT_MS) - if (typeof deadlineTimer.unref === 'function') { - deadlineTimer.unref() - } - }) - const rendererReady = await Promise.race([rendererReadyPromise, initialDeadline]) - if (deadlineTimer) { - clearTimeout(deadlineTimer) - } - if (closed || signal?.aborted) { - return - } - if (rendererReady) { - read = await runtime.readTerminal(params.terminal) - const stableRendererSnapshot = await serializeStableMobileRendererSnapshot( - runtime, - ptyId - ) - if (closed) { - return - } - if (stableRendererSnapshot?.data.length) { - serialized = stableRendererSnapshot - const trailingOutput = pendingOutput.flatMap((item) => { - const output = getOutputAfterSnapshotSeq(item, stableRendererSnapshot.seq) - const seq = item.meta?.seq - return output && typeof seq === 'number' ? [{ data: output.data, seq }] : [] - }) - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery( - ptyId, - stableRendererSnapshot, - trailingOutput - ) - } - } else { - // Why: a renderer can settle after the bounded initial response; keep observing so an idle PTY self-heals without bytes. - lateRendererReadyPromise = rendererReadyPromise - } - } - let initialOutputOverflowed = false - if (pendingOutputOverflowed) { - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - read = await runtime.readTerminal(params.terminal) - serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (pendingOutputOverflowed) { - initialOutputOverflowed = true - pendingOutput.splice(0) - pendingOutputBytes = 0 - pendingOutputOverflowed = false - } - } - const size = runtime.getTerminalSize(ptyId) - const displayMode = runtime.getMobileDisplayMode(ptyId) - // Why: layout seq is the mobile stale-event filter's high-water mark (undefined pre-transition is fail-open). See docs/mobile-terminal-layout-state-machine.md. - const layoutSeq = runtime.getLayout(ptyId)?.seq - // Why: only an output offset can cover buffered chunks; layout versions are a separate sequence domain. - let snapshotOutputSeq = serialized?.seq - emit({ - type: 'subscribed', - streamId, - lines: read.tail, - truncated: - initialOutputOverflowed || (!sendBinary && isTerminalReadPayloadIncomplete(read)), - cols: serialized?.cols ?? size?.cols, - rows: serialized?.rows ?? size?.rows, - displayMode, - seq: layoutSeq, - ...(supportsQueryReply ? { capabilities: { queryReply: 1 as const } } : {}) - }) - const snapshotStats = sendSnapshotFrames(sendFrame, { - kind: 'scrollback', - // Why: prefer the subscriber's viewport over the 80x24 stopgap when the PTY has - // no size yet — the mismatch made mobile burn its resubscribe budget (STA-3337). - cols: serialized?.cols ?? size?.cols ?? params.viewport?.cols ?? 80, - rows: serialized?.rows ?? size?.rows ?? params.viewport?.rows ?? 24, - displayMode, - seq: snapshotOutputSeq, - cwd: serialized?.cwd, - truncated: initialOutputOverflowed, - truncatedByByteBudget: serialized?.truncatedByByteBudget, - oscLinks: serialized?.oscLinks, - data: serialized?.data ?? '' - }) - console.log('[mobile-terminal-stream] snapshot', { - terminal: params.terminal, - streamId, - kind: 'scrollback', - bytes: snapshotStats.bytes, - chunks: snapshotStats.chunks, - scrollbackRows: serialized?.scrollbackRows, - truncatedByByteBudget: serialized?.truncatedByByteBudget === true - }) - // Why: baseline for resize re-stream gating; the client already rewrapped to these cols via the initial snapshot replay. - lastResizeCols = serialized?.cols ?? size?.cols - let recoveryAttempts = 0 - // Why: if the bounded pre-subscribe tail overflowed, only a fresh model snapshot covers the dropped middle without replay gaps. - while (pendingOutputOverflowed && recoveryAttempts < 2) { - pendingOutputOverflowed = false - recoveryAttempts += 1 - const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) - if (closed) { - return - } - if (!recovery) { - break - } - // Why: without an output seq (renderer fallback) covered chunks can't be trimmed exactly, so keep the bounded replay over an unverifiable snapshot. - if (typeof recovery.seq !== 'number') { - break - } - // Why: clients drop a repeat scrollback snapshot but apply 'resized' inline; omit seq so output-byte seqs don't pollute the layout-seq filter. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'pending-output-overflow', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'pending-output-overflow', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) - pendingOutput = trimmed.chunks - pendingOutputBytes = trimmed.bytes - snapshotOutputSeq = recovery.seq - } - buffering = false - const bufferedOutput = pendingOutput.splice(0) - const queryReplayData = pendingQueryOverflowed - ? '' - : pendingQuerySequences - .filter( - (query) => - initialOutputOverflowed || - (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) - ) - .map((query) => query.data) - .join('') - if (queryReplayData) { - // Why: snapshots omit control queries but their seq trims the live chunk; replay the post-snapshot query so the mobile xterm answers once. - outputBatcher.push(queryReplayData) - } - if (!initialOutputOverflowed) { - for (const item of bufferedOutput) { - const uncovered = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) - let uncoveredData = uncovered?.data ?? null - let uncoveredMeta = uncovered?.meta - if ( - uncoveredData && - uncoveredData !== item.data && - typeof snapshotOutputSeq === 'number' && - typeof item.meta?.seq === 'number' && - typeof item.meta.rawLength === 'number' - ) { - if (item.meta.rawLength === item.data.length) { - uncoveredMeta = { ...item.meta, rawLength: uncoveredData.length } - } - uncoveredData = stripSnapshotBoundaryQuerySuffixes( - uncoveredData, - snapshotOutputSeq, - snapshotOutputSeq, - pendingQuerySequences - ) - } - if (uncoveredData) { - outputBatcher.push(uncoveredData, uncoveredMeta) - } - } - } - pendingOutputBytes = 0 - outputBatcher.flush() - const lateRendererReady = lateRendererReadyPromise - lateRendererReadyPromise = null - if (lateRendererReady) { - void lateRendererReady - .then(async (rendererReady) => { - if (!rendererReady || closed) { - return - } - outputBatcher?.flush() - const recovery = await serializeStableMobileRendererSnapshot(runtime, ptyId) - if (closed) { - return - } - if (!recovery?.data.length) { - return - } - // Why: late recovery has no buffered-output gate, so only an exact renderer high-water may reset mobile without erasing live bytes. - if (recovery.seq !== runtime.getPtyOutputSequence(ptyId)) { - return - } - runtime.replaceHeadlessTerminalFromRendererSnapshotForRecovery(ptyId, recovery) - // Why: shipped mobile clients apply resized snapshots in place, so a blank xterm recovers without resubscribe. - const recoveryStats = sendSnapshotFrames(sendFrame, { - kind: 'resized', - cols: recovery.cols, - rows: recovery.rows, - displayMode, - reason: 'renderer-mount-ready', - source: recovery.source, - truncated: false, - truncatedByByteBudget: recovery.truncatedByByteBudget, - data: recovery.data - }) - lastResizeCols = recovery.cols - console.log('[mobile-terminal-stream] recovery snapshot', { - terminal: params.terminal, - streamId, - reason: 'renderer-mount-ready', - bytes: recoveryStats.bytes, - chunks: recoveryStats.chunks, - scrollbackRows: recovery.scrollbackRows, - truncatedByByteBudget: recovery.truncatedByByteBudget === true - }) - }) - .catch(() => {}) - } - const sendResizedFrame = (event: { - cols: number - rows: number - displayMode: string - reason: string - seq?: number - }): void => { - lastResizeCols = event.cols - sendFrame( - TerminalStreamOpcode.Resized, - encodeTerminalStreamJson({ - cols: event.cols, - rows: event.rows, - displayMode: event.displayMode, - reason: event.reason, - seq: event.seq - }) - ) - } - unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { - outputBatcher?.flush() - const eventGeneration = resizeGeneration + 1 - resizeGeneration = eventGeneration - // Why: xterm only re-wraps soft-wrapped lines, so a width change needs a full re-serialize+replay to rewrap restored hard-wrapped scrollback. - const widthChanged = isMobile && event.cols !== lastResizeCols - if (widthChanged) { - lastResizeCols = event.cols - void sendMobileResizeRestream( - runtime, - ptyId, - sendFrame, - event, - () => !closed && resizeGeneration === eventGeneration - ) - .then((restreamed) => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - if (!restreamed) { - sendResizedFrame(event) - } - }) - // Why: on re-stream failure, still emit the geometry-only Resized frame so the client never misses the resize. - .catch(() => { - if (closed || resizeGeneration !== eventGeneration) { - return - } - sendResizedFrame(event) - }) - return - } - sendResizedFrame(event) - }) - - // Install the resize listener before draining the parked viewport, since applyLayout emits synchronously. - if ( - clientId && - params.client && - registeredRemoteDesktopDriver && - pendingRemoteDesktopViewport - ) { - const viewport = pendingRemoteDesktopViewport - pendingRemoteDesktopViewport = null - void updateViewportForClient( - runtime, - ptyId, - remoteDesktopSubscriptionKey, - params.client, - viewport, - 'desktop', - 'register', - !supportsDesktopViewportClaims - ).catch(() => {}) - } - - // Legacy fit-override-changed for non-mobile (desktop) subscribers - unsubscribeFit = !isMobile - ? runtime.subscribeToFitOverrideChanges(ptyId, (event) => { - const mode = - event.mode === 'mobile-fit' - ? event.mode - : (runtime.getRemoteDesktopFitHold?.(ptyId, remoteDesktopSubscriptionKey).mode ?? - 'desktop-fit') - emit({ - type: 'fit-override-changed', - mode, - cols: event.cols, - rows: event.rows - }) - }) - : () => {} - } catch (error) { - registration.releaseIfCurrent() - throw error - } - - await streamClosed - } - }), - defineMethod({ - name: 'terminal.unsubscribe', - params: TerminalUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - // Why: only the connection that owns the subscription may retire it — a stale - // unsubscribe from the pre-reconnect socket names an id the replacement now owns. - let unsubscribed = runtime.cleanupSubscriptionIfOwnedByConnection( - params.subscriptionId, - connectionId - ) - // Why: older builds send a bare-handle subscriptionId, so also try the reconstructed `${terminal}:${clientId}` composite key. - // Why AND over the calls that ran: a clientless stream registers under the bare id - // and a client-scoped one under the composite, so either call can be the real - // teardown. Reporting false needs one genuine refusal, not merely a missing id. - if (params.client && !params.subscriptionId.includes(':')) { - unsubscribed = - runtime.cleanupSubscriptionIfOwnedByConnection( - `${params.subscriptionId}:${params.client.id}`, - connectionId - ) && unsubscribed - } - return { unsubscribed } - } - }), - defineMethod({ - name: 'terminal.getAutoRestoreFit', - params: z.object({}), - handler: async (_params, { runtime }) => ({ - ms: runtime.getMobileAutoRestoreFitMs() - }) - }), - defineMethod({ - name: 'terminal.setAutoRestoreFit', - params: TerminalSetAutoRestoreFit, - handler: async (params, { runtime }) => ({ - ms: runtime.setMobileAutoRestoreFitMs(params.ms) - }) - }) + ...TERMINAL_QUERY_METHODS, + ...TERMINAL_SEND_METHODS, + ...TERMINAL_LIFECYCLE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_BEFORE_STREAMS, + ...TERMINAL_MULTIPLEX_METHODS, + ...TERMINAL_SUBSCRIBE_METHODS, + ...TERMINAL_VIEWPORT_METHODS_AFTER_STREAMS ] diff --git a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts index 04a6990fe51..e8eb1425bd7 100644 --- a/src/main/runtime/rpc/methods/terminal/stream-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/stream-schemas.ts @@ -32,6 +32,7 @@ export const TerminalSubscribe = TerminalHandle.extend({ terminalBinaryStream: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), mobileInputLeaseOnly: z.literal(1).optional(), + queryReply: z.literal(1).optional(), writeUnavailable: z.literal(1).optional() }) .optional() @@ -54,6 +55,7 @@ export const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ ackOutputSourceRanges: z.literal(1).optional(), desktopViewportClaims: z.literal(1).optional(), outputPause: z.literal(1).optional(), + queryReply: z.literal(1).optional(), writeUnavailable: z.literal(1).optional() }) .optional() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index 8c234ce2ba4..ab81b6aa40e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -108,10 +108,12 @@ export async function sendTerminalStreamInput( text: string client: TerminalViewportClient | undefined isMobile: boolean + inputKind?: 'input' | 'query-reply' } ): Promise { const action = { text: args.text, enter: false, interrupt: false } - const clientId = args.isMobile ? args.client?.id : undefined + const clientId = + args.inputKind === 'query-reply' ? undefined : args.isMobile ? args.client?.id : undefined const floorClaim: MobileInputFloorClaimHolder = { current: null } try { if (!clientId) { diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts index 1033464071c..808ccf63241 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-binary-control-frames.ts @@ -31,6 +31,7 @@ export function registerLegacyBinaryControlFrames( clientId, isMobile, supportsDesktopViewportClaims, + supportsQueryReply, supportsWriteUnavailable } = args if (!registerBinaryStreamHandler) { @@ -40,7 +41,10 @@ export function registerLegacyBinaryControlFrames( if (controls.isClosed()) { return } - if (frame.opcode === TerminalStreamOpcode.Input) { + if ( + frame.opcode === TerminalStreamOpcode.Input || + (frame.opcode === TerminalStreamOpcode.QueryReply && supportsQueryReply) + ) { const text = decodeTerminalStreamText(frame.payload) if (!text) { return @@ -56,7 +60,8 @@ export function registerLegacyBinaryControlFrames( terminal: params.terminal, text, client: params.client, - isMobile + isMobile, + inputKind: frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input' }) if (!controls.isClosed() && outcome === 'rejected' && supportsWriteUnavailable) { controls.sendFrame(TerminalStreamOpcode.WriteUnavailable) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 72c8939cb6c..1a75bfb6bfe 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -29,6 +29,7 @@ export async function publishLegacyBinaryInitialSnapshot( ptyId, clientId, isMobile, + supportsQueryReply, missingHeadlessStateBeforeMobileFit, rendererMountRequestedBeforePty, serializerGenerationBeforeMobileFit @@ -148,7 +149,8 @@ export async function publishLegacyBinaryInitialSnapshot( cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode: state.displayMode, - seq: layoutSeq + seq: layoutSeq, + ...(supportsQueryReply ? { capabilities: { queryReply: 1 as const } } : {}) }) const snapshotStats = sendSnapshotFrames(state.sendFrame, { kind: 'scrollback', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts index d9eb624f0e1..652d7c2e506 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscription-types.ts @@ -22,6 +22,7 @@ export type TerminalSubscriptionArgs = { clientId: string | undefined isMobile: boolean supportsDesktopViewportClaims: boolean + supportsQueryReply: boolean supportsWriteUnavailable: boolean missingHeadlessStateBeforeMobileFit: boolean rendererMountRequestedBeforePty: boolean diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts index 6f1c2f8fc47..e5970f8e3e1 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-initial-snapshot.ts @@ -58,10 +58,13 @@ export async function publishMultiplexInitialSnapshot( rows: serialized?.rows ?? size?.rows, displayMode, seq: layoutSeq, - ...((stream.ackOutputSourceRanges || stream.supportsOutputPause) && { + ...((stream.ackOutputSourceRanges || + stream.supportsOutputPause || + stream.supportsQueryReply) && { capabilities: { ...(stream.ackOutputSourceRanges ? { ackOutputSourceRanges: 1 as const } : {}), - ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}) + ...(stream.supportsOutputPause ? { outputPause: 1 as const } : {}), + ...(stream.supportsQueryReply ? { queryReply: 1 as const } : {}) } }), ...(stream.ackOutputSourceRanges ? { streamGeneration: stream.streamGeneration } : {}), diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts index 1020b2542d4..8d91a43ee81 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-slot-frames.ts @@ -60,7 +60,10 @@ export function installMultiplexSlotFrames( } return } - if (frame.opcode === TerminalStreamOpcode.Input) { + if ( + frame.opcode === TerminalStreamOpcode.Input || + (frame.opcode === TerminalStreamOpcode.QueryReply && stream.supportsQueryReply) + ) { const text = decodeTerminalStreamText(frame.payload) if (!text) { return @@ -78,7 +81,8 @@ export function installMultiplexSlotFrames( terminal: stream.terminal, text, client: stream.client, - isMobile: stream.isMobile + isMobile: stream.isMobile, + inputKind: frame.opcode === TerminalStreamOpcode.QueryReply ? 'query-reply' : 'input' }) state.notifyStreamWriteUnavailable(stream, outcome) }) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts index cc3ff90b2d4..175711869e1 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-stream-initialization.ts @@ -54,6 +54,7 @@ export async function initializeMultiplexStream( ackInFlightBytes: 0, ackWindowBytes: TERMINAL_MULTIPLEX_ACK_STREAM_INITIAL_WINDOW_BYTES, supportsOutputPause: request.capabilities?.outputPause === 1, + supportsQueryReply: request.capabilities?.queryReply === 1, supportsWriteUnavailable: request.capabilities?.writeUnavailable === 1, outputPaused: false, supportsDesktopViewportClaims: request.capabilities?.desktopViewportClaims === 1, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts index 121cf9a7adb..7d07d31be1e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-stream-types.ts @@ -68,6 +68,7 @@ export type TerminalMultiplexStream = { ackInFlightBytes: number ackWindowBytes: number supportsOutputPause: boolean + supportsQueryReply: boolean supportsWriteUnavailable: boolean outputPaused: boolean supportsDesktopViewportClaims: boolean diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index bd16382d747..d3e6dba971e 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -72,6 +72,7 @@ export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ clientId, isMobile, supportsDesktopViewportClaims: params.capabilities?.desktopViewportClaims === 1, + supportsQueryReply: params.capabilities?.queryReply === 1, supportsWriteUnavailable: params.capabilities?.writeUnavailable === 1, rendererMountRequestedBeforePty, missingHeadlessStateBeforeMobileFit, diff --git a/src/mobile-web/src/native-shell-channel.ts b/src/mobile-web/src/native-shell-channel.ts index e9f34b473dd..c709a2c0788 100644 --- a/src/mobile-web/src/native-shell-channel.ts +++ b/src/mobile-web/src/native-shell-channel.ts @@ -219,8 +219,14 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { }) } const unsubscribe = subscribeToMobileWebShellMessages(window, receive) + const onRouteFailure = (): void => { + cancelAnimationFrame(healthFrame) + cancelAnimationFrame(interactiveFrame) + } + window.addEventListener('orca-mobile-web-route-failure', onRouteFailure) return () => { unsubscribe() + window.removeEventListener('orca-mobile-web-route-failure', onRouteFailure) client?.dispose() cancelAnimationFrame(healthFrame) cancelAnimationFrame(interactiveFrame)