Sessions already live on disk
~/.codex/sessions, ~/.claude/projects, ~/.grok/sessions. Native resume works because those files are the conversation.
A self-hosted phone UI for the Codex, Claude Code, and Grok sessions already on one computer. The host’s session files are the conversation. The bridge translates taps into local CLI calls and fans the result back to every paired browser. The transcript itself is a typed view, drawn by Vue, of facts the bridge already owns.
Written from commit 7afe523 (“Speed thread catch-up and show more text without eager command output”), the tip of the Vue migration. That commit is on starred-threads history and is not what main publishes. The branch tip after it, 03dbe25, is a temporary pre-Vue restore and is not this document.
Codex Phone is a remote control, not a second agent product. It does not host a model, copy chats to a cloud, or replace the CLIs. A Mac (or Linux host) that already has the CLIs signed in runs one Node bridge. Phones reach that bridge over verified HTTPS.
~/.codex/sessions, ~/.claude/projects, ~/.grok/sessions. Native resume works because those files are the conversation.
Codex is JSON-RPC over stdio. Claude is stream-json. Grok is the Agent Client Protocol. A phone cannot talk to them directly.
One process serves the PWA, checks the pairing token, owns turn policy, and broadcasts one event stream to every open browser.
Primary journeys. Open any thread. Send. Steer. Queue. Show a provider failure. Continue by sending again. If the worst happens, the thread stays recoverable without restarting the CLI.
Remote browsers go through HTTPS. Native apps on the host skip the bridge and read the same files. That split is why a phone send and a later codex resume are one session.
The shell is still one hand-written public/index.html: composer, session list, settings, sheets. The #messages subtree is a precompiled Vue 3 app. Pairing stores an HttpOnly cookie. The page does not decide whether a message was delivered.
How a browser reaches the host. Funnel needs Tailscale only on the Mac. There is no LAN HTTP mode: a PWA and Web Push need a secure context. Quick Tunnels are excluded because the hostname changes and they do not support SSE.
server.mjsHTTP for actions the user takes. SSE on GET /api/events for everything the agent does. Provider adapters, the reliability kernel, and durable JSON under ~/.codex-phone live here. macOS setup installs a per-user LaunchAgent; Linux has a best-effort user systemd unit.
Codex app-server, Claude -p --input-format stream-json, Grok agent stdio. Model credentials stay in the CLI’s own login. Codex Desktop, Claude Code, and Grok read those files without the bridge.
The client request id is the idempotency key. The same id cannot post twice. If the bridge dies after the CLI may already have the prompt, the row is uncertain and is never auto-replayed. Sending twice is worse than asking the user to check.
Codex Desktop, a terminal, or an IDE can already hold the writer. The phone queues or waits. It does not kill that process. While a browser has the thread on screen, watch.mjs follows the session file and clients refetch. There is no second live stream and no sync protocol: devices agree because they share the host.
History is GET /api/thread. Live deltas are GET /api/thread/delta plus SSE. The shell does not paint those frames itself. It hands each one to message-view-state.ts, which accepts, ignores, or invalidates it, and Vue redraws the keyed rows. On reconnect the canonical read is authoritative. A delta the view cannot place asks the shell for that read. It never invents the missing rows.
A 256-bit token in the pairing URL becomes an HttpOnly, SameSite cookie (Secure on HTTPS). App, API, manifest, service worker, icons, and vendor assets all require it. Unauthenticated replies are generic: no thread names, paths, or version. Bad tokens back off per client.
Funnel is internet-reachable, so the pairing link is a password. Serve keeps HTTPS on the tailnet. Cloudflare Access adds an identity policy on a stable hostname. The token is still required behind Access.
Shell access is off by default. Enrollment is a five-minute QR from the host plus a passkey. Each browser enrolls separately. Administration is loopback-only. The project folder is a starting directory, not a sandbox: the shell is the bridge OS user.
fileAccess defaults to project (the thread’s working directory and sibling worktrees). anywhere drops that check. It is not the security boundary. A paired client can already drive an agent.
| Process | Owner | Lifetime |
|---|---|---|
server.mjs | LaunchAgent / foreground CLI | The bridge. HTTP, SSE, stores, policy. |
Codex control app-server | providers/codex.mjs | One long-lived child for reads, models, account. Restart with backoff. Turns do not run here. |
| Codex thread holder | same | One app-server per held writer lease. Idle release around 5 minutes. |
| Claude stream-json | providers/claude.mjs | A persistent process for the live turn. Approvals via a PreToolUse hook back to the bridge. |
| Grok ACP | providers/grok.mjs | One grok agent stdio process per open thread, speaking ACP through @agentclientprotocol/sdk. |
terminal-worker.mjs | terminal security | Interactive PTY in a separate worker so a native terminal failure cannot stop the bridge or a provider turn. |
Setup (bin/codex-phone.mjs) owns Tailscale Funnel/Serve, public verification, and the pairing QR. A healthy supervised bridge is not restarted by a later setup, so an in-flight turn keeps running. Code updates load on stop then start.
Four ownership rules. Adapters observe provider protocol and emit capabilities and events. They do not invent browser state. The bridge owns turn lifecycle, delivery, queue order, recovery, and the projection inputs it broadcasts. The browser owns draft text and view state. It must not infer delivery or liveness. One lifecycle decision governs send, steer, queue, interrupt, compact, and recover.
| Module | Job |
|---|---|
dispatch-decision.ts | Pure function: intent + run state + capabilities → execute, queue, reject, or recover. No I/O. |
turn-lifecycle.ts | One phase machine per turn. Every non-terminal phase has a deadline. busy is derived, never assigned. |
send-ledger.ts | Durable outcome per requestId. Commit, then announce. Public states are still dispatching, accepted, uncertain, not sent, or resolved. |
thread-identity.ts | (provider, provisionalId) → canonicalId, plus request correlation for the first send that had no thread yet. |
bridge-turn-state.ts | Durable active or interrupted marker so a restart can tell a turn the bridge owned from a file that merely looks open. |
thread-queue.ts | Bridge-owned follow-ups. Drained when a turn this bridge owns ends. Distinct from a provider-native queue. |
dispatch-settings.ts | The exact model, effort, and permission mode that will be sent. Fail closed if the installed CLI cannot prove the flag. |
message-view-state.ts | The transcript as one value. Absorbs the old projection: snapshot vs delta, intent folding, provisional live rows, stable keys. No DOM, no fetch, no delivery opinion. |
frontend/ | Vue 3 owns #messages only. Placement and order. Existing item renderers stay leaves inside elements Vue created. |
transcript-projection.mjs | Still injected into the shell by onboarding.mjs for the path Vue does not draw. Superseded wherever the Vue transcript is mounted. Removed when that fallback goes. |
schemas.ts | Zod only at the durable-file and HTTP-body boundary. Schemas satisfies the kernel types. Bad rows are skipped; startup does not throw. |
watch.mjs | Follows a thread a client is viewing when this bridge did not start the turn. |
cli-capabilities.mjs | Strongest honest surface: Codex model/list, Grok ACP init metadata, otherwise the installed CLI’s --help. |
decideDispatch is the only policy. POST /api/message, /api/steer, /api/queue, /api/interrupt, /api/resume, and compact all consume it. The browser asks the bridge which route is legal. state.busy is a chip hint. A session-file “still open” marker never gates the user’s own verb: that marker is true after every interrupt and every failed turn.
| Intent | When the bridge owns a running turn | Otherwise |
|---|---|---|
| send | Queue (turn_in_progress). Drain on the terminal edge. | Execute. The adapter’s atomic check is the last word. |
| steer | Execute if the provider can steer. If nativeSteer === false, recover: stop, then send. | Execute. Adapter accepts or refuses. |
| queue | Reject bridge_owns_turn. Use the durable queue. | Execute only when a foreign turn is running and the provider has a native queue. |
| compact | Reject until the turn ends. Compaction rewrites the conversation that turn is reading. | Execute when idle and the provider advertises compaction. |
| interrupt | Always execute. The adapter reports ok: false for a turn it does not hold. | |
| recover | Execute only when the provider supports interrupted resume and the run state says the turn was interrupted. Otherwise reject. | |
Grok sets nativeSteer false. Claude steers by injecting into the live stream-json process. Codex steers on the holder it spawned. An explicit false is the only “cannot steer.” Missing capability is treated as able, and the adapter still refuses a write it cannot make.
Replaces a boolean session.busy that send and steer asserted in opposite directions, and that a silent turn could latch forever. A phase cannot be entered unless something can leave it. Illegal transitions are refused.
Failure exits, from any non-terminal phase: uncertain then failed, or interrupted. busy is true for dispatching, awaiting_ack, working, and uncertain.
| Deadline | Default | Meaning |
|---|---|---|
| Acknowledgement | 300s | Provider has not proved it took the prompt. Sized past Claude compaction silence, which has been measured at about 107–146s with no start marker. |
| Inactivity | 900s | Acknowledged, then quiet. Moves to uncertain and emits turn/stalled. Does not kill a long tool call outright. |
| Recovery | 300s | How long uncertain may last. Then the turn fails as interrupted so the thread is sendable again without a bridge restart. |
BaseProvider.settleTurnDeadlines is the one sweep. A turn that ends because nothing was heard is reported as interrupted work, not as a clean provider failure.
File ~/.codex-phone/send-ledger.json. One row per request: dispatching → accepted, or uncertain / failed. A dispatching row loaded after restart has lost the process that owned it, so it is settled on load instead of hanging forever. The ledger announces an intent only after the durable write. SSE intent frames carry provider, thread, request id, method, public state, text, revision. A resolved intent is a tombstone. Newest revision wins. /api/thread remains authoritative on reconnect.
A first send has no provider thread id yet. It runs under a provisional id (often draft-…). Codex and Grok allocate the real id in their response; Claude may be given one. thread-identities.json is the only map. adoptThreadIdentity in the server is one transaction: registry first, then every store and the ledger, correlated by request id so a null-thread row can be placed. Routes, decideFor, and sendOnce consult the registry. They do not copy the alias into each store’s own key forever. Identity comes from provider metadata (session_meta.id, history base, fork parent), never from a filename shape.
Model, effort, and permission mode are recorded against the provisional id and adopted with the thread. The exact resolved dispatch is what crosses into the provider. If the installed CLI’s help cannot prove a flag, the send is refused with an upgrade or reload instruction. Provider-default stays available. A restrictive mode is never silently widened.
This is the migration. Before it, eleven functions in public/index.html could each append into #messages. A user message existed as an optimistic bubble, browser pending state, a bridge intent, and a canonical row, and each had its own paint path. message-view-state.ts is the single answer: inputs in, one ordered keyed list out. Vue is only the thing that places those keys.
Request-id and revision folding. The local-to-bridge handoff. Whether a snapshot is newer than the cursor the view already holds (generation plus revision, with sourceVersion so an evicted bridge LRU cannot look older). Whether a delta’s ops may be applied, must be ignored, or invalidate the view. Resolved tombstones. Provisional rows watched on the live stream before the provider file contains them. Stable row keys. Which affordance a held message may show. It must not infer delivery, parse a provider, call an API, touch the DOM, or become a second lifecycle.
| Input | Function | Outcome |
|---|---|---|
| Canonical read | applySnapshot | adopted or declined. A declined read does not replace what is on screen. |
| File delta | applyDelta | applied, ignored, or invalidated. Invalidated asks the shell for a canonical read. |
| Ledger intent | applyIntentFrame | added, updated, resolved, or ignored. Newest revision wins. A resolved frame is a tombstone. |
| Composer, not yet admitted | localIntent / setLocalIntents | Shown with no verdict. A lost HTTP response is transport-uncertain: kept on screen, retryable only under the same request id. |
| Live notification | applyLiveItem | A provisional row, anchored to the last canonical id. Retired when the transcript accounts for it. A gap in the stream forbids retiring by position. |
| Provider terminal | applyTerminal | Settles unfinished work. Separate from running(false), which has many writers and is not “the turn ended.” |
Only a bridge intent carries a verdict. not_sent may retry and dismiss. uncertain may dismiss and must not retry, because the provider may already have accepted it. A local copy may not claim failure. transport-uncertain may retry under the same request id, which the send ledger deduplicates, so a second provider message cannot be produced. A resend under a new id is the bug.
public/index.htmlComposer, list, settings, sheets, the one turn clock, sticky scroll. It calls window.RemoteAgentsTranscript with facts the bridge reported. There is no method for “draw this message.”
frontend/main.tsMounts once on #messages, holds a shallowRef of the projection, and republishes on every transition. Deep reactivity is avoided because a transcript can be thousands of rows. The live-work label is a string the shell already computed, not a second clock.
Transcript.vue → TurnSection → ItemLeaf / ActivityRun / IntentBubbleEvery row is keyed from the view state. Consecutive activity (reasoning, commands, file changes, MCP, web search) is one piece so “Ran 3 commands” stays one disclosure. #liveStream is a leaf the shell fills token by token and Vue does not patch. #transcriptNotes is the same kind of leaf for usage-limit and error notes, and it survives the turn ending.
frontend/host.tsThe shell’s existing item renderers draw into an element Vue hands them and may not reach outside it. itemFingerprint decides when a leaf redraws. A browser check fails the build if a node appears in the subtree that Vue did not put there.
Vite compiles the slice with @vitejs/plugin-vue. No Vue runtime compiler ships. Output is public/assets/transcript-[hash].js plus manifest.json. frontend-build.mjs fingerprints frontend/* and message-view-state.ts into build.json. On a checkout, the bridge refuses to start if that stamp does not match the sources, so a stale bundle cannot pretend to be the tree. A published package ships the built assets without the .vue sources, and is not checked. The service worker precaches the manifest’s file list and upgrades all-or-nothing: an old document and a new one never share a mixed set of hashed files. The rest of index.html is still read from disk on each navigation.
What this slice does not take. The composer stays in the shell. Sheets may show actions or transient detail, and must not be the only place a durable failure or recovery appears. Stage 2 (isReaderSpeech, one contract for “the reader said this,” with per-provider normalisation behind it) and stage 4 (the rest of the shell as a projection) are not in this tree.
Every adapter is constructed with emit(event, data). The server stamps provider and broadcasts. Shared contract: init, listThreads, readThread, newThread, forkThread, send, interrupt, models, usage, projects, plus queue and resumeInterrupted where the CLI has them. Streaming events are notify (params always include threadId), approval, and bridge.
Reads come from rollout logs, not the control process. Turns run in per-thread holders. Native steer. Account selection can pin a thread to a Codex profile. Sessions under ~/.codex.
claude -p with stream-json in and out. The adapter translates Claude’s envelope into the same items. Manual vs auto permission flags are mapped per CLI version (default and manual are one contract). Sessions under ~/.claude/projects.
Persistent ClientSideConnection per open thread. ACP has no live injection, so steer on a bridge-owned turn is stop-then-send. Headless mode auto-approves until phone-side permission interception exists. Sessions under ~/.grok/sessions.
Provider discovery (provider-detect.mjs) looks on PATH and the official installer locations, including the codex binary inside ChatGPT.app. Credentials are read from each CLI’s auth file, the Claude keychain item, or the usual API-key variables. A provider that is missing or explicitly signed out is omitted from the picker until the bridge restarts. sign-in unverified stays enabled: the first real send is a better test than a setup-time guess.
Home is ~/.codex-phone (REMOTE_AGENTS_HOME overrides). Directory mode 0700, secret-bearing JSON 0600. Writes that must survive a crash are atomic (write aside, then rename). Loaders parse through Zod and skip damaged rows.
| File | Contents |
|---|---|
config.json | Token, port, public origin, VAPID keypair, binary overrides, fileAccess. |
push.json | Web Push subscriptions. 404/410 from the push service prunes a subscription. |
send-ledger.json | Delivery rows and intent revisions. |
thread-identities.json | Provisional to canonical ids. Own file so aliases are not pruned with turn state. |
bridge-turn-state.json | Active or interrupted turns this bridge owned. |
thread-queue.json | Durable follow-ups waiting on a bridge-owned turn. |
thread-settings.json | Per-thread model, effort, permission mode. |
thread-drafts.json · thread-schedule.json | Composer drafts and scheduled sends, shared across devices. |
usage-retries.json · usage-retry-policy.json · usage-retry-audit.jsonl | Optional “continue after the usage window resets.” Off by default. One Continue. after the account is idle and the exact settings still match. A restart during the actual send is uncertain and is not retried. |
thread-stars.json · thread-hidden.json · thread-snooze.json · thread-attention.json · thread-subscriptions.json | Sidebar and notification preferences. Bridge-owned, not provider metadata. |
thread-custom-names.json · thread-origins.json · thread-projects.json · thread-side.json · projects.json | Display names, where a thread was started, project grouping, side chats. |
thread-context.json · terminal-security.json | Compaction / context notes, and passkey credentials plus trusted terminal devices. |
session-performance.jsonl | Provider stages plus client timings posted to /api/ui-timing. Recorded, never acted on. |
Routes are a method-plus-path table inside server.mjs. Bodies for dispatch go through one dispatchRequestSchema; a bad body is HTTP 400 invalid_body naming the field. Unparseable input must not surface as 500, because the phone reads 500 as “maybe delivered.”
| Group | Representative routes |
|---|---|
| Read | GET /api/providers /api/threads /api/thread /api/thread/delta /api/thread/runtime /api/models /api/sidebar |
| Mutate a turn | POST /api/message /api/steer /api/queue /api/interrupt /api/resume /api/thread/compact /api/approval /api/thread/new |
| Intent recovery | GET /api/send/status POST /api/send/reconcile /api/thread/intent/retry /api/thread/intent/dismiss GET /api/intents/orphaned |
| Thread chrome | Stars, hidden, snooze, attention, name, origin, project, drafts, schedule, side chats, settings, lock warm/release. |
| Usage | /api/usage /api/usage-retries /api/usage-retry-policy and Codex thread-account pin. |
| Push | GET /api/push/key POST /api/push/subscribe. Notifications cover turns this bridge drove. The service worker opens the matching provider and thread. |
| Live | GET /api/events is the SSE fan-out: thread list changes, turn notifications, approvals, and intent frames. One stream, copied to every connected client. |
Static responses (HTML, sw.js, icons, math fonts, highlight.js, xterm) are also behind the pairing cookie. /internal/terminal-admin and /internal/local-proof are for the host CLI, not the phone.
remote-agents terminal enable mints a five-minute enrollment code. The browser creates a passkey (WebAuthn, @simplewebauthn/server). Later, /api/terminal/auth/* checks a fresh assertion and /api/terminal/ticket issues a short unlock. public/terminal.html is xterm.js over /api/terminal/ws. The PTY is node-pty inside terminal-worker.mjs. If the native addon is missing, the page offers passkey-protected one-shot commands and never automatically repeats a command whose result is uncertain.
The working directory is resolved again on the server from the thread’s provider-reported project folder before every new session. Revoke or disable invalidates unlocks and tickets and stops both interactive and one-shot processes. Revocation is accepted only from the bridge machine.
What this system is not. Not a hosted agent. Not a multi-writer editor. Not a global watcher of every external session while the PWA is closed. If the host sleeps, remote clients wait.