Scaffold iOS companion control architecture (#1252)

This commit is contained in:
Ramzi 2026-05-14 20:47:26 +01:00 committed by GitHub
parent 11f24f17ad
commit cf6867aa6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 386 additions and 0 deletions

View File

@ -12,6 +12,8 @@ Keep this folder focused on the durable references for Orca's public CLI and run
- Why the runtime layer exists and which boundaries it owns.
- `orca-cli-bundled-distribution.md`
- How the bundled desktop-app distribution and PATH registration model works.
- `orca-ios-companion-architecture.md`
- The first-pass control-plane architecture for continuing laptop sessions from an iPhone.
## Why The Folder Is Small

View File

@ -0,0 +1,113 @@
# Orca iOS Companion Architecture
## Goal
Let a user continue an existing Orca session from an iPhone while the laptop stays open and remains the authoritative runtime. The phone is a remote surface for the same PTY-backed sessions, not a second local execution environment.
## Product Cut For The First Architecture PR
This PR intentionally scaffolds the backend shape before any iOS UI exists:
- define the shared protocol and payloads the phone app will consume
- define how Orca represents remote-manageable sessions
- define the pairing model for a single owner-controlled device
- keep the laptop as the only place where commands actually run
Out of scope for this PR:
- SwiftUI screens
- push notifications
- cloud relay infrastructure
- remote wake or background execution when the laptop is closed
- multi-user collaboration
## Core Model
One remote session maps to one live Orca PTY leaf, not one repo or one worktree.
Why:
- users want to continue the exact conversation they already have open
- a worktree can contain multiple parallel agent panes with different contexts
- leaf-level identity keeps "send input", "interrupt", and "approve" targeted and predictable
The existing `OrcaRuntimeService` already owns the truthful view of live terminals. The companion architecture therefore layers on top of runtime terminal summaries instead of inventing a second session registry.
## Ownership Boundary
The laptop remains the control plane:
- session discovery comes from `OrcaRuntimeService`
- terminal writes still go through the main-process PTY controller
- approval decisions are forwarded back into Orca's existing command flow
- the phone never runs shell commands locally on behalf of Orca
This keeps the first version aligned with the user's mental model: "my laptop is doing the work; my phone is steering."
## Transport Shape
Phase 1 uses `local_only` transport:
- Orca generates a short-lived pairing token
- the phone scans a QR payload
- the iPhone connects over the local network to a main-process remote-control endpoint
- Orca validates the token and starts streaming session snapshots and output updates
The shared protocol already includes a `relay` transport mode so a future PR can add Cloudflare Tunnel or a hosted relay without replacing the message schema.
## Security Model
The first version assumes a single owner device and optimizes for explicit opt-in:
- remote control is disabled by default
- enabling it creates a short-lived pairing token
- tokens expire automatically
- disabling remote control invalidates the active pairing state
- the service exposes capability levels per session so read-only fallback remains possible if a terminal is not writable
This architecture deliberately avoids persistent broad-scope bearer tokens in the first PR.
## Protocol Surface
The shared protocol lives in `src/shared/remote-control-types.ts`.
Main concepts:
- `RemoteControlSnapshot`: full state sent to the phone
- `RemoteControlSession`: one PTY leaf exposed as one phone-manageable session
- `RemoteControlPairingState`: short-lived QR/bootstrap payload
- `RemoteControlClientCommand`: initial command set for listing, focusing, input, and approvals
- `RemoteControlServerEvent`: initial event set for snapshots, output streaming, and exits
This is intentionally small. The point is to lock down the control-plane contract early so later UI work does not invent ad hoc payloads.
## Service Boundary
The main-process service lives in `src/main/remote-control/service.ts`.
Responsibilities:
- manage enabled/disabled remote-control state
- mint and rotate pairing payloads
- translate runtime terminals into remote sessions
- expose a snapshot method that future IPC, WebSocket, or HTTP layers can call
Non-responsibilities:
- hosting a WebSocket server in this PR
- pushing renderer UI
- storing long-term device registrations
That split keeps this PR reviewable. The next PR can add the network transport around a service boundary that already exists.
## Suggested Follow-Up PR Order
1. Add a main-process WebSocket endpoint and wire it to `RemoteControlService`.
2. Add a settings toggle plus QR pairing UI in desktop Orca.
3. Scaffold the SwiftUI iPhone app against the shared protocol.
4. Add approval cards, output streaming, and session switching.
5. Add optional relay mode for away-from-home access.
## Why This Cut
The biggest failure mode would be building a polished phone UI before the session identity, transport, and security boundaries are stable. This PR fixes that by making the architecture concrete in the codebase first, while still staying small enough to review as an initial design-and-scaffold change.

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeTerminalSummary } from '../../shared/runtime-types'
import { buildRemoteControlSessions } from './service'
describe('buildRemoteControlSessions', () => {
it('sorts sessions by recent output and exposes control capabilities for writable terminals', () => {
const terminals: RuntimeTerminalSummary[] = [
{
handle: 'term-1',
worktreeId: 'wt-a',
worktreePath: '/repo/a',
branch: 'feature/a',
tabId: 'tab-a',
leafId: 'leaf-a',
title: 'Claude',
connected: true,
writable: true,
lastOutputAt: 200,
preview: 'latest output'
},
{
handle: 'term-2',
worktreeId: 'wt-b',
worktreePath: '/repo/b',
branch: 'feature/b',
tabId: 'tab-b',
leafId: 'leaf-b',
title: 'Codex',
connected: true,
writable: false,
lastOutputAt: 100,
preview: 'older output'
}
]
const sessions = buildRemoteControlSessions(terminals)
expect(sessions.map((session) => session.sessionId)).toEqual(['tab-a:leaf-a', 'tab-b:leaf-b'])
expect(sessions[0].capabilities).toEqual([
'view_output',
'switch_session',
'send_input',
'send_interrupt',
'approve_action',
'run_command'
])
expect(sessions[1].capabilities).toEqual(['view_output', 'switch_session'])
})
})

View File

@ -0,0 +1,140 @@
import { randomBytes, randomUUID } from 'crypto'
import type { RuntimeTerminalSummary } from '../../shared/runtime-types'
import type {
RemoteControlCapability,
RemoteControlPairingState,
RemoteControlSession,
RemoteControlSnapshot,
RemoteControlTransportMode
} from '../../shared/remote-control-types'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
const DEFAULT_PAIRING_TTL_MS = 10 * 60 * 1000
export function buildRemoteControlSessions(
terminals: RuntimeTerminalSummary[]
): RemoteControlSession[] {
return terminals
.map((terminal) => ({
sessionId: `${terminal.tabId}:${terminal.leafId}`,
worktreeId: terminal.worktreeId,
worktreePath: terminal.worktreePath,
branch: terminal.branch,
tabId: terminal.tabId,
leafId: terminal.leafId,
title: terminal.title,
connected: terminal.connected,
writable: terminal.writable,
lastOutputAt: terminal.lastOutputAt,
preview: terminal.preview,
capabilities: buildCapabilities(terminal)
}))
.sort(compareRemoteControlSessions)
}
export class RemoteControlService {
private readonly runtime: OrcaRuntimeService
private enabled = false
private transportMode: RemoteControlTransportMode = 'local_only'
private pairing: RemoteControlPairingState | null = null
constructor(runtime: OrcaRuntimeService) {
this.runtime = runtime
}
async getSnapshot(): Promise<RemoteControlSnapshot> {
return {
enabled: this.enabled,
publishedAt: Date.now(),
transportMode: this.transportMode,
pairing: this.pairing,
sessions: this.enabled ? await this.listSessions() : []
}
}
async enable(options?: {
deviceLabel?: string
transportMode?: RemoteControlTransportMode
ttlMs?: number
}): Promise<RemoteControlSnapshot> {
this.enabled = true
this.transportMode = options?.transportMode ?? 'local_only'
this.pairing = createPairingState({
deviceLabel: options?.deviceLabel ?? 'Owner iPhone',
transportMode: this.transportMode,
ttlMs: options?.ttlMs ?? DEFAULT_PAIRING_TTL_MS
})
return this.getSnapshot()
}
async refreshPairing(options?: {
deviceLabel?: string
ttlMs?: number
}): Promise<RemoteControlSnapshot> {
if (!this.enabled) {
return this.getSnapshot()
}
this.pairing = createPairingState({
deviceLabel: options?.deviceLabel ?? this.pairing?.deviceLabel ?? 'Owner iPhone',
transportMode: this.transportMode,
ttlMs: options?.ttlMs ?? DEFAULT_PAIRING_TTL_MS
})
return this.getSnapshot()
}
async disable(): Promise<RemoteControlSnapshot> {
this.enabled = false
this.pairing = null
return this.getSnapshot()
}
private async listSessions(): Promise<RemoteControlSession[]> {
const { terminals } = await this.runtime.listTerminals()
// Why: the phone must resume the exact PTY leaf the laptop is already
// showing, not a coarser worktree bucket that would lose pane-level
// context and make "continue this session" ambiguous.
return buildRemoteControlSessions(terminals)
}
}
function buildCapabilities(terminal: RuntimeTerminalSummary): RemoteControlCapability[] {
const capabilities: RemoteControlCapability[] = ['view_output', 'switch_session']
if (terminal.writable) {
capabilities.push('send_input', 'send_interrupt', 'approve_action', 'run_command')
}
return capabilities
}
function compareRemoteControlSessions(a: RemoteControlSession, b: RemoteControlSession): number {
const activityDelta = (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
if (activityDelta !== 0) {
return activityDelta
}
return (a.title ?? a.branch).localeCompare(b.title ?? b.branch)
}
function createPairingState(options: {
deviceLabel: string
transportMode: RemoteControlTransportMode
ttlMs: number
}): RemoteControlPairingState {
const issuedAt = Date.now()
const expiresAt = issuedAt + Math.max(30_000, options.ttlMs)
const pairingId = randomUUID()
const accessToken = randomBytes(24).toString('base64url')
return {
pairingId,
issuedAt,
expiresAt,
deviceLabel: options.deviceLabel,
transportMode: options.transportMode,
accessToken,
qrPayload: JSON.stringify({
version: 1,
pairingId,
accessToken,
transportMode: options.transportMode,
expiresAt
})
}
}

View File

@ -0,0 +1,82 @@
export type RemoteControlTransportMode = 'local_only' | 'relay'
export type RemoteControlCapability =
| 'view_output'
| 'send_input'
| 'send_interrupt'
| 'switch_session'
| 'approve_action'
| 'run_command'
export type RemoteControlPairingState = {
pairingId: string
issuedAt: number
expiresAt: number
deviceLabel: string
transportMode: RemoteControlTransportMode
accessToken: string
qrPayload: string
}
export type RemoteControlSession = {
sessionId: string
worktreeId: string
worktreePath: string
branch: string
tabId: string
leafId: string
title: string | null
connected: boolean
writable: boolean
lastOutputAt: number | null
preview: string
capabilities: RemoteControlCapability[]
}
export type RemoteControlSnapshot = {
enabled: boolean
publishedAt: number
transportMode: RemoteControlTransportMode
pairing: RemoteControlPairingState | null
sessions: RemoteControlSession[]
}
export type RemoteControlClientCommand =
| {
type: 'session.list'
}
| {
type: 'session.focus'
sessionId: string
}
| {
type: 'session.input'
sessionId: string
text: string
enter?: boolean
interrupt?: boolean
}
| {
type: 'session.approve'
sessionId: string
approvalId: string
decision: 'approve' | 'deny'
}
export type RemoteControlServerEvent =
| {
type: 'snapshot'
snapshot: RemoteControlSnapshot
}
| {
type: 'session.output'
sessionId: string
output: string
at: number
}
| {
type: 'session.exited'
sessionId: string
exitCode: number | null
at: number
}