Skip to main content

nexor-runner

Hosts and runs AI coding agents (Claude Code, OpenCode) in tmux sessions on one Linux machine. It owns a dedicated tmux server, keeps a state file so sessions can be restored after a restart, starts every agent with a minimal environment, and exposes a small local HTTP API.

Python 3.11+, no runtime dependencies.

Install

From PyPI, no repository access needed:

pipx install nexor-runner            # or: pip install --user nexor-runner
nexor-runner --version

Install it, do not pipx run it: the systemd units point at the interpreter that runs install-service, and pipx run uses a temporary environment that is garbage-collected, which would leave the units pointing at a path that no longer exists.

Known limits: units are written under $XDG_CONFIG_HOME (or ~/.config), where systemd looks only if that is its user config directory; --no-agora does not remove an agora unit installed earlier (uninstall-service does).

Requirements: tmux (3.x; 3.2+ for the browser terminal), the agent CLIs you want to run (claude, codex, opencode), and systemd --user for the units (loginctl enable-linger $USER keeps them alive after logout). nexor-runner and runner are the same command; python -m nexor_runner works too.

Quick start on Linux (one command)

The owner first creates the runner in the hub (Settings -> Runners: a name and a one-time nxr_ token). The token is not the Agora connector's delegation token (nxad_): the hub refuses that one on every runner endpoint. Save it in a file with mode 0600, then:

nexor-runner install-service --hub https://hub.example.com --token-file ~/runner-token
# or: printf '%s' "$TOKEN" | nexor-runner install-service --hub https://hub.example.com --token-stdin
# or: NEXOR_RUNNER_HUB_TOKEN=... NEXOR_RUNNER_HUB=https://hub.example.com nexor-runner install-service
nexor-runner doctor

install-service writes the three systemd --user units (nexor-runner-tmux, nexor-runner-api, nexor-runner-agora) pointing at the interpreter the package is installed in, creates the config directory (0700) and the API token (0600), links the runner to the hub (validating the token), and enables and starts everything. The hub token is copied to ~/.config/nexor-runner/hub-token (0600) when it comes from stdin or the environment; it is never printed, logged, put on a command line or in a unit. Without --hub the agora unit is skipped (add it later by re-running with --hub); other flags: --no-agora, --no-api, --port, --extra-path, --cards-dir, --host, --dry-run (print the units, change nothing). The directories of the claude, codex and opencode binaries your login shell finds (nvm included) are added to the units.

Upgrading: pipx upgrade nexor-runner && nexor-runner install-service (no flags: the link, the port and the extra path you installed with are kept in ~/.config/nexor-runner/service.json; an explicit --port / --extra-path overrides and becomes the new value). It is idempotent and restarts the API and agora units, which are stateless. It never restarts or stops nexor-runner-tmux: that unit hosts your running sessions. If its unit file changed, the new one applies the next time you restart it yourself, between sessions.

nexor-runner doctor [--json] checks tmux, the agent CLIs, linger, the three units, GET /health and the hub link (exit 1 when something fails, a missing individual agent CLI is only a warning). nexor-runner uninstall-service removes the API and agora units and leaves tmux running; --include-tmux removes that one too (ending every session). By default config, tokens and the hub link are kept. --unlink also forgets the hub link (agora.json; the token file you provided is never touched). --purge-config also deletes config.json, the API token, the copied hub-token and agora.json (it implies --unlink), then the config directory if it is empty; the cards directory and the session state are never touched, and tmux.conf goes only together with --include-tmux.

Plain-http hub (trusted network only). The hub URL must be https:// (http only for loopback). If your hub is plain http on a trusted network such as the VPN, opt in explicitly: --allow-insecure-hub on link and install-service, or NEXOR_RUNNER_ALLOW_INSECURE_HUB=1. Each use prints WARNING: plain http: token and traffic are not encrypted; use only on a trusted network such as the VPN (on stderr; the token is never printed). The choice is stored as "insecure": true in agora.json, so the agora unit and doctor keep working and later install-service runs need no flag. runner link is safe to re-run: it validates the token and rewrites agora.json with the same content, touching nothing else.

macOS and Windows: there is no service installer (systemd is Linux only) and install-service exits 3 after printing this. Run the pieces in the foreground, each in its own terminal: nexor-runner serve --init-token, nexor-runner link --hub https://<hub> --token-file <file>, nexor-runner agora. tmux is required, so on Windows the runner does not work.

Install from a clone (development)

git clone https://github.com/nexorhq/nexor-runner.git ~/dev/nexor-runner
cd ~/dev/nexor-runner
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"

Manual setup of the units

The three sections below are what install-service does, one unit at a time (useful to tune a unit by hand).

1. The tmux server unit

runner systemd install --dry-run    # print the unit and tmux.conf
runner systemd install              # write them
systemctl --user daemon-reload
systemctl --user enable --now nexor-runner-tmux.service

The unit runs tmux -L nexor-runner -f <conf> -D with an explicit minimal environment. Sessions live in this server (socket nexor-runner), separate from your default tmux server.

2. The API unit (optional)

runner serve --init-token           # creates ~/.config/nexor-runner/token (0600); stop it with Ctrl-C
runner systemd install --api --extra-path "$HOME/.nvm/versions/node/<version>/bin" --dry-run
runner systemd install --api --extra-path "$HOME/.nvm/versions/node/<version>/bin"
systemctl --user daemon-reload
systemctl --user enable --now nexor-runner-api.service
curl http://127.0.0.1:7800/health

--init-token creates the token file if it does not exist (random, mode 0600). The API unit requires the tmux unit. Options: --port (default 7800), --token-file, --runner-bin, --tmux-bin.

Connect the runner to an agora hub: the hub records intents (launch, kill, restore, remote-control, stop-scope), the runner connects outbound (no inbound port), reports its sessions and cards, executes the intents and reports the result. The hub never executes anything.

runner link --hub https://hub.example.com --token-file ~/.config/nexor-runner/hub-token
runner systemd install --agora --dry-run
runner systemd install --agora
systemctl --user daemon-reload
systemctl --user enable --now nexor-runner-agora.service

The owner creates the runner (name plus a one-time nxr_ token) in the hub's Settings -> Runners and saves the token in a file with mode 0600. runner link validates it with GET /self and writes ~/.config/nexor-runner/agora.json (0600: hub URL, token-file path, runner name); the token is never on the command line and never logged. The hub URL must be https (http only for loopback). runner unlink removes the link. The unit requires the tmux unit; runner agora runs the same loop in the foreground.

The loop: heartbeat every ~30 s; the full snapshot (sessions and cards) on start and whenever it changes, at most every 10 s; long-poll for intents, claim (a 409 is skipped), execute through the same code as the local API, report done or failed with a short message (the dead-pane tail on a failed start). Hub errors back off from 1 s to 60 s and never stop the loop.

Chat and terminal channel

Next to the intent loop, runner agora keeps ONE more outbound connection to the hub: a WebSocket (wss://<hub>/api/v1/runner-hosts/<name>/channel, same nxr_ bearer token, sent in the upgrade request). The hub cannot reach this machine (it may run in a container, or the runner on a laptop), so the browser's streams are multiplexed over this connection. The runner still opens no port. It reconnects with a 1 s to 60 s backoff and never stops the intent loop; an older hub without the endpoint answers 404 and the runner retries every two minutes. NEXOR_RUNNER_CHANNEL=0 turns the channel off.

Two kinds of stream, each opened by the hub for one browser pane:

  • chat (OpenCode sessions only): the message history (paged), live events (text and reasoning deltas, tool calls, status, approval prompts), send a prompt, interrupt, answer a permission. The adapter (oc_chat.py) is a whitelist, not a proxy: it can only call GET /api/session/{id}/message, GET /api/event, GET /api/session/active, GET|POST /api/session/{id}/permission[/{request}/reply], POST /api/session/{id}/prompt, POST /api/session/{id}/interrupt and two read-only lookups, GET /api/session (find the engine session titled with a handle) and GET /api/session/{id}/message/{message} (the finished copy of one message), on engine session ids it validated (ses_...). Engine calls go straight to loopback: no proxy from the environment and no redirects, so the Basic credentials cannot be sent elsewhere. The engine's filesystem and shell endpoints are unreachable from a browser. The engine session of a runner session is the session_id the runner stored (see "Stable Agora handles for OpenCode sessions"), else the newest engine session titled with the handle; with neither, the session is terminal-only. The engine is only ever contacted on loopback, with the password from its own service.json, never logged.
  • terminal (ANY runner tmux session, so Claude and Codex sessions too). Off by default: turn it on for this runner with NEXOR_RUNNER_TERMINAL=1 or runner config set terminal on (independent of chat; while it is off the terminal capability is not advertised and an open is refused with terminal_disabled). A browser terminal is a SHELL-LEVEL capability on this machine (whatever the agent's program can do, the owner's browser can type), so it is the owner's explicit choice, and the hub audits every one that is opened. The viewer is tmux -L nexor-runner attach-session -r -t =<name> in a PTY: a read-only tmux client, so it can only show the screen. Keystrokes from the browser are NOT typed into that client; they are delivered with tmux send-keys -t =<name>: -H <hex bytes> (validated, at most 64 KiB per frame, 4096 bytes per command), which writes the bytes to the program in the pane and never touches tmux's key table. Consequently prefix+c (new window, i.e. a shell), prefix+: (command prompt), kill-server, switch-client and mouse bindings are out of reach from the browser: they arrive in the pane as plain text. Requires tmux 3.2 or newer. Before send-keys, the runner drops what the browser's xterm.js answers to tmux's attach-time questions (device attributes, XTVERSION, colour and mode reports, DECXCPR) and the mouse and focus reports; everything else is typing and is delivered untouched, Shift/Alt/Ctrl+F3 (ESC [ 1 ; <mod> R) included. Keyboard modes follow tmux, not the pane: measured on tmux 3.4, an attach client switches the outer terminal to application cursor keys, application keypad and bracketed paste whatever the pane asked for, so arrows arrive as ESC O A and a paste is wrapped in ESC [ 200~ ... ESC [ 201~ (Claude Code reads both arrow forms and consumes the paste markers). Swallowing those modes in the browser would unwrap pastes for agents that asked for bracketed paste, so it is not done.

Capabilities per session, announced to the hub in a sessions frame (every 10 s when changed): {read, send, approve, interrupt, terminal_only, terminal}. OpenCode sessions with an engine session get chat (terminal_only: false) and a terminal; everything else is terminal_only.

The browser terminal does not resize the real session. Measured on tmux 3.4: ignore-size alone is not enough, because when the browser is the only client tmux still sizes the window to it and keeps that size afterwards. So while a browser watches a session the runner pins every window of it to its current size (resize-window -x W -y H, which makes tmux treat the size as manual, plus a @nxr_pin window marker that remembers the previous window-size). The PTY is opened at the window's size; a browser resize only changes the PTY (the part of the window the client shows). When the last stream of the session closes, the previous setting is restored; a runner that died while pinned undoes the pins at its next start. While a browser is attached, a human who attaches with a real terminal sees the pinned size. A window created while a browser watches (the agent or a human runs new-window) is sized by tmux to the browser at first: the runner checks the windows of watched sessions every ~2 s and puts a new one back to the size the session's windows had. The pin is held until the attach client of a stream has started, even if the stream is closed in that moment. Closing a stream hangs up the PTY (the tmux client detaches); the session itself is never killed.

After a reconnect the hub may reuse a stream id while a slow open of the old connection is still running: a stream only ever removes the chat subscription it registered itself, and one that was closed registers none.

Limits: at most 32 open streams per runner, 1 MiB per WebSocket message, 64 KiB per terminal input frame, 32,000 characters per chat prompt, snapshot frames trimmed to about 600 KB. The protocol is specified in the hub's docs/chat.md.

Remote Control

Claude: every session the runner starts or restores runs claude --remote-control <handle> (a --resumed one too): Remote Control is on from the first seconds, named like the session, with no keystroke at all. Measured on Claude Code 2.1.305 in tmux: about two seconds after start the screen shows /remote-control is active · Continue here, on your phone, or at with the https://claude.ai/code/session_... URL under it, and the footer ends with /rc. The runner only looks for that URL on the screen (rc_status ok, URL in remote_url). The flag is always given the handle as its value: a bare --remote-control would take the next argument, the prompt, for a name. The row records how the run was started (rc_native). On by default; NEXOR_RUNNER_REMOTE_CONTROL=0 disables it, and remote_control: true|false in the POST /sessions / POST /restore body overrides it per call (runner new --no-remote-control for the CLI). runner new itself never waits: it marks the row pending, and the agora unit or runner serve picks the URL up. Exactly one live process owns that watch: both compete for a leadership flock on <state dir>/locks/rc-owner.lock, the holder ticks every second while a session is pending (every ~5 s otherwise) and the other stands by. A pending row is given up when the session is gone, 30 minutes after arming with no sign of Remote Control (re-armed while the session is alive, then tried every 30 s), or 24 h after arming. The outcome is stored on the session row as rc_status and reported in every session of the agora snapshot and in GET /sessions, next to rc_attention:

rc_status meaning
null not asked (disabled, or not a Claude session)
pending waiting for Remote Control to show up (or, in the typed fallback, for an idle prompt)
sent Remote Control is on, the URL has not been seen yet (still looked for)
ok the Remote Control URL was captured (remote_url)
gave_up the session ended, no sign of Remote Control for 24 h, or the session was not started with Remote Control and typing is off (rc_attention says what to do)

What the watch reads. Only the visible screen (capture-pane, no scrollback), and it sends no key at all (no Escape either). The URL is read only from the line(s) right under the newest /remote-control is active banner and must be https://claude.ai/code/session_<id>: the hyperlink target of the line (OSC 8) is preferred, else the text, joined with the next line when it reaches the right edge (a wrapped URL). A URL quoted in the transcript, an older banner, or the URL of the run before the last restore (kept aside in the row as rc_prev_url before the launch, remote_url cleared) is never taken. /rc counts as the footer only under the input box's bottom edge. A session that shows no URL two minutes after it was armed gets rc_attention (Remote Control may not be connecting: check the session and the account's policy); the note goes away with the URL. runner restore arms this watch like POST /restore does.

Folder-trust dialog. A session started in a folder Claude has not been trusted in stops at Quick safety check: Is this a project you created or one you trust?. Measured on Claude Code 2.1.305: the options are ❯ No, exit and Yes, I trust this folder, No, exit is pre-selected (an Enter exits the session), Down/Up toggle the two and number keys do nothing. The runner never sends a key to it: it only recognises it, by its structure on the visible screen (no input box on the screen, the footer Enter to confirm · Esc to cancel as the last non-empty line, a contiguous block of Yes/No options right above it, the question under a column-0 rule above them), so a quoted dialog in the transcript or a draft saying "yes, i trust this folder" is not one. The row stays pending and rc_attention (in GET /sessions and the agora snapshot) says folder trust needed — open the session and accept; once a person answers, Remote Control (already asked for by the flag) comes up by itself and the note goes away. The typed fallback stops at the dialog too (nothing typed).

Typed fallback (off by default). For a session the runner did not start itself (one imported from a foreign tmux and still running, or a row armed by an older runner) there is no flag to give. NEXOR_RUNNER_RC_TYPING=1 or runner config set rc-typing on (an empty variable defers to the config file) lets the runner type /remote-control into such a session once its input box is idle, as before. Without it such a row is not armed and not watched: the next retry gives it up (gave_up) with rc_attention not started with Remote Control: restart it with Remote Control (runner restore) or enable rc-typing (runner restore relaunches it with the flag). It is the old, careful path: the input box may hold a prompt suggestion or text somebody typed, so the runner never presses Enter blindly: it clears the input line (C-u), types /remote-control, reads the input line back from the pane, and sends Enter only if the line is exactly /remote-control. Any other line is cleared again, nothing is submitted, the attempt is retried later (not before 5 s, kept on the row) and a line without the text itself goes to the log; after 20 wrong lines it gives up at the deadline. A session that is working (its footer says it can be interrupted) is not typed into, and a typing lock (flock on <state dir>/locks/<session>.lock) keeps two processes from interleaving keystrokes. The read-back uses capture-pane -e of the visible screen only (no scrollback): a trailing faint (SGR 2) prompt suggestion, a reverse-video trailing space (the cursor) and the NBSP Claude Code draws after ❯ are ignored; text on continuation lines of a multi-line draft counts as text (only plain spaces are blank, and a | or │ is text), and a pane showing a numbered-option dialog is never typed into. The box is located from what only the program draws, in column 0 (everything a user types, continuation and wrapped lines included, is indented by two spaces; measured on Claude Code 2.1.305): the bottom border is the last line starting with a run of ─ (a hint in it is fine), the box's top edge is the nearest such line above it with a ❯ line right below (a title with the session name is fine), and a bordered │ ❯ ... │ box needs both side borders. A draft that imitates a border or a ❯ /remote-control line is therefore read as a draft, never as the bare command, and neither a stale frame higher up nor the suggestion list drawn under the box confuses it.

OpenCode: sessions connect to the machine's always-on opencode service engine (the runner ensures it is up before launch and before issuing a ticket). There is no automatic step: GET /sessions/{ref}/remote-control (or a remote-control intent from the linked agora hub) issues a fresh pairing ticket with opencode pair --remote --yes — a one-time link, valid five minutes, through the OpenTunnel public address. Opening it shows the engine's web UI, where the session can be prompted and its permission requests answered, like the terminal does. The ticket is returned to the caller and stored as the session's remote_url (the last one issued); it is never logged. The engine is per-user and the ticket reaches every session on it, so keep the runner user's account the only one running agents on the machine.

Remote access security

OpenCode remote control goes through the engine's public tunnel (OpenTunnel), which stays on whenever the engine runs. What protects it:

  • The engine has one fixed password (service.json, key password). It also works over the public address (Basic opencode:<password>), so treat it like a root credential.
  • A pairing ticket (opencode pair --remote --yes, what Remote Control issues) is one-time and valid 5 minutes. Opening it hands the device a token valid for 30 days. Tokens are signed and stateless: OpenCode has no per-device revocation. The only way to invalidate them is to change the engine password.
  • The runner therefore rotates the password. runner opencode rotate-password (or POST /opencode/rotate-password, {"rotated": true}) sets a new random 43-character secret with the official opencode service set password <value>, then starts the engine again. If that CLI key is missing or does not persist the value, it falls back to rewriting password in service.json (atomic, mode 0600, other keys kept) while the engine is stopped. The secret is never printed or logged. Before anything is touched, service.json is copied to service.json.bak-<UTC timestamp> (mode 0600, the newest three are kept); a numeric suffix (-001, -002, ...) keeps rotations of the same second apart.
  • Weekly rotation: runner systemd install --rotate [--extra-path DIRS], then systemctl --user daemon-reload && systemctl --user enable --now nexor-runner-rotate.timer (OnCalendar=weekly, Persistent=true). It is opt-in; turn it off with systemctl --user disable --now nexor-runner-rotate.timer. Every rotation invalidates all paired devices: open Remote again to pair them.
  • Lost or stolen phone: run runner opencode rotate-password now; do not wait for the timer.
  • Never send a ticket through chat apps or any shared channel: whoever opens it first gets a 30-day token.

The engine daemonizes inside the cgroup of whoever starts it, so a unit that starts it would kill it when that unit stops. The runner starts the engine (rotation and ensure_running, also from the API unit) through systemd-run --user --scope when it runs inside a unit, so restarting nexor-runner-api or the end of the rotate oneshot does not take the engine down; the rotate unit also sets KillMode=process as a second guard.

Effect on running sessions: the password change stops and restarts the engine process. Sessions started with --standalone run their own server and are not touched. Sessions the runner launched attach to the engine (opencode --session <id>); expect them to lose their connection while the engine is down, and run runner restore for any that do not come back (the state file keeps the session id, so the conversation resumes). Long-running work in the engine itself is interrupted by the restart, so rotate between tasks when possible.

Stable Agora handles for OpenCode sessions

The Agora connector gives every OpenCode session its own identity, taken from the session id on each tool call. So that a runner-launched session acts under its stable handle (gc-oc-agora, not a per-session one), the runner keeps a map {"ses_...": "<handle>"} in ~/.config/nexor-agora/opencode-sessions.json (override: NEXOR_AGORA_OC_HANDLES), which the connector re-reads whenever it changes.

  • Where the session id comes from. The runner chooses it: oc sessions are launched as opencode --session ses_<random> and the id is kept in the state file, so the entry is written right after the tmux session is created, before the first prompt is handled. restore and POST /restore rewrite it; kill removes it. When a launch replaces a stale row (its tmux session had disappeared), the entry is written again in the same state-lock hold that commits the new row: a reconcile during the startup settle still sees the old row and would otherwise leave the old id mapped until the next one.
  • Ids not known yet (rows from runner import, older state files): the agora loop reconciles every 30 s (and runner opencode map --sync on demand). It lists the engine's sessions (GET /api/session, password from service.json), picks the newest one titled with the handle (the runner titles its sessions that way) that no other row owns, stores the id in the state file and maps it. An unreachable engine only leaves those rows unmapped.
  • Rules. One handle maps to at most one session id (the newest wins and the old entry is dropped); entries for handles the runner does not know are kept; the file is written atomically (temp file plus os.replace, mode 0600, directory 0700) under a lock shared by the API unit, the agora unit and the CLI. A file that cannot be read is never overwritten; one that is not a JSON object is kept as <file>.invalid and rebuilt. Map errors are logged and never fail a launch.
runner opencode map [--sync] [--json]   # print the map (session ids and handles, no secrets)

CLIs installed with nvm

The session environment is an allow-list, so nvm directories are not on PATH. The runner resolves each client binary to an absolute path at launch, in this order:

  1. NEXOR_RUNNER_CLAUDE_BIN / NEXOR_RUNNER_OPENCODE_BIN (absolute path);
  2. NEXOR_RUNNER_EXTRA_PATH (colon-separated directories, e.g. ~/.nvm/versions/node/<v>/bin; runner systemd install --api --extra-path writes it into the API unit);
  3. the login shell (bash -lc 'command -v claude', once, cached).

The binary's directory is prepended to the session PATH so npm-installed CLIs find node. --dry-run on runner systemd install prints the detected paths.

Runner config

One file, ~/.config/nexor-runner/config.json, holds the settings every entry point shares, so the CLI in a shell, runner serve and the agora unit cannot disagree (the unit needs no environment variable for them):

runner config set cards-dir /srv/agent-cards   # where the agent cards live
runner config set host my-box                  # the name cards' home.host is compared with
runner config show [--json]                    # effective values and where each comes from
runner config unset cards-dir
runner systemd install --agora --cards-dir /srv/agent-cards   # writes the same file

Precedence, highest first: the flag (--cards-dir), the environment variable (NEXOR_RUNNER_CARDS_DIR, NEXOR_RUNNER_HOST), config.json, then the default (cards in ~/.config/nexor-runner/cards; host from the agora link name, else the system hostname). When the cards directory does not exist, runner new / runner cards say so and print how to set it.

Other variables: NEXOR_RUNNER_RESTORE_PERSONAL (restore personal sessions too), NEXOR_RUNNER_REMOTE_CONTROL, NEXOR_RUNNER_HANDLE_PREFIX, NEXOR_RUNNER_CARDS_DIR (cards directory), NEXOR_RUNNER_HOST (see Cards), NEXOR_RUNNER_TOKEN / NEXOR_RUNNER_TOKEN_FILE, NEXOR_RUNNER_CLAUDE_SESSIONS (where runner import looks for Claude session files).

Session activity

Every session row carries what the agent is doing right now, so a dashboard can tell a busy agent from a parked one without opening its terminal:

Field Values
activity working (a turn is running), idle (waiting for the next prompt), waiting (blocked on a permission/approval prompt or a question), unknown (no readable source)
activity_since ISO 8601 UTC (2026-10-19T10:00:09.000Z) of the last change of activity, or null when unknown
last_turn_end ISO 8601 UTC of the last finished turn, or null when none is known

It is in GET /sessions, in the agora snapshot (sessions[]) and in runner ls (a column, and the three fields in --json). It is computed on every listing and every agora report tick (5 s), read-only, and bounded: an unchanged transcript costs one stat, an engine answer is reused for 3 s, a pane is captured only for a session that looks busy (or has no other source) and at most once per 2 s. A source that fails makes that row unknown; it never fails a listing.

Client Source Rule
Claude Code ~/.claude/projects/<cwd-slug>/<session-id>.jsonl (CLAUDE_CONFIG_DIR moves ~/.claude), tail only, incremental system/turn_duration, an assistant entry with stop_reason: end_turn or an interrupt notice = idle. A prompt, a tool_result or an assistant entry with stop_reason: tool_use = working. Sub-agent (isSidechain) entries, slash-command output, compaction summaries and non-conversation records are ignored. A fresh session with no conversation yet is idle
Codex ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<thread-id>.jsonl (CODEX_HOME), tail only, incremental event_msg task_started = working; task_complete or turn_aborted = idle. Rollouts whose thread_source is guardian_review (the approval reviewer's own thread) are never used
OpenCode the local engine, GET only: /api/session/active, /api/session/<id>, /api/session/<id>/permission, /api/session/<id>/form id in the active set = working; a pending permission request or form = waiting; else idle. last_turn_end is the session's time.idle
any, fallback tmux capture-pane, last 30 lines a numbered approval menu (1. Yes) = waiting (also upgrades a Claude/Codex working); esc to interrupt = working; any other readable screen = idle

Notes and gaps:

  • OpenCode sessions are looked up by the row's session_id; when the engine does not know it the newest engine session titled with the handle is used (the same rule as the handle map).
  • Codex has no launcher, so a cdx row only has a rollout when its session_id is the thread id. Without one the row falls back to the pane rules above (no last_turn_end).
  • waiting for Claude Code and Codex comes from the screen only: neither writes the pending approval to its transcript. The menu patterns are heuristics; an unrecognised prompt keeps the row working.
  • When the source gives no timestamp (pane fallback, OpenCode working/waiting), activity_since is the moment this runner first saw the new activity.
  • A transcript whose last conversation entry is a prompt or a tool result is working even if the client died mid-turn; the session disappears from the list when its tmux pane does.

Concepts

Handle. A session's identity: <prefix>-<client>-<topic>[--<stream>], where prefix is 2-4 lowercase letters (the owner's initials; set NEXOR_RUNNER_HANDLE_PREFIX to let cld-<topic> be shorthand for <prefix>-cld-<topic>) and client is cld (Claude Code), oc (OpenCode) or cdx (Codex, refused for now). Examples: jd-cld-api, jd-oc-api, jd-cld-api--auth. The tmux session is named cc-<handle> and the handle is exported to the agent as NEXOR_AGORA_HANDLE. At most 3 streams (--<stream>) per topic; a stream session defaults to a smaller model than the main one.

Card. <handle>.md in the cards directory (--cards-dir, NEXOR_RUNNER_CARDS_DIR or runner config set cards-dir, see Runner config): YAML-like frontmatter plus one fenced JSON block. home.cwd is the working directory, shared by every client and stream of a topic (the card is always the cld one, <prefix>-cld-<topic>.md). home.host must equal this machine's name, ignoring case (My-Box is my-box), otherwise runner new refuses unless you pass --force; a card without home.host runs anywhere. The name is NEXOR_RUNNER_HOST, else host in config.json, else the runner name from agora.json (see below), else the system hostname. The cards directory defaults to ~/.config/nexor-runner/cards.

Scope. A card may declare scope: personal in its frontmatter (default work when absent; only personal, any case, is recognised). It marks agents the owner wants kept apart from work ones. The scope is stored on the session row at new / launch / import / restore and is re-read from the card whenever it matters (snapshot, restore, bulk stop), so editing a card takes effect without relaunching. Free (dev-*) sessions and sessions without a readable card are work.

  • runner restore skips personal rows (reported as skipped (personal) <name> on stderr; the rows stay in the state file). Pass --include-personal, or set NEXOR_RUNNER_RESTORE_PERSONAL=1, to restore them too. The CLI is the only bulk restore path (no unit restores at boot by itself); restoring one session explicitly (hub Resume, POST /restore with targets) is never filtered.
  • runner kill --scope personal|work stops every live session of that scope (the same through POST /sessions/stop-scope {"scope": "personal"} and the agora intent stop-scope).
  • The agora snapshot carries scope on every session and every card.

Free sessions. Handle dev-<slug> needs no card but needs -C <dir>; names are unique (cc-dev-<slug>-<n>) and never refused as duplicates.

Environment. Sessions start with env -i plus an allow-list (PATH, HOME, LANG, TERM, NEXOR_AGORA_*, XDG_CONFIG_HOME, ...). Variables matching *API_KEY* and ANTHROPIC_AUTH_TOKEN are refused even when requested: agents run on the subscription login.

State. ~/.local/state/nexor-runner/sessions.json. Claude sessions are started with --session-id <uuid> (stored); restore runs claude --resume <uuid>, the exact conversation. Rows without an id, and OpenCode, use --continue.

CLI reference

runner ls [--json]                         list sessions (with their activity)
runner cards [--json] [--cards-dir D]      list agent cards and whether they run
runner new <handle> ["task"] [-f FILE]     start a session
        [--model M] [-C DIR] [--cards-dir D] [--force] [--no-remote-control] [--dry-run]
runner attach <index|name|fragment>        attach to a session
runner kill <index|name|fragment>          kill a session
runner kill --scope work|personal          kill every live session of a scope
runner restore [--dry-run] [--include-personal] [--cards-dir D]
                                           re-create sessions the tmux server lost
runner import [--from-tmux default|SOCKET] [--cards-dir D] [--dry-run]
              [--only GLOB]... [--exclude GLOB]... [--prefix PREFIX]...
runner serve [--host H] [--port 7800] [--token-file F] [--init-token] [--cards-dir D]
runner link --hub URL --token-file F       link this runner to an agora hub
runner unlink                              forget the link
runner agora [--cards-dir D]               outbound loop: report to the hub, run its intents
runner opencode rotate-password            new OpenCode engine password (revokes paired devices)
runner opencode map [--sync] [--json]       session id -> handle map read by the Agora connector
runner systemd install [--api|--agora|--rotate] [--dry-run] [--tmux-bin P] [--runner-bin P]
        [--port N] [--token-file F] [--extra-path DIRS] [--cards-dir D] [--host NAME]
runner config show [--json]                print the effective settings and their source
runner config set cards-dir|host|terminal|rc-typing VALUE
                                           save a setting in config.json
runner config unset cards-dir|host|terminal|rc-typing
                                           remove it
  • new: the first task is the optional positional prompt (or -f FILE). cld runs claude --model M --name <handle> ["<task>"]. oc runs opencode --standalone [--prompt "<task>"]: the model comes from the OpenCode config (a requested model is ignored); point it at another config with XDG_CONFIG_HOME. --dry-run prints the launch command without starting anything.
  • attach / kill accept an index (order of runner ls), an exact name or a unique fragment.
  • restore skips rows whose name (or imported origin) is still alive in another tmux server, so two clients never share one conversation.
  • import is described under Migration.

API reference

Loopback only (127.0.0.1, ::1, localhost; any other --host is refused), JSON in and out, no access log. Every endpoint except GET /health needs Authorization: Bearer <token>. ref is an index, exact name or unique fragment, as in the CLI (index follows GET /sessions). Errors are {"error": "..."} with 400/401/404/405/409/413/501.

Endpoint Body / query Response
GET /health (no auth) {ok, version}
GET /sessions {sessions:[{name, uid, tmux, title, state, session_id, handle, client, model, project, command, created, last_active, port, url, activity, activity_since, last_turn_end}], count}
POST /sessions {handle, prompt?, model?, cwd?, force?, remote_control?} (cwd skips the card; dev-<slug> needs cwd; cwd must be inside the runner user's home: relative paths like dev/my-project resolve against it, ~/absolute accepted if inside, symlinks resolved, home itself refused) 201 {status:"spawned", name, uid, tmux, handle, client, model, project, port:null, url:"", message}; 409 if already running
GET /cards {cards:[{handle, mission, status, home, model, local, running, where, session, filoni} | {handle, error}], count}; sorted by handle; running is true when cc-<handle> is alive in the runner's tmux or in a foreign one (where: runner, foreign, or null); local = the card's home.host is this machine (or it has none); a card that fails to parse is {handle, error}, never a 500
GET /sessions/{ref} one session row
DELETE /sessions/{ref} (or POST /sessions/{ref}/kill) {status:"killed", name, title, known}; an unknown ref gives known:false with 200
GET /restore?query=&limit= {status:"listed", total, candidates:[{session_id, title, project, last_active, repeats}]}
POST /restore {targets?:[uuid|name|index], query?, limit?, dry_run?, remote_control?} without targets as above; with targets {status:"restored", restored:[{session_id, status, name, title, url}]}
POST /sessions/stop-scope {scope: "work"|"personal"} {status:"stopped", scope, stopped:[name], count}; idempotent; 400 for any other scope
GET /sessions/{ref}/attach {name, socket, argv, command, ssh}
GET /sessions/{ref}/remote-control Claude: {supported, automatic, mechanism, typing_fallback, keys, note} (describes how Remote Control is started: the launch flag, and the typed fallback with its keys). OpenCode: issues a fresh pair ticket and answers {supported, automatic:false, mechanism, remote_url, one_time:true, expires_in:300, note}; 502 when the engine is unavailable. 501 for Codex
POST /opencode/rotate-password {rotated: true}; 502 when the engine cannot be restarted (see Remote access security)
GET /sessions/{ref}/ttyd 501 (not implemented)

state is always running; what the agent is doing is activity (see Session activity); session_id is the Claude conversation id or the OpenCode ses_ session id (empty only for rows the runner did not launch); remote_url is the Remote Control URL (Claude) or the last pair ticket issued (OpenCode, one-time, five minutes); rc_status is the Remote Control outcome (see Remote Control: pending, sent, ok, gave_up, or null) and rc_attention says why a person has to look at the session (a folder-trust dialog waiting for an answer, no Remote Control URL after two minutes, a session not started with Remote Control; null when nothing is needed); last_active is null; url and port are empty (no web terminal).

Security model

  • Own tmux server and a dedicated systemd --user unit with env -i, an explicit PATH/HOME/LANG and UnsetEnvironment for API keys. Restarting another service cannot touch the sessions; restarting the runner's own unit does, and runner restore brings them back.
  • Sessions start from the allow-list only. *API_KEY* and ANTHROPIC_AUTH_TOKEN are never passed.
  • The API listens on loopback only, with no unix socket and no TLS: put anything that must reach it from outside behind your own authenticated proxy.
  • The bearer token is read from NEXOR_RUNNER_TOKEN, else from a file (--token-file, NEXOR_RUNNER_TOKEN_FILE, default ~/.config/nexor-runner/token) that must be mode 0600 and at least 16 characters. It is never accepted on the command line.
  • import only reads the source tmux (list-panes) and Claude session files; it never starts, kills or renames a session.

Migrating from an existing tmux-based launcher

Sessions started by an older launcher live in the default tmux server. The runner's sessions live in its own server, so the two coexist.

Old sessions stay where they are; new ones are born in the runner; agent sessions are moved one at a time.

  1. Install the runner and both units (above). Start new sessions with runner new or POST /sessions from now on.
  2. To move one agent session:
    1. runner import --dry-run and check the table (handle, cwd, model, session id).
    2. runner import records the live sessions in the state file. Nothing is started or touched, and re-running adds nothing.
    3. Close the old session (exit the client, or kill that tmux session in the default server).
    4. runner restore --dry-run, then runner restore: the session comes back in the runner with claude --resume <exact session id> and the model it was using.
    5. Remote access is re-enabled automatically (see Remote Control below).
  3. Repeat for the next session whenever convenient. restore refuses a session that is still alive in the old server, so you cannot end up with two clients on one conversation.

Agents still running in the old server

While agents are not moved yet, the runner also watches the foreign tmux servers listed in NEXOR_RUNNER_FOREIGN_TMUX (comma-separated socket names; default = the default server, which is also the default value; empty = none). GET /cards, runner cards and the agora report show a card as running when cc-<handle> is alive in the runner's server or in a foreign one (where: "runner" | "foreign" | null). Launching such a handle (POST /sessions, runner new, an agora launch intent) is refused with 409 ("already running outside the runner; move it with import+restore") unless force: true / --force (agora intents cannot force). If a foreign server cannot be probed (anything but "no server running"), launch fails closed with 503 and the card reports where: null. Filoni counting only looks at the runner's server.

Alternative: one shot

  1. runner import --dry-run, then runner import (all live sessions).
  2. Stop whatever owns the default tmux server (this kills every session in it).
  3. runner restore --dry-run, then runner restore.
  4. Remote access is re-enabled automatically in each restored Claude session.

What runner import does

runner import [--from-tmux default|<-L socket>] reads the source tmux and ~/.claude/sessions/<pid>.json (pid, sessionId, cwd, name, tmux; matched on the pane PID or one of its descendants, else on the file whose tmux names the session with a live pid). Per live claude/opencode session: handle = the session name minus cc- when it is a <prefix>- handle whose card exists, else dev-<slug> from the tmux name; cwd = pane cwd; the original tmux name is kept as origin. Claude sessions without an id are skipped with a warning; OpenCode needs a cc-<prefix>-oc-<topic> name. The model is read from the last assistant entry of ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl and passed to --model on resume (opus when the transcript has none).

Filtering. --only HANDLE_OR_GLOB and --exclude HANDLE_OR_GLOB (both repeatable) are matched with case-sensitive fnmatch against the resulting agent handle (e.g. --exclude 'tc-*'); --prefix P is shorthand for --only 'P-*'. --exclude wins over --only. Non-matching sessions are reported as excluded (also with --dry-run) and are neither recorded nor counted as imported. Empty patterns are rejected; when nothing matches, a message says so and the exit code stays 0. Sessions skipped with a warning have no handle yet and are not filtered. The HTTP API does not expose import, so there is no API counterpart.

Development

pip install -e ".[dev]" && ruff check . && pytest

Metadata

Release files for nexor-runner 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nexor-runner 0.2.0
File Size Uploaded Provenance
nexor_runner-0.2.0.tar.gz 223.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for nexor-runner 0.2.0
File Interpreter ABI Platform
nexor_runner-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 356.7 kB

Release files / nexor_runner-0.2.0.tar.gz

Download URL nexor_runner-0.2.0.tar.gz
Size 223.6 kB
Tags Source
SHA-256 checksum
How to use checksums
58a95ea100df1c8b437d12b651838e647ceafe8b7ff563b60c7c40b149e51fbe
BLAKE2b-256 checksum
How to use checksums
c9a6b24285973b281b3131773cdff57992f824f4e2d229072c823f4781b6a9b0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Release files / nexor_runner-0.2.0-py3-none-any.whl

Download URL nexor_runner-0.2.0-py3-none-any.whl
Size 133.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b42b297e2f73597c56a2a3bbb03f8a0ba168bbb02e8599b8e9912e5cd3356571
BLAKE2b-256 checksum
How to use checksums
a59eb6ec515a73b66884f84acf930e343ae1be55c9fc68e441afba633a4263f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

Security for nexor-runner 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for nexor_runner-0.2.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page