Skip to content

Latest commit

 

History

History
211 lines (180 loc) · 10.6 KB

File metadata and controls

211 lines (180 loc) · 10.6 KB

Remote Access

With remote access enabled the bridge additionally accepts ACP connections over WebSocket, so a phone or browser can watch and drive the same sessions as your editor. Zed (or any ACP editor over stdio) remains the primary client and owns the process: when the editor disconnects, the bridge — and every remote attachment — exits with it.

A ready-made client — Android APK plus a self-hostable web build — lives at william0wang/zcode-acp-remote.

phone / browser ──WS── tunnel ── hub (127.0.0.1:8377, single entry)
                                   │ byte-level proxy
                                   ▼
                     bridge ACP endpoint (127.0.0.1:8378+n)
                                   │ same AgentApp as stdio
Zed ──────── stdio ────────────────┘

Configuration

Remote access is opt-in and requires a token (the endpoint is expected to sit behind a public tunnel, so "loopback-only" is never a safe assumption). The authoritative source is the global user config file — every bridge, the hub daemon and hub-spawned terminal windows read the same file, so behaviour does not depend on which process happened to spawn what:

~/.config/zcode-acp/config.json ($XDG_CONFIG_HOME aware):

{
  "remote": {
    "enabled": true,
    "token": "<a-long-random-secret>",
    "hubPort": 8377,
    "hubHost": "127.0.0.1",
    "bridgePort": 8378,
    "terminal": { "app": "iTerm" }
  }
}

All fields are optional. Precedence per field: config file → environment variable → built-in default. The env vars (ZCODE_ACP_REMOTE, ZCODE_ACP_REMOTE_TOKEN, ZCODE_ACP_HUB_PORT, ZCODE_ACP_HUB_HOST, ZCODE_ACP_REMOTE_PORT, ZCODE_ACP_HUB_TERMINAL*) keep their semantics and fill any gap the file leaves, so existing env-only setups keep working.

Why a file: the hub is a detached daemon that idle-exits (~10 min) and is re-spawned by whichever bridge needs it next — its birth env rotates between GUI-launched editors (no shell vars) and interactive shells, so env-carried preferences drifted with every rebirth. Terminal preferences are re-read live at every remote incubation: edit the file, the next session-create uses it, no hub restart. Token/port changes apply when the hub is next (re)born — rotate while no hub is running, or kill it and let the next bridge re-spawn.

Alternatively, enable it per-agent in Zed's settings (Zed merges these into the agent's environment — no config file needed):

"agents": {
  "ZCode": {
    "command": "zcode-acp-server",
    "env": {
      "ZCODE_ACP_REMOTE": "1",
      "ZCODE_ACP_REMOTE_TOKEN": "<a-long-random-secret>"
    }
  }
}

Hub. The first bridge with remote enabled spawns the hub daemon as a detached, machine-level singleton on ZCODE_ACP_HUB_PORT (it can also be run manually). It does three things only: token auth, instance discovery, and byte-level proxying (ACP WebSocket plus read-only session files) — no session state, no path semantics. It exits after ~10 idle minutes and is re-spawned on demand. Each bridge registers every 10s as a heartbeat and drops out of discovery ~30s after it stops. Sandbox inheritance is self-healed: a hub born inside the Seatbelt wrap (a sandboxed backend spawned it — marked via ZCODE_ACP_SANDBOX_ACTIVE) relaunches itself through launchd, outside the sandbox, before binding; inside the sandbox macOS would refuse its opening of the visible session terminal (TCC), breaking remote session-create.

Discovery API (for client authors; fields are additive-only):

GET /api/instances              → [{"id","port","pid","startedAt","workspace",
                                    "origin","sessions":[{"sessionId","title?","updatedAt"}]}]
GET /api/instances?probe=1      → same list, minus bridges unreachable for ~8s
                                  (one failed probe only marks them unhealthy)
WS   /acp?instance=<id>         → proxied to that bridge's endpoint
GET /api/instances/{id}/fs/…    → read-only session files (list + raw bytes, ADR-0004)

origin labels how an instance was started: "editor" (a bridge an editor spawned over stdio) or "serve" (a headless bridge created remotely, see below).

Remote session-create (ADR-0014). A remote client can start a NEW agent session in any of the machine's known projects — no editor required:

GET  /api/projects              → [{"workspacePath","sessions","lastActive"}]
POST /api/instances {workspacePath} → {"id","reused"}

/api/projects aggregates the App's tasks index: every workspace that ever ran a session (system temp trees, ~/.zcode itself, and vanished directories filtered out), newest activity first. The list gates the POST — paths outside it get 403 (a convenience bound, not a security boundary: a token holder can already drive an editor-bridge session in any cwd; the trust boundary is the token). On create the hub incubates a VISIBLE interactive TUI window in the machine's terminal (ADR-0016 as amended by ADR-0020, macOS; headless/SSH, remote.terminal.enabled: false in the config file, or ZCODE_ACP_HUB_TERMINAL=0 falls back to the detached headless zcode-acp serve); its bridge registers back within seconds (budget ~20s) and is reachable like any other instance. The hub itself is a background process with no terminal, so nothing is auto-detected: the target terminal resolves as remote.terminal.command → remote.terminal.app from the config file (re-read live at every incubation; the ZCODE_ACP_HUB_TERMINAL_COMMAND → ZCODE_ACP_HUB_TERMINAL_APP env vars fill in when the file is silent — built-in launch recipes for Terminal, iTerm, WezTerm, kitty, Alacritty, Ghostty; other names pass through to open -a) → plain Terminal.app. Warp refuses .command files, so terminal.app: "warp" (or "warp-preview") instead opens warp://action/new_tab?path=<script> — its URI scheme executes the script as a new tab in Warp's default mode (app/src/uri/mod.rs → open_file). Hyper stays unsupported (no programmatic command execution, vercel/hyper#3677). Create NEVER reuses a live serve-origin instance (ADR-0016 as amended) — every create incubates its own window, and identical concurrent requests join the same in-flight spawn. A terminal-TUI instance lives while its window lives; the headless fallback exits ~10 minutes after the last client detaches and the last turn finishes. Session roots are pinned to the project cwd in both surfaces regardless of what a client sends.

Remote session-resume (ADR-0015). Remote clients can also reopen a PREVIOUS conversation of a project — including closed ones no bridge currently advertises (discovery lists only running conversations):

GET /api/projects/sessions?workspacePath=… → {workspacePath, instance, sessions}

The listing is the project's full backend session store; the hub incubates the project's serve bridge on first listing and reuses it after. Resume is the normal attach flow with session/load on a listed backend id. See docs/REMOTE-CLIENTS.md ("Resuming a closed session") for the client contract.

Remote project/session delete (ADR-0031). Both are soft deletes (tombstones in the App's tasks-index, upstream deleteTask semantics — the backend store keeps every conversation byte):

POST /api/projects/delete {workspacePath}              → {ok, deletedTasks}
POST /api/instances/{id}/sessions/{sid}/delete         → {ok, deleted}

A deleted project leaves GET /api/projects (and the session-create whitelist); a deleted session leaves every listing (/api/projects/sessions, ACP session/list, the CLI /resume picker, the desktop App sidebar). Sessions archived in the desktop App hide the same way. Deleting never stops anything: live conversations are refused (409), tombstoned resume ids get 404, and deletes are self-healing — opening the project anywhere (the CLI's /resume picker, an editor tab) or using any session again cancels the hide; a fully deleted project comes back whole the moment it is reopened. See docs/REMOTE-CLIENTS.md ("Deleting a project or session").

sessions lists the project's currently running conversations (live editor tabs and remote attachments) under the same ACP session ids the editor uses — attaching by id joins the conversation's live notification stream, and the hub dedupes sessions shared by several bridges of the same project.

The local TUI used to be a hub client too (ADR-0018, removed with the in-house REPL by ADR-0020). The Martty TUI boots its own bridge per window and does not merge hub-live sessions into its picker; watching a session driven from another client stays a remote-App capability (the hub's WS proxy and the /api surface are unchanged).

Auth is Authorization: Bearer <token> or ?token= (browsers cannot set WS headers); /api/* sends Access-Control-Allow-Origin: * — the token is the security boundary. A proxied connection stays bound to one instance; switching instances means reconnecting. Remote clients can also pull plan quota via the non-standard account/usage_stats ACP method (no session required), and browse/download the files of a session's project through the /fs routes above. During replay, compaction summaries and rewritten tool calls arrive as collapsed tool_call updates instead of walls of text (REPLAY-GUIDE.md).

Building a remote client — web, mobile, or CLI? The full integration contract (endpoints, framing, lifecycle timings, failure recovery, platform notes) lives in REMOTE-CLIENTS.md.

Semantics. All agent notifications are broadcast to every client. Permission / elicitation requests go to every client and the first answer wins; losing clients receive $/cancel_request so their dialogs close. Concurrent prompts for one session are serialized exactly as they are for a single editor. Capabilities declared by any client are OR-merged.

Tunnels. Designed for one-port tunnels (Cloudflare Tunnel, frp): map the hub port only. frp's tcp mode passes WebSocket as-is; Cloudflare Tunnel drops idle WebSocket connections, so the hub sends 30s keepalive pings on both legs. The bridge endpoint itself is loopback-only and never exposed.

Binding beyond loopback. The hub speaks plain HTTP/WS — the token travels and authorizes in cleartext, so ZCODE_ACP_HUB_HOST=0.0.0.0 (needed only when the tunnel agent runs in its own container) is exactly as safe as the network it lands on. Keep the bind loopback unless that interface is private to the tunnel agent, and put TLS in front before mapping it anywhere untrusted.