Metadata-Version: 2.5
Name: nexor-runner
Version: 0.2.2
Summary: Hosts and runs AI coding agents in tmux sessions
Project-URL: Homepage, https://github.com/nexorhq/nexor-runner
Project-URL: Repository, https://github.com/nexorhq/nexor-runner
Project-URL: Issues, https://github.com/nexorhq/nexor-runner/issues
Project-URL: Changelog, https://github.com/nexorhq/nexor-runner/blob/main/CHANGELOG.md
Author: nexor-runner contributors
License: MIT
License-File: LICENSE
Keywords: agents,claude-code,codex,nexor-agora,opencode,systemd,tmux
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

# 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:

```bash
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:

```bash
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)

```bash
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

```bash
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)

```bash
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`.

### 3. The agora link (optional)

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.

```bash
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
`--resume`d 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 not taken from a leftover banner; but a resumed session re-uses its URL, so the same URL is accepted once the footer shows the live `/rc` (the proof that this run is connected). `/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.

```bash
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` (also downgrades a Claude/Codex transcript `working` older than 30 s) |

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.

### Recommended: gradual

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

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