Scaffold iOS companion control architecture (#1252)
This commit is contained in:
parent
11f24f17ad
commit
cf6867aa6b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue