-
Notifications
You must be signed in to change notification settings - Fork 10
Runner and Remote Sessions
Status: accepted target-state design, 2026-07-14. Canonical design for issue #925. This page supersedes the future mobile, remote-control, runner, sandbox, and handover sections of Relay-Server and Platform-Rollout.
docs/relay-protocol.mdremains authoritative for the protocol that ships today.
Irrlicht starts as an observer: it finds AI coding sessions on a Mac and reports whether each one is working, waiting, or ready. The full remote-session solution keeps that observational core and adds an explicit execution boundary.
The result is one continuous session experience across three places:
- On the phone: pair once, inspect every session, answer a question, attach to the terminal when needed, and choose where new work runs.
-
On the Mac: run an agent through
irrlicht run, keep the native terminal experience, and gain reliable control because Irrlicht owns that process's PTY. - On a user-owned Linux server: provision the runner once over SSH, start work in an isolated sandbox, or beam a Mac session to the server and later pull it back.
A session lives in exactly one place at a time. Clients are windows onto the authoritative runner; they are not replicas of the process.
- Session state stays
working | waiting | ready. Placement, connectivity, and handover are separate dimensions, not new session states. - Observation remains useful without control. Pairing an iPhone read-only is a complete first increment.
- Reliable control is opt-in through
irrlicht run <command>. Discovered, unwrapped sessions retain the existing injection path as a clearly labeled limited tier. - The native agent TUI remains the interface. The runner owns and mirrors its PTY; Irrlicht does not replace it with a protocol-specific chat client.
- One stable Irrlicht session identity survives Mac-to-server and server-to-Mac placement changes.
- The relay brokers identity, routing, and streams. It does not execute agents, interpret rules, or hold repository credentials.
- Remote execution is single-user in v1 and always sandboxed. The provider is selectable and capability-driven.
- Real credentials remain outside the sandbox. Git access uses session- and branch-scoped credentials.
| Name | Responsibility | Does not do |
|---|---|---|
| Irrlicht clients | macOS app, web dashboard, and iOS app. Render observation, issue scoped requests, and show placement/capability state. | Own an agent process or decide placement truth. |
| funken.io | Hosted identity and pairing control plane: workspace, devices, short-lived pairing challenges, scoped credentials, revocation, and relay service discovery. The managed profile also exposes the authenticated relay endpoint. | Execute user code, store repository credentials, or become the source of session state. |
irrlichtrelay |
Broker/data plane. Fan out observations, route commands to the authoritative runner, keep placement leases, and carry attachment streams. May be managed behind funken.io or self-hosted. | Run rules, inspect terminal meaning, or host sandboxes. |
irrlichd |
Observer. Discover agent transcripts/processes, derive the three states and metrics, and publish observation events. Runs on the Mac and the Linux server. | Own PTYs or migrate processes. |
| runner | Execution boundary invoked as irrlicht run <command>. Own the PTY, mirror locally or run headless, host per-session rules, expose control/attach, and create checkpoints. |
Discover unrelated sessions or broker clients. |
| runner supervisor | Long-running service on the Linux server. Advertise workspaces/providers, accept spawn requests, create runner instances, and report health/capabilities. | Act as a general remote shell daemon. |
| sandbox provider | Create/destroy an isolated environment, provide a persistent workdir and interactive PTY, enforce resource/network policy, and report capabilities. | Decide product placement or hold user credentials. |
irrlicht run is the public command. The implementation may ship as an irrlicht-run helper binary, but users and docs should not present two competing command names.
| Epic | User value | Minimum dependency | Acceptance demo |
|---|---|---|---|
| 1. Pair and observe | The iPhone shows Mac sessions through funken.io from anywhere. | Existing daemon forwarder/relay observation plus device pairing. | Pair by QR, leave Wi-Fi, and watch a Mac session move through all three states. |
| 2. Control sessions on the Mac | A phone can answer, send commands, interrupt, and attach to sessions launched by irrlicht run. macOS/web manage connection and devices. |
Stable identity, local runner, control routing, scoped device credentials. | Start irrlicht run claude, answer from iPhone, attach, revoke that iPhone, and prove further control is denied. |
| 3. Run and beam on a server | A provisioned Linux box can spawn sandboxed sessions and receive a Mac session at a turn boundary. | Runner supervisor, SSH setup, sandbox providers, placement/checkpoint protocol. | Spawn from iPhone, then round-trip another session Mac -> server -> Mac with observation continuous. |
These are product increments, not isolated engineering projects. Stable identity, capability negotiation, permission gating, and request/result semantics are shared foundations.

flowchart LR
subgraph Clients
IOS[iOS app]
MAC[macOS app]
WEB[Web app]
end
subgraph Hosted[funken.io hosted profile]
CP[Identity and pairing]
RELAY[irrlichtrelay broker]
end
subgraph Mac[User Mac]
MD[irrlichd observer]
MR[runner: irrlicht run]
AGENT[Agent PTY]
end
subgraph Server[User-owned Linux server]
SUP[Runner supervisor]
SD[irrlichd observer]
SP[Sandbox provider]
SR[Headless runner]
SAGENT[Agent PTY]
end
IOS -->|pair and manage device| CP
MAC -->|pair and manage device| CP
WEB -->|account session| CP
IOS <-->|WSS observe/control/attach| RELAY
MAC <-->|WSS observe/control/attach| RELAY
WEB <-->|WSS observe/control/attach| RELAY
MD -->|outbound observations| RELAY
MR <-->|outbound requests/streams| RELAY
MR --> AGENT
SD -->|outbound observations| RELAY
SUP <-->|outbound catalog/spawn| RELAY
SUP --> SP --> SR --> SAGENT
No inbound port is opened on the Mac or execution server. Daemons, runners, supervisors, and clients dial out to the relay. The hosted broker is internet-reachable over TLS and rejects every operation without a scoped credential. The self-hosted profile can instead place the same relay on a Tailscale/WireGuard network.
-
Observe:
irrlichd -> relay -> clients. Low-volume, state-convergent events; snapshot reconciliation repairs dropped/coalesced updates. -
Control:
client -> relay -> authoritative runner. Ordered requests with acceptance and completion/error results. -
Attach:
authoritative runner -> relay -> attached clients. Initial VT snapshot followed by ordered deltas; input still uses the control path. -
Placement:
runner/supervisor <-> relay -> clients. A generation-checked lease says which runner is authoritative. - Checkpoint: source runner -> target runner, brokered by relay/object transfer. Contains work and native session data, never a live process image.
Today relay identity is (workspace, daemon_id, session_id). That is sufficient for observation but cannot survive handover because daemon_id changes when a session moves.
The remote-session model introduces four distinct identifiers:
| Identifier | Lifetime | Purpose |
|---|---|---|
session_key |
Entire Irrlicht session | Random, globally stable identity used by clients, rules, parent links, and placement. |
agent_session_id |
Agent-native conversation | Claude/Codex/etc. resume identity. May need path remapping when moved. |
runtime_id |
One process incarnation | Identifies the current PTY/process. Changes after resume or handover. |
runner_id |
One installed runner/supervisor | Routing and capability identity for a Mac or server. |
Target placement shape:
{
"session_key": "ses_01K...",
"generation": 7,
"runner_id": "run_buildbox",
"runner_label": "buildbox",
"location": "server",
"runtime_id": "rt_01K...",
"observer_daemon_id": "daemon_server",
"workspace_id": "ws_personal",
"sandbox": {
"provider": "srt",
"sandbox_id": "sbx_01K...",
"network": "allowlist"
},
"capabilities": ["control", "interrupt", "attach", "checkpoint", "resumable"]
}- The relay routes by
session_keyand the current placement lease, never by a client-supplied daemon target. - Every placement mutation compares
generation; stale requests fail instead of creating two authorities. - The source retains authority until the target runner has restored the checkpoint and confirmed readiness. The relay then atomically advances the generation.
- A lease expires when a runner disappears, but the relay does not silently start a duplicate. The session becomes unavailable until the runner reconnects or the user chooses an explicit recovery.
- Host-local PID, CWD, transcript path, launcher, and observer daemon stay runtime metadata. They never define global identity.
Before spawning an agent, the runner registers session_key + runtime_id + owned_pid with the local daemon. The daemon suppresses process-presession discovery for that owned PID, then binds the eventual transcript-native session to the registered key. This handshake is complete before the child can be reported, eliminating the current duplicate-discovery race.
Observation still comes from irrlichd; ownership and control come from the runner. A temporary observer outage must not remove runner authority, and a runner outage must not rewrite the last observed state.
| Profile | Pairing/control plane | Broker exposure | Intended use |
|---|---|---|---|
| Managed funken.io | funken.io | Public wss:// behind TLS, auth, workspace isolation, limits, and revocation |
Phone access without operating a broker. |
| Self-hosted tailnet | Local CLI/admin UI or optional funken pairing | Private Tailscale/WireGuard address | Maximum infrastructure control. |
| Trusted LAN development | Manual token | Loopback/LAN only | Development and protocol testing, not remote phone access. |
The managed profile intentionally supersedes issue #925's earlier absolute statement that the relay is never internet-facing. The invariant that remains is: no user machine exposes an inbound port, and no unauthenticated relay operation exists.
sequenceDiagram
autonumber
participant M as macOS Remote Access
participant F as funken.io control plane
participant I as iOS app
participant R as relay
M->>F: Create one-time pairing challenge
F-->>M: QR payload + short code + 5-minute expiry
I->>F: Redeem challenge, present device public key
F-->>M: Confirm device name and requested observe scope
M->>F: Approve
F-->>I: Device credential bound to key, workspace, role, scopes
I->>R: hello(device credential, proof of key)
R-->>I: snapshot limited by granted scopes
The QR code never contains the workspace's long-lived bearer token. It contains a one-time opaque challenge, the control-plane origin, and a pinned workspace identifier. Pair approval shows device name, platform, requested scopes, and expiry.
| Credential | Storage | Scope and lifetime |
|---|---|---|
| Device credential | iOS/macOS Keychain, bound to a device key | One workspace; observe, optional control, attach, spawn, handover; revocable; rotating. |
| Browser session | HttpOnly, Secure, SameSite cookie for hosted web; local daemon proxy for local web | Short-lived account session. Long-lived relay tokens do not enter localStorage. |
| Runner credential | Mode 0600 file or OS key store on runner host |
Runner role, one workspace, catalog/spawn/control for its sessions only. |
| Daemon credential | Existing protected relay token store, upgraded with daemon role | Publish observations for its daemon identity only. |
| Session grant | In-memory, signed/opaque capability | One session and operation set; short expiry; used for attach/handover. |
| Git credential | Host-side credential helper | One repository/session branch, short expiry; never copied into sandbox files. |
Every read or modification performed by a new adapter remains consent-gated:
- PTY input, interrupt, attach output, and checkpoint reads are runner permissions.
- SSH probing/install, service changes, and remote file writes are provisioning permissions.
- Sandbox creation, workdir mounts, network policy, and exposed ports are provider permissions.
- Git credential minting is a separate modify permission with branch/repository scope.
- Relay toggles remain an outer gate; per-device scope, per-agent consent, session capability, and placement authority are all checked again at the executing runner.
The runner/provider implementations require behavioral coverage equivalent to contracttesting.AssertPermissionGated; transport authorization never replaces local consent.
V1 uses TLS and a trusted broker. Framing keeps routing metadata separate from payloads so end-to-end encryption can be added later. E2E changes notification design: APNs may need generic βA session needs youβ payloads unless a user device encrypts notification content for itself.
Epic 1 is intentionally read-only. It validates device identity, WAN reconnect, aggregated session identity, and mobile information hierarchy before remote commands raise the security stakes.
- In macOS Remote Access, the user signs into or selects funken.io and sees the connection health.
- Pair device creates an expiring QR code and short code.
- The iOS app scans the code, names itself, requests
observe, and waits for approval. - The Mac shows the request; the user approves.
- The phone receives an initial workspace/daemon/session snapshot, then live updates.
- Session rows say where work is running: On this Mac or Server - buildbox. A cloud icon alone is not sufficient.
- Tapping a row opens state, question/summary, project/branch, model/context, cost, source, last update, and connection quality.
There is no iOS target today. The implementation starts by extracting transport-neutral Swift code from the macOS app:
IrrlichtCore
Models/ session, placement, capabilities, questions
Transport/ daemon + relay streams, auth, reconnect, snapshots
Identity/ pairing, Keychain credentials, device key
Notifications/ transition policy, presence, deep links
Presentation/ grouping, ordering, source/placement labels
IrrlichtMac menu-bar shell, launch/focus, macOS notifications
IrrlichtMobile SwiftUI navigation, QR scanner, APNs, terminal view
The first mobile target is native SwiftUI with NavigationStack on iPhone and adaptive master/detail on iPad. It consumes the same canonical models and relay envelope as macOS. Platform actions such as local terminal activation remain outside IrrlichtCore.
- Initial connection is always
hello -> capability agreement -> snapshot -> live. - The phone keys rows by
session_key, not daemon/session tuples. - Snapshot reconciliation restores truth after sleep, radio changes, or dropped coalescible events.
- A daemon or runner disconnect changes connectivity/availability badges without inventing a fourth session state.
- The last observation remains visible with Offline - last seen ... instead of disappearing.
- Local and relay paths may report the same session; stable identity collapses them deterministically.
- Parent/child relationships and grouped project views remain intact across sources.
- Default triggers are
working -> waitingand an optional long-tailreadynotification. - The app registers its APNs token against its device identity, not a raw workspace token.
- Tapping a notification deep-links to the session detail. Epic 1 has no reply action.
- When a paired Mac later publishes that the same session's terminal is focused, phone notifications for that session are suppressed. This presence signal arrives in Epic 2; until then iOS uses local foreground suppression only.
- A disconnected relay or expired credential produces a visible connection state and one actionable notification, not repeated session alerts.
| Condition | Behavior |
|---|---|
| Mac sleeps or loses network | Keep last snapshot, mark Mac/source offline, reconcile on reconnect. |
| Relay unavailable | Exponential backoff with jitter; no local data is deleted. |
| Device revoked | Close with a distinct auth code, erase credential, return to pairing. |
| Protocol capability missing | Render supported observation fields and hide unavailable actions. |
| APNs unavailable | Live data still works while the app is open; show notification status in settings. |
- A new iPhone pairs using a one-time QR challenge; the QR does not reveal a reusable token.
- The device can be approved, listed, and revoked from the Mac.
- On cellular data, the iPhone displays all Mac sessions with correct state, source, placement, parent/child grouping, and offline behavior.
- All three state transitions reconcile after an intentional disconnect/reconnect.
- The paired credential grants observation only; control frames from that device are rejected.
- iOS model/transport behavior is shared with macOS rather than duplicated.
Epic 2 introduces the runner locally, where PTY behavior, terminal mirroring, identity registration, and rule execution can be debugged without server provisioning.
irrlicht run claude
irrlicht run codex -- <agent arguments>
irrlicht run --sandbox null -- geminiThe runner:
- Allocates
session_keyandruntime_id. - Registers ownership with the local daemon before starting the child.
- Creates a PTY through the selected local provider.
- Starts the command with the user's terminal size and environment policy.
- Mirrors bytes between the PTY and local terminal.
- Runs an always-on VT state machine for instant attach and UI prompt detection.
- Hosts the effective backchannel rule snapshot for that session.
- Connects outbound to the relay and advertises control/attach capabilities.
- On exit, flushes final observation/checkpoint metadata and releases placement.
The wrapper is explicit. An optional shell integration may shadow common agent commands only where native tmux/kitty/iTerm/Terminal control is unavailable, but it must be managed, idempotent, visible, and removable.
| Tier | Wrapped session | Unwrapped discovered session |
|---|---|---|
| Observe | Transcript/process observation via daemon | Same |
| Control | Reliable PTY write/signal through authoritative runner | Best-effort injection through tmux/kitty/AppleScript when available |
| Attach | VT snapshot + ordered deltas | Unavailable unless an adapter can provide an equivalent owned stream |
| Handover | Available when agent is resumable | Unavailable; offer restart through irrlicht run
|
Clients label these as Reliable, Limited, or Observe only. They do not present injection as equivalent to runner ownership.
Control is no longer a silent fire-and-forget frame:
{
"type": "control_request",
"request_id": "req_01K...",
"session_key": "ses_01K...",
"placement_generation": 3,
"action": "input",
"text": "/compact\r",
"client_seq": 42
}The broker returns request_accepted after authorization and routing. The runner returns request_result with applied | rejected | failed, its authoritative generation, and a user-safe error code. Input is ordered per device/session. A client may retry the same request_id; the runner deduplicates it. Interrupt is a distinct action and never inferred from text.
Checks occur in this order:
- Device credential permits the action.
- Workspace/session grant includes the session.
- Relay routes to the current placement lease.
- Runner relay-control toggle is enabled.
- Agent/provider permission is granted locally.
- Session advertises the capability.
- Request generation and ordering are current.
Attach is downstream terminal output plus the existing ordered input path:
sequenceDiagram
autonumber
participant C as Client
participant B as Relay
participant R as Runner
C->>B: attach_request(session_key, cols, rows)
B->>R: attach_request(attachment_id, grant)
R-->>C: vt_snapshot(seq=900, screen, cursor, modes)
loop While attached
R-->>C: vt_delta(seq=901..n, bytes)
C->>B: attach_credit(last_applied, bytes_available)
end
alt client falls behind or delta is lost
R-->>C: resync_required
R-->>C: vt_snapshot(new seq)
end
C->>B: detach(attachment_id)
PTY traffic never uses the relay's ordinary drop-when-full observation queue. Each attachment has its own ordered sequence, credit window, idle timeout, and maximum buffered bytes. Overflow causes snapshot resynchronization. Terminal resize is an explicit control request; multiple attached clients can view, but one device holds the input lease by default.
The current global backchannel rules are resolved into an effective per-session rule snapshot at launch. The runner subscribes to observed state and VT UI signals, applies cooldown/global limits, and writes through its PTY controller. A checkpoint contains the rule snapshot and whether cooldown timestamps should continue. Editing global rules does not silently rewrite a running remote session; the UI offers Apply updated rules.
- A session row exposes one keyboard/control icon only when at least one control capability is available.
- The detail action sheet contains the observed question, command input, Send, Interrupt, and Attach where supported.
- The current macOS control popover becomes transport-neutral; it routes by placement rather than hardcoded loopback HTTP.
- Web gains the same capability-aware action model. Terminal attach uses xterm.js or an equivalent proven terminal renderer; it does not implement ANSI/VT parsing itself.
- Remote notification taps open session detail/attach rather than trying to activate a terminal on another host.
Remote connectivity is promoted out of Advanced Settings into a dedicated macOS full-panel destination and a dedicated web dialog/page. Both use the same information architecture:
- Connection - funken workspace/profile, broker URL, status, last sync, and self-hosted override.
- Pair a device - expiring QR/short code, requested default scopes, cancel/regenerate.
- Devices - name, platform, scopes, last seen, current presence, edit scope, revoke.
- This Mac - publish observation, allow remote control, default runner/provider, focus presence.
- Runners - configured Linux servers, health/version, advertised providers, setup/reconfigure/remove.
- Security - credential rotation, audit events, and destructive disconnect workspace action.
Raw bearer tokens are not the primary UI. Manual tokens and URLs remain under an advanced self-hosted section.

The Mac runner publishes short-lived presence for session_key when its terminal has focus. The relay keeps only the latest expiring presence and clients use it to suppress redundant phone notifications. Presence never changes authority and is not the existing host-local focus_requested activation frame.
- A Claude Code session launched with
irrlicht runappears once, with stable identity and Reliable control. - iOS, macOS, and web can answer, send a command including submission CR, interrupt, and receive explicit success/failure.
- A late attach receives a correct screen immediately, survives resize, and resynchronizes after forced overflow.
- An unwrapped controllable terminal still works and is labeled Limited; an unsupported editor is Observe only with a wrapper offer.
- Pairing, scope changes, device list, last-seen, and revoke work from macOS and web Remote Access.
- Revocation terminates the device connection and blocks a replayed request.
- Runner, device, and attach modify paths pass permission-gating contract tests.
The execution server is a user-owned Linux machine. It runs a supervisor, irrlichd, and one runner per active session. funken.io brokers access but never becomes the execution environment.
- Linux on
amd64orarm64, with systemd available for the initial implementation. - Outbound HTTPS/WSS and package/container registry access according to chosen provider policy.
- SSH access using an existing SSH agent/key. Password auth may be entered interactively but is never stored by Irrlicht.
- A non-root user with a writable home directory. Root is not required for the base install.
- Optional: Docker/Podman and permission to use it;
bubblewrap/SRT dependencies; Git and an agent CLI.
sequenceDiagram
autonumber
participant U as macOS/web setup UI
participant P as Provisioning service
participant S as Linux host over SSH
participant F as funken.io/relay
U->>P: Add server user@host
P->>S: Connect and obtain host key
P-->>U: Show fingerprint for explicit trust
U->>P: Approve host key
P->>S: Probe OS/arch/systemd/providers
P-->>U: Show install plan and permission effects
U->>P: Approve
P->>S: Install signed versioned binaries and config
P->>S: Install/enable user systemd units
P->>F: Mint runner + daemon credentials
P->>S: Store scoped credentials mode 0600
S->>F: Outbound supervisor and daemon hello
F-->>U: Runner online + catalog/capabilities
Provisioning is host-key-verified, idempotent, and resumable. It records the accepted fingerprint and refuses a changed key until the user explicitly re-trusts it. Every remote modification is previewed as an effect and audited.
The base install uses user-owned paths:
~/.local/bin/irrlicht
~/.local/bin/irrlichd
~/.config/irrlicht/server.toml
~/.local/share/irrlicht/
~/.config/systemd/user/irrlicht-runner.service
~/.config/systemd/user/irrlichd.service
The installer enables user lingering only with explicit consent. Provider-specific setup that needs elevated rights is a separate step with a separate permission; the base install does not run a remote shell script as root.
- The supervisor advertises label, version, health, capacity, agents, workspaces, provider capabilities, and supported resume adapters.
-
irrlichdobserves server sessions and publishes the same session/state events as the Mac daemon. - A spawn request is validated, creates a sandbox, starts a headless runner within it, and returns a stable
session_keybefore the child agent starts. - Runner and daemon use the same registration handshake as on the Mac, so server sessions appear once.
- The supervisor survives relay outages, keeps local runners alive, and reconciles catalog/placement when it reconnects.
- Updating the server drains or preserves existing runners; it does not terminate them without an explicit incompatible-upgrade warning.
type SandboxProvider interface {
Capabilities(ctx context.Context) CapabilitySet
Create(ctx context.Context, spec SandboxSpec) (Sandbox, error)
Destroy(ctx context.Context, id SandboxID) error
ExecPTY(ctx context.Context, id SandboxID, spec ExecSpec, size Size) (PTY, error)
}
type PTY interface {
Read([]byte) (int, error)
Write([]byte) (int, error)
Resize(Size) error
Signal(Signal) error
Wait() (ExitStatus, error)
Close() error
}SandboxSpec includes workdir source, persistent-workdir name, resource limits, environment allowlist, mounts, secret proxies, and network policy:
none
allowlist(domains)
full
Providers advertise persistent_workdir, network_none, network_allowlist, network_full, snapshot_restore, expose_port, devcontainer, and resource-limit capabilities. The UI disables unsupported choices and explains the degradation before spawn.
| Provider | Role | Remote default? |
|---|---|---|
| SRT/bubblewrap | Lightweight host-toolchain sandbox with filesystem boundaries and allowlisted egress where supported. | Default for a basic Linux server after capability probe. |
| Docker/Podman + devcontainer | Reproducible OCI environment, persistent volumes, devcontainer configuration, stronger dependency isolation. | Recommended for project-defined environments. |
| null | Plain directory/worktree. Validates the port and supports local migration debugging. | Never the remote default; requires an explicit No isolation choice. |
| Later providers | Kata/Docker sandboxed containers, Kubernetes Agent Sandbox, E2B, Daytona, Modal, Fly Machines. | Behind the same capabilities, not new client protocols. |
βEverything runs in a sandboxβ therefore means remote spawn selects an isolating provider by default. null is an explicit development escape hatch, not a silent fallback. If no isolating provider is available, setup finishes with Runner online - no safe provider and blocks remote spawn until the user installs one or opts into no isolation.
The supervisor advertises a catalog of workspaces, agents, and providers. Clients support two paths:
- Catalog spawn: choose workspace/repository, agent, provider, network preset, initial prompt, and resource size.
-
Empty sandbox: create a persistent workdir with a plain shell, attach, then run
gh auth login, clone a repository, perform an agent device login, and launch the agent. Authentication URLs open in the phone browser.
The host-side credential helper answers Git credential requests with session-scoped credentials. It restricts push to the session branch and keeps real tokens outside the sandbox. Agent OAuth is performed interactively per sandbox/provider or through an explicit provider secret proxy; handover never copies arbitrary host secrets.
Handover is checkpoint/resume at a turn boundary, not process migration:
sequenceDiagram
autonumber
participant M as Mac runner
participant B as Relay/placement
participant S as Server supervisor/runner
participant C as Clients
M->>M: Drain to waiting/ready or interrupt
M->>M: Commit work to session branch
M->>M: Build checkpoint bundle
M->>S: Transfer branch reference + native session files + rules
S->>S: Create sandbox, checkout, remap paths, resume same agent
S-->>B: Target ready(session_key, expected generation)
B->>B: Advance placement generation atomically
B-->>C: placement_updated(Mac -> server)
B-->>M: Release old runtime
Checkpoint bundle:
manifest.json
session_key, source generation, agent adapter/version
agent_session_id, source/target path map
git remote, session branch, commit, dirty/untracked policy
effective rule snapshot and cooldown policy
provider requirements and capability constraints
warnings: background processes, MCP state, ports, secrets
agent-session/ minimum native files required for same-agent resume
transcript/ observation continuity/import material
workspace.patch fallback for data not safely committed
The source process is not terminated until the target proves it resumed and placement flips. Failure before the flip leaves the Mac authoritative. Failure after the flip uses an explicit recovery action; automatic split-brain recovery is forbidden.
irrlicht pull <session> supports:
| Landing mode | Preconditions | Result |
|---|---|---|
| Same project directory | Same normalized Git remote; clean tree or user-approved commit/stash; compatible agent installed. | Fetch/checkout session branch, import native session, resume in current directory. |
| New worktree | Local checkout of same repository. | Create a worktree on the session branch and resume without disturbing the current checkout. |
| Fresh directory | No local checkout. | Clone origin, or clone from the server runner when the sandbox-born repository has no remote; import and resume. |
Only same-agent native resume is in scope. Cross-agent handoff through a distilled prompt is lossy and remains a separate future feature.
| Failure | Required behavior |
|---|---|
| SSH provisioning interrupted | Re-run probes and converge idempotently; never assume partial success. |
| Server relay outage | Keep local runners alive, buffer only bounded control results, reconcile placements/catalog on reconnect. |
| Sandbox creation fails | Return structured provider/capability error; no placement is created. |
| Resume adapter incompatible | Keep source authoritative and show the exact incompatibility. |
| Target starts but placement CAS fails | Terminate/quarantine target runtime; source remains authoritative. |
| Source disappears mid-handover | Do not promote incomplete target automatically; offer recovery from last complete checkpoint. |
| Sandbox-born repo has no remote | Runner serves a read-only/push-scoped Git endpoint over the private authenticated path for fresh-dir pull. |
| Background shell/MCP/port cannot resume | Detect before checkpoint, list losses, require confirmation. |
- A Linux server is configured from Remote Access using SSH, explicit host-key trust, and an idempotent install plan.
- Supervisor and server daemon connect outbound, appear healthy, and advertise at least one isolating provider.
- From iPhone/web, a user can create an empty sandbox, attach, authenticate via device flow, clone, and launch an agent.
- The session remains observable and controllable after detaching the terminal.
- A wrapped Mac session beams at a turn boundary, retains
session_key, and continues observation on the server without duplicate rows. - The same session pulls back into same-dir and worktree modes; a phone-born/no-remote project lands through fresh-dir runner-served Git.
- Failure injection proves source-authoritative rollback before placement flip and prevents split brain after a stale generation.
The visual artifact is a target-state storyboard, not a screenshot of shipping software. It uses Irrlicht's existing semantic colors and compact operational language.
- Pairing is the entry screen only until a credential exists.
- The session list leads with state, project/session, observed question, and explicit placement.
- On this Mac and Server - buildbox are readable labels; a source glyph is secondary.
- Offline sources remain visible and show last-seen age.
- No command composer appears for an observe-only device.
- A waiting-session detail leads with the agent's question and context needed to answer it.
- Suggested answers are conveniences, never automatic approval.
- The command composer is per session and labels its delivery tier: Reliable or Limited.
- Interrupt is destructive/secondary and asks for confirmation if a tool is active.
- Attach opens a dedicated terminal view with connection/resize/input-lease status.
- New session chooses target, workspace, agent, provider, network policy, and optional prompt.
- Beam to server is a session action available only at a safe boundary and for resumable agents.
- Progress names real phases: draining, checkpointing, transferring, restoring, placement switched.
- Warnings about dirty work, background processes, MCP state, provider capability, or credentials appear before the action, not after failure.
- Successful placement is visible in every client immediately.
Remote Access is an operational settings surface, not a setup wizard that disappears. The same destination handles initial connection and later device/server administration.
- Open as a full 380-point panel view from Settings or a dedicated header action; do not add a fifth cramped footer button.
- Use native SwiftUI controls, SF Symbols, Keychain, confirmation sheets, and the existing compact 4/8/12/16 spacing.
- Pairing expands inline so the QR, expiry, requested scope, and waiting approval are visible together.
- Device and runner rows are dense lists with status dots and an overflow menu; they are not nested cards.
- Use a dedicated Remote Access dialog/page wider than the current 420-pixel generic settings dialog when device and runner tables are present.
- Local-daemon web delegates credential storage and SSH actions to the daemon; it never receives private keys or long-lived relay credentials.
- Hosted web uses account session cookies and WebAuthn/device confirmation where available.
- Every state/action must remain usable at a mobile browser width, but native iOS remains the primary phone client.
| Area | States |
|---|---|
| Connection | disconnected, connecting, connected, reconnecting, credential expired |
| Pairing | idle, challenge active, device requesting, approved, expired/cancelled |
| Device | online, recently seen, offline, scope changed, revoked |
| Runner | setup required, provisioning, online, degraded, incompatible version, offline |
| Provider | available, missing dependency, capability-limited, disabled by policy |
The shipped protocol stays at its current version until implementation. New peers negotiate capabilities in hello; required incompatible envelope changes bump the protocol version.
| Role | Existing/new | Allowed direction |
|---|---|---|
client |
Existing, extended | Observe; scoped control/attach/spawn/handover requests. |
daemon |
Existing, narrowed | Publish observation snapshots/events only. |
runner |
New | Own/control one or more local sessions; publish placement/capabilities/results/PTY output. |
supervisor |
New | Advertise catalog/capacity and accept spawn/provisioned runner lifecycle requests. |
Credentials are role-bound. A client token cannot announce itself as a daemon or runner.
| Family | Frames | Notes |
|---|---|---|
| Identity |
hello, hello_ack, device_presence
|
Capability strings, role enforcement, device/runner identity. |
| Observation |
daemon_snapshot, snapshot, push, daemon_status
|
Existing state-convergent path, extended with stable session/placement references. |
| Placement |
placement_snapshot, placement_updated, placement_unavailable
|
Generation/lease and authoritative runner. |
| Catalog |
runner_catalog, runner_status, capacity_updated
|
Agents, workspaces, providers, resume adapters, health. |
| Control |
control_request, request_accepted, request_result
|
Request ID, generation, ordered input, explicit errors. |
| Attach |
attach_request, attach_result, vt_snapshot, vt_delta, attach_credit, resync_required, detach
|
Dedicated queue and flow control. |
| Spawn |
spawn_request, spawn_progress, spawn_result, terminate_request
|
Catalog or empty-sandbox spawn; explicit lifecycle. |
| Handover |
checkpoint_request, checkpoint_ready, handover_request, handover_progress, handover_result
|
Turn-boundary checkpoint, capability/precondition report, CAS placement. |
| Device/admin | Control-plane API, not general relay fan-out | Pair, approve, scope, list, revoke, rotate; audit events may be streamed to authorized clients. |
All mutating operations use the same contract:
{
"type": "spawn_request",
"request_id": "req_01K...",
"idempotency_key": "device-uuid:173",
"workspace_id": "ws_personal",
"device_id": "dev_iphone",
"issued_at": "2026-07-14T20:30:00Z",
"expires_at": "2026-07-14T20:31:00Z",
"payload": {}
}-
request_acceptedmeans authorized and durably assigned to an authoritative runner/supervisor; it does not mean completed. -
request_resultis terminal and replayable for an idempotency window. - Errors use stable codes plus safe display text; internal paths/credentials never cross the relay.
- Expired requests are rejected even if a disconnected client later retries them.
- Per-session control ordering is monotonic. Spawn/handover ordering is per target runner and bounded by capacity.
The current relay is single-node and in-memory. Handover requires placement generation and in-flight request results to survive a broker restart. Before Epic 3, the broker adds a small durable store or reconstructs authoritative placement through a strict runner reconciliation protocol. Clients never infer authority from the most recent observation.
For a single-node v1, an embedded durable store is sufficient. Horizontal scale and general event history remain out of scope.
The customer-visible sequence and implementation sequence overlap but are not identical:
flowchart TD
F1[Stable session identity and placement model]
F2[Hostable control package and permission subjects]
F3[Scoped device credentials and role-bound relay auth]
E1[Epic 1: iOS observe]
R1[Local owned-PTY runner + daemon registration]
C1[Routed request/result control]
A1[VT snapshot/delta attach]
E2[Epic 2: remote control]
P1[SSH provisioner + supervisor]
S1[SandboxProvider: SRT, Docker, null]
H1[Checkpoint/resume + placement CAS]
E3[Epic 3: server spawn and beam]
F1 --> E1
F3 --> E1
F1 --> R1
F2 --> R1
R1 --> C1 --> E2
R1 --> A1 --> E2
F3 --> E2
E1 --> E2
P1 --> E3
S1 --> E3
R1 --> H1 --> E3
E2 --> E3
Suggested implementation slices:
- Extract stable identity/placement projections without changing existing observation behavior.
- Add role-bound credentials, one-time pairing, and device administration.
- Extract a shared Swift transport/model core and ship read-only iOS observation.
- Extract the control/rules engine into a hostable package.
- Ship the local runner with null provider for development, registration handshake, local mirroring, and reliable control.
- Add VT state, attach flow control, and transport-neutral macOS/web controls.
- Add SSH provisioner, Linux supervisor, SRT and Docker providers, and catalog/empty-sandbox spawn.
- Prove checkpoint -> kill -> resume on one machine, then add generation-checked beam/pull.
- Add focus presence, notification suppression, credential hardening, and recovery/audit polish.
Each slice must preserve the hexagonal direction domain -> ports -> adapters -> application/services: provider and provisioning interfaces are ports; concrete SSH, Docker, SRT, VT, and relay implementations are adapters. The runner may host application services but must not import cmd/irrlichd or concrete inbound adapters.
One end-to-end release candidate must pass this exact journey:
- Start from an unpaired iPhone, an installed Mac app, and a fresh supported Linux host.
- Connect the Mac to funken.io; pair the iPhone with read-only scope.
- Launch an unwrapped agent and confirm iPhone observation works but control is labeled limited/unavailable.
- Launch
irrlicht run claude; confirm it appears once and is labeled On this Mac - Reliable. - Answer a waiting question from the iPhone and verify explicit success plus terminal output.
- Attach from web late in the session and verify the first frame is a correct screen snapshot.
- Revoke the iPhone; prove its open socket closes and replayed input fails.
- Re-pair with control/spawn scope.
- Add the Linux server over SSH, verify its host key, review effects, and complete idempotent setup.
- Spawn an empty SRT sandbox from iPhone, attach, complete a device-code login, clone, and launch an agent.
- Detach; verify observation/control continue with no terminal bandwidth stream.
- Beam the Mac runner session to the server at a turn boundary; verify one stable row and placement generation advance.
- Simulate relay reconnect and confirm snapshots/placement recover without duplicate authority.
- Pull the phone-born session into a fresh Mac directory and the handed-over session into a worktree.
- Confirm branch/transcript continuity, rule continuity, explicit warnings for non-resumable state, and no real credential inside either sandbox.
- Live process/VM migration across macOS and Linux.
- Two active writable copies of one session.
- Cross-agent handover such as Claude -> Codex.
- Multi-user collaborative terminal input or shared session ownership.
- Multi-tenant execution isolation guarantees in the user-owned server.
- Building a custom ANSI/VT engine when a proven library is suitable.
- General SSH terminal management through Irrlicht.
- Kubernetes, microVM, or hosted sandbox implementation in the first server epic.
- Long-term relay event history, general audit analytics, or horizontal relay scale.
- Automatic transfer of background shells, arbitrary MCP server memory, ports, or host secrets.
| Risk | Required proof/decision |
|---|---|
| PTY flow control corrupts late/slow clients | Prototype snapshot/delta/credit/resync under loss before selecting frame sizes and buffers. |
| Runner/daemon double discovery | Race tests where transcript/process discovery occurs before, during, and after registration. |
| Stable identity migration breaks existing clients | Introduce additive fields and deterministic fallback; test mixed old/new peers. |
| Hosted broker sees plaintext | Threat-model TLS broker v1; decide E2E key exchange before claiming untrusted-relay security. |
| APNs conflicts with E2E | Choose generic encrypted-safe notifications or a user-device notification producer. |
| Browser credential safety | Keep long-lived credentials out of localStorage; define daemon proxy and hosted cookie/WebAuthn flows. |
| Placement durability | Select embedded durable store vs runner-led reconstruction before handover implementation. |
| SRT/bubblewrap support varies by distro | Capability probe and block safe spawn when isolation is unavailable; never silently fall back to null. |
| Resume files use absolute paths | Per-agent adapters must remap and test native session stores across directories/machines. |
| Stateful MCP/background processes are lost | Detect, list, warn, and require confirmation; do not claim transparent migration. |
| Sandbox-born repo lacks a remote | Deliver runner-served Git in the first pull implementation, not as later polish. |
| Scoped Git credentials are provider-specific | Define host-side helper/proxy semantics before exposing private repo spawn. |
- Issue #925 - accepted v2 design, phases, UX notes, and ACP/framework research.
- funken.io - hosted control plane for Irrlicht workspaces and device tokens.
- Relay-Server - history and current relay topology.
- Platform-Rollout - platform sequence; this page owns the expanded iOS journey.
-
docs/relay-protocol.md- shipped wire protocol. - Claude Code on the web - branch/transcript handoff and scoped credentials.
- Claude Code sandboxing - filesystem/network policy model.
- Codex cloud environments - setup/agent phases, secret removal, and apply fallback.
- Cursor cloud agents - environment snapshots and cloud-to-local takeover.
- happy - encrypted relay and phone-as-window model.
- Omnara - mobile remote control patterns.
- Terragon OSS - remote environment/conversation pull-down.
- Rivet sandbox-agent - supervisor and capability surface across sandbox providers.
- Kubernetes Agent Sandbox - future provider interface reference.
- claude-sync and codsync - native session-store transfer and path-indexing lessons.
The architecture borrows proven pieces, but the local-to-server push direction remains the differentiator: work and native session state move together, authority flips once, and the original process is never mirrored.