Skip to content

Latest commit

 

History

History
383 lines (296 loc) · 12.6 KB

remote.md

File metadata and controls

383 lines (296 loc) · 12.6 KB

Remote Control API

Okena includes a local HTTP/WebSocket server for remote control — useful for mobile companion apps or access via Cloudflare Tunnel.

Quick Start

  1. Open Settings and enable Remote Server (under Appearance)
  2. The status bar shows REMOTE :19100 K7M2-9QFP — click to copy the pairing code
  3. Pair from another device:
    curl -X POST http://127.0.0.1:19100/v1/pair \
      -H 'Content-Type: application/json' \
      -d '{"code":"K7M2-9QFP"}'
  4. Use the returned token for all subsequent requests

Security

  • Server always binds to 127.0.0.1 only — never exposed to the network
  • For remote access, use a tunnel (e.g. Cloudflare Tunnel, SSH port forwarding)
  • Pairing codes are 8-character base32, valid for 60 seconds, single-use
  • Tokens are stored as HMAC-SHA256 digests (never plaintext) using a persistent app secret (remote_secret in your platform's config dir)
  • Rate limiting: 5 attempts per IP per minute, 30 globally per minute
  • 300ms delay on every failed pairing attempt

Configuration

In settings.json in your platform's config dir (macOS: ~/Library/Application Support/okena/, Linux: ~/.config/okena/):

{
  "remote_server_enabled": true
}

When running, the server writes remote.json to the same config dir:

{
  "port": 19100,
  "pid": 12345
}

This file is deleted on shutdown.

API Reference

GET /health

No auth required.

{ "status": "ok", "version": "0.1.3", "uptime_secs": 120 }

POST /v1/pair

No auth required (has its own rate limiting).

Request:

{ "code": "K7M2-9QFP" }

Response (200):

{ "token": "base64url-encoded-token", "expires_in": 86400 }

Errors:

  • 401 — invalid or expired code
  • 429 — rate limited

GET /v1/state

Requires Authorization: Bearer <token>.

Returns the workspace state with a monotonic state_version counter. Clients can detect missed updates by comparing versions.

The example below is a subset of the full project shape. One field worth calling out: terminal_agent_status maps terminal id → { "lifecycle", "custom"?, "labels"? } for panes running an AI agent (see Agent Status). It is runtime-only — omitted entirely for terminals with no status, and absent after a daemon restart — and a change to it bumps state_version. claude_dialogs (terminal id → open dialog count), claude_sessions (terminals with a connected claude) and claude_pr_links (terminal id → the session's pull requests) are runtime-only too; see Claude remote.

{
  "state_version": 42,
  "projects": [
    {
      "id": "uuid",
      "name": "my-project",
      "path": "/home/user/my-project",
      "is_visible": true,
      "layout": {
        "type": "terminal",
        "terminal_id": "uuid",
        "minimized": false,
        "detached": false
      },
      "terminal_names": {},
      "status": "review",
      "terminal_agent_status": {
        "uuid": { "lifecycle": "working", "custom": "running tests 3/5" }
      }
    }
  ],
  "focused_project_id": "uuid",
  "fullscreen_terminal": null
}

Layout nodes are recursive:

Type Fields
terminal terminal_id, minimized, detached
split direction (horizontal/vertical), sizes, children
tabs children, active_tab

POST /v1/actions

Requires Authorization: Bearer <token>.

Tagged enum body — the action field selects the operation.

send_text

Write raw text to a terminal (no newline appended).

{ "action": "send_text", "terminal_id": "uuid", "text": "ls -la" }

run_command

Write text + newline to a terminal.

{ "action": "run_command", "terminal_id": "uuid", "command": "echo hello" }

set_project_status

Set or clear a project's user status (a board column id; see status-board.md). Omit status or send null to clear. status on the project reflects it in the next state; it is omitted while unset.

{ "action": "set_project_status", "project_id": "uuid", "status": "review" }

set_status_lanes

Show (on: true) or flatten a project family's status lanes in a desktop window. A worktree addresses its parent's family. Lanes are desktop presentation: the daemon only checks the project exists, and after success attached desktops receive a one-shot status_lanes_requested push and apply it to window ("main" or an extra window's id), else their active window. An extra window no attached desktop declared is rejected with window not found.

{ "action": "set_status_lanes", "project_id": "uuid", "on": true, "window": "main" }

set_overview_status_lanes

Show (on: true) or flatten the status lanes of a desktop window's overview (no project focused: every visible column grouped by status). Desktop presentation like set_project_layout: the daemon only validates the window, and after success attached desktops receive a one-shot overview_status_lanes_requested push and apply it to window, else their active window. An extra window no attached desktop declared is rejected with window not found.

{ "action": "set_overview_status_lanes", "on": true, "window": "main" }

send_special_key

Send a named key.

{ "action": "send_special_key", "terminal_id": "uuid", "key": "CtrlC" }

Available keys: Enter, Escape, CtrlC, CtrlD, CtrlZ, Tab, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown

split_terminal

Split a pane at a layout path.

{
  "action": "split_terminal",
  "project_id": "uuid",
  "path": [0],
  "direction": "horizontal"
}

close_terminal

{ "action": "close_terminal", "project_id": "uuid", "terminal_id": "uuid" }

focus_terminal

{ "action": "focus_terminal", "project_id": "uuid", "terminal_id": "uuid" }

read_content

Get the visible terminal viewport as text.

{ "action": "read_content", "terminal_id": "uuid" }

Response:

{ "content": "user@host:~$ ls\nfile1  file2\nuser@host:~$ " }

GET /v1/usage

Claude Code / Codex / Grok rate-limit usage measured on the daemon's machine, and the AI proxy's account pool while new shells route through it (pool on the Claude entry, see ai-proxy.md). Bearer auth like the other /v1 routes. Returns ApiUsage (crates/okena-core/src/usage.rs is the schema, with field docs):

{
  "providers": [
    {
      "provider": "claude", "status": "ok", "plan": "max",
      "fetched_at": 1792260185.0, "last_attempt_at": 1792260184.2,
      "next_attempt_at": 1792260485.0, "fetching": false,
      "error": null, "consecutive_failures": 0, "rate_limited_until": null,
      "windows": [
        {
          "id": "five_hour", "label": "Session", "kind": {"type": "five_hour"},
          "used_percent": 20.0, "window_seconds": 18000.0,
          "reset_at": 1792264800.0, "active": true,
          "history": [{"recorded_at": 1792259000.0, "used_percent": 18.0}]
        }
      ],
      "extra_usage": {"is_enabled": false, "monthly_limit_cents": 10000.0,
                      "used_credits_cents": 0.0, "utilization": 0.0},
      "credits": null, "reset_credits": null
    },
    { "provider": "codex", "status": "disabled", "windows": [] },
    {
      "provider": "grok", "status": "ok",
      "windows": [
        {
          "id": "included", "label": "Credits", "kind": {"type": "other"},
          "used_percent": 37.0, "window_seconds": 2678400.0,
          "reset_at": 1793404800.0, "active": true, "history": []
        }
      ],
      "prepaid_balance": 1200.0
    }
  ],
  "working_days": [0, 1, 2, 3, 4],
  "generated_at": 1792260190.0
}
  • status: disabled (extension off in settings) · no_credentials · pending · ok · error (windows are then the last good data, see fetched_at).
  • Window kind: five_hour, weekly, weekly_scoped (+ model), code_review, other. Window ids double as history series names.
  • Timestamps are epoch seconds. Time elapsed in a window is (now - (reset_at - window_seconds)) / window_seconds; a window with window_seconds: 0 (Grok without a billing period) has none.
  • pool and the grok entry come only from daemons that know them; older daemons omit both.
  • working_days: 0 = Monday; the desktop's weekly pace marker only advances on these days.
  • Polling: every 5 min per enabled provider (enabled_extensions contains claude-code / codex / grok), backoff 30 s → 1 h on errors, retry-after on 429. Only while a client holds a /v1/stream WebSocket: with none, a fetch that falls due waits for the next client, which gets it at once. POST /v1/usage/refresh fetches without a client.
  • The claude login is polled only while no pool is published. With a pool, new shells use the pool accounts; the login's entry keeps its last data and refresh requests for it are ignored.

GET /v1/usage/pool/{account_id}

One AI proxy pool account's five_hour and seven_day windows with their history (the pool in GET /v1/usage carries none), as ApiPoolAccountHistory ({"account_id": "…", "windows": [ApiUsageWindow]}). The daemon serves its own copy, loaded from the proxy at most once a minute; an account that does not serve requests has no history. 404 while no pool is published or the account is not in it, and from daemons older than the route.

POST /v1/usage/refresh

Body {"provider": "claude"}, {"provider": "codex"}, {"provider": "grok"}, or empty / {} for all. Returns the ApiUsage snapshot as soon as the daemon has applied the request (at most ~2 s): a requested provider with fetching: true is being refetched, otherwise the request was ignored. The fetch result arrives as usage_changed. Ignored per provider when its last attempt is under 60 s old, it is fetching, rate limited (rate_limited_until) or disabled. An error backoff does not block it: it only spaces the automatic poll. Unknown fields or providers → 400, so a daemon older than Grok support rejects {"provider": "grok"}.

WS /v1/stream

Real-time PTY output and state change notifications.

Authentication — two modes:

  1. Query param: ws://127.0.0.1:19100/v1/stream?token=YOUR_TOKEN
  2. First message: connect without token, then send {"type":"auth","token":"..."} within 2 seconds

Inbound messages (client to server)

Type Fields Description
subscribe terminal_ids: string[] Start receiving PTY output
unsubscribe terminal_ids: string[] Stop receiving PTY output
send_text terminal_id, text Write text to terminal
send_special_key terminal_id, key Send named key
ping — Keepalive

Outbound messages (server to client)

JSON text frames:

Type Fields Description
auth_ok — Authentication succeeded
auth_failed error Authentication failed
subscribed mappings: {terminal_id: stream_id} Subscription confirmed with numeric stream IDs
state_changed state_version: u64 Workspace state changed — refetch via GET /v1/state
usage_changed usage: ApiUsage Full agent usage snapshot (same as GET /v1/usage); sent right after auth_ok and on every change
dropped count: u64 Subscriber fell behind, N events were dropped
pong — Keepalive response

Binary frames (PTY output):

[u8 proto_version=1] [u8 frame_type=1] [u32 stream_id (big-endian)] [raw PTY bytes...]

The stream_id maps to terminal UUIDs via the subscribed response, avoiding UUID overhead in every frame.

Backpressure

If a subscriber can't keep up, the server drops oldest events and sends a dropped message. The client should refetch state and/or resubscribe.

Port Binding

The server tries ports 19100-19200 in order, falling back to an OS-assigned port if all are taken. The actual port is always reported in remote.json and the status bar.

Architecture

[Tokio thread]                          [GPUI main thread]
   axum handler                            cx.spawn() loop
      |                                        |
  async_channel::Sender ──────────►  async_channel::Receiver
      |                                        |
  tokio::sync::oneshot::Receiver ◄── oneshot::Sender (reply)

All terminal and workspace operations go through a single bridge channel. The GPUI-side processor handles them sequentially, ensuring thread safety without locks on GPUI entities.